From 8fdac824fd251d7eb98c43d55b60a9a679663388 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:28:22 +0200 Subject: [PATCH 01/26] feat(packaging): ship Linux embedded MPV frame-copy runtime (#1200) * docs: design Linux frame-copy packaging * docs: plan Linux frame-copy packaging * feat(packaging): define Linux frame-copy profiles * fix(packaging): reject inherited profile names * feat(embedded-mpv): validate staged Linux runtime * fix(embedded-mpv): require Linux source packages * fix(embedded-mpv): harden Linux runtime staging * feat(embedded-mpv): build LGPL Linux runtime * fix(embedded-mpv): pin Linux runtime inputs * feat(embedded-mpv): build relocatable Linux helper * fix(embedded-mpv): require bundled Linux runtime * fix(embedded-mpv): make Linux runtime portable * feat(packaging): ship Linux frame-copy artifacts * fix(embedded-mpv): verify Linux helper linkage * fix(packaging): enforce Linux frame-copy isolation * fix(embedded-mpv): pin Linux display data * docs(embedded-mpv): document Linux frame-copy packaging * feat(embedded-mpv): probe Linux frame-copy runtime * test(embedded-mpv): smoke packaged Linux frame-copy * docs(embedded-mpv): clarify Linux system runtime baseline * fix(embedded-mpv): harden Linux runtime capability gate * ci: verify Linux frame-copy packages * test(embedded-mpv): harden packaged Linux smoke * test(embedded-mpv): preserve packaged GL mode * test(packaging): harden Linux package probes * fix(embedded-mpv): enable private Snap shared memory * fix(embedded-mpv): sanitize Linux helper environment * fix(packaging): enforce private Snap memory semantics * fix(packaging): reject ambiguous Snap memory metadata * fix(embedded-mpv): prioritize trusted Snap GL * fix(packaging): reject advanced Snap YAML semantics * fix(packaging): reject arbitrary Snap YAML aliases * feat(packaging): ship Linux runtime license notices * docs(embedded-mpv): document Linux runtime distribution * fix(packaging): parse Snap trailing comments safely * fix(release): gate Snap publish on public source release * fix(packaging): strip VCS metadata from source bundle * docs(packaging): clarify Linux source release gate * test(embedded-mpv): smoke missing bundled libmpv * style(embedded-mpv): format final validation inputs * fix(e2e): satisfy fixture index signature typing * fix(ci): declare fontconfig gperf generator * fix(embedded-mpv): hash runtime cache identities * fix(packaging): harden Linux frame-copy delivery * fix(packaging): tighten runtime delivery gates * fix(ci): decouple Linux runtime matrix * fix(packaging): harden Linux frame-copy delivery * fix(packaging): validate Linux frame-copy runtimes * fix(packaging): scope Snap Electron library checks * feat(packaging): ship Linux frame-copy runtimes * fix(packaging): improve Linux runtime smoke diagnostics * fix(packaging): expose bounded helper probe details * test(packaging): trace Snap EGL probe failures * fix(packaging): prefer core22 ABI in Snap helper * fix(packaging): bound helper probe capture * fix(packaging): harden Linux frame-copy releases * fix(packaging): canonicalize libplacebo submodule identity * fix(packaging): make source archive inspection portable * fix(packaging): harden Snap release verification --- .github/workflows/build-and-make.yaml | 815 ++++++-- .github/workflows/publish-snap.yaml | 269 +++ .gitignore | 1 + ...linux-embedded-mpv-frame-copy-packaging.md | 90 + AGENTS.md | 139 +- CLAUDE.md | 128 +- .../playwright.packaged.config.ts | 27 + apps/electron-backend-e2e/project.json | 20 + .../src/electron-test-fixtures.ts | 182 +- ...pv-frame-copy-packaged-filesystem.tests.ts | 288 +++ ...dded-mpv-frame-copy-packaged-filesystem.ts | 255 +++ ...d-mpv-frame-copy-packaged-fixtures.spec.ts | 191 ++ ...bedded-mpv-frame-copy-packaged-fixtures.ts | 344 +++ .../embedded-mpv-frame-copy-packaged.e2e.ts | 311 +++ apps/electron-backend/build-embedded-mpv.js | 544 ++++- .../embedded-mpv-linux-linkage.cjs | 340 +++ apps/electron-backend/native/binding.gyp | 10 +- .../native/helper/frame_helper_gl.h | 5 +- .../native/helper/mpv_frame_helper.cpp | 129 ++ apps/electron-backend/project.json | 30 +- apps/electron-backend/src/app/app.spec.ts | 32 +- ...edded-mpv-frame-copy-platform.util.spec.ts | 207 +- .../embedded-mpv-frame-copy-platform.util.ts | 96 +- .../embedded-mpv-frame-copy-runtime.ts | 16 + .../contracts.ts | 330 +++ .../development-manifest.spec.ts | 172 ++ .../flatpak-runtime.spec.ts | 148 ++ .../helper-environment.spec.ts | 369 ++++ .../helper-environment.ts | 281 +++ .../helper-failures.spec.ts | 338 +++ .../helper-launch.spec.ts | 203 ++ .../helper-launch.ts | 103 + .../manifest-policy.spec.ts | 149 ++ .../manifest-validator.ts | 267 +++ .../package-integrity.spec.ts | 246 +++ .../package-validator.ts | 238 +++ .../probe-orchestration.spec.ts | 391 ++++ .../embedded-mpv-frame-copy-runtime/probe.ts | 334 +++ .../runtime-closure-validator.ts | 95 + .../runtime-fixtures.test-helpers.ts | 306 +++ .../runtime-harness.test-helpers.ts | 74 + .../runtime.spec-data.ts | 179 ++ .../source-runtime-policy.spec.ts | 150 ++ .../source-runtime-validator.ts | 247 +++ .../trusted-snap-root.ts | 51 + .../embedded-mpv-frame-copy-runtime/types.ts | 103 + .../validation-primitives.ts | 162 ++ .../embedded-mpv-frame-copy.adapter.spec.ts | 348 ++- .../embedded-mpv-frame-copy.adapter.ts | 98 +- .../embedded-mpv-linux-linkage.spec.ts | 539 +++++ .../embedded-mpv-native-source.spec.ts | 297 ++- .../embedded-mpv-native.service.spec.ts | 121 +- .../services/embedded-mpv-native.service.ts | 66 +- .../embedded-mpv-runtime-diagnostic.spec.ts | 112 + .../embedded-mpv-runtime-diagnostic.ts | 36 + .../src/app/services/store.service.ts | 8 +- apps/electron-backend/src/main.ts | 44 +- apps/electron-backend/tsconfig.spec.json | 29 +- docs/architecture/embedded-mpv-native.md | 495 ++++- ...linux-embedded-mpv-frame-copy-packaging.md | 658 ++++++ ...mbedded-mpv-frame-copy-packaging-design.md | 263 +++ electron-builder.json | 25 + .../src/lib/embedded-mpv-session.interface.ts | 9 +- package.json | 4 +- pnpm-lock.yaml | 3 + tools/embedded-mpv/README.md | 419 +++- tools/embedded-mpv/build-linux-runtime.cjs | 1693 +++++++++++++++ tools/embedded-mpv/build-linux-runtime.mjs | 865 ++++++++ .../embedded-mpv/build-linux-runtime.test.mjs | 1638 +++++++++++++++ .../generate-linux-runtime-notices.cjs | 753 +++++++ .../generate-linux-runtime-notices.test.mjs | 338 +++ tools/embedded-mpv/linux-runtime-manifest.cjs | 999 +++++++++ .../linux-runtime-manifest.test.mjs | 1185 +++++++++++ .../linux-source-archive-contract.cjs | 181 ++ .../linux-source-archive-contract.d.cts | 26 + tools/embedded-mpv/runtime-probe-contract.cjs | 6 + .../embedded-mpv/runtime-probe-contract.d.cts | 6 + tools/embedded-mpv/stage-runtime.mjs | 396 +++- tools/packaging/asar-dependency-closure.mjs | 222 +- .../asar-dependency-closure.test.mjs | 223 ++ .../configure-linux-frame-copy-build.mjs | 263 +++ .../configure-linux-frame-copy-build.test.mjs | 866 ++++++++ tools/packaging/electron-after-pack.cjs | 175 +- .../electron-package-identity.test.mjs | 60 +- tools/packaging/embedded-mpv-arch.test.mjs | 1339 +++++++++++- .../embedded-mpv-frame-copy-files.cjs | 562 ++++- tools/packaging/embedded-mpv-packaging.cjs | 1188 ++++++++++- tools/packaging/linux-frame-copy-profile.cjs | 100 + .../linux-frame-copy-profile.test.mjs | 212 ++ .../prepare-linux-runtime-source-snapshot.cjs | 753 +++++++ ...are-linux-runtime-source-snapshot.test.mjs | 776 +++++++ tools/packaging/project.json | 45 +- .../packaging/publish-snap-workflow.test.mjs | 507 +++++ tools/packaging/release-snap-assets.cjs | 701 +++++++ tools/packaging/release-snap-assets.test.mjs | 1649 +++++++++++++++ .../packaging/release-snap-source-binding.cjs | 1765 ++++++++++++++++ .../snap-workflow-policy.test-helpers.mjs | 564 +++++ .../validate-snap-release-boundary.mjs | 108 + .../verify-electron-package-layout.mjs | 79 +- .../verify-linux-frame-copy-runtime.mjs | 1744 ++++++++++++++++ .../verify-linux-frame-copy-runtime.test.mjs | 1860 +++++++++++++++++ vendor/embedded-mpv/README.md | 39 +- 102 files changed, 36064 insertions(+), 801 deletions(-) create mode 100644 .github/workflows/publish-snap.yaml create mode 100644 .plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md create mode 100644 apps/electron-backend-e2e/playwright.packaged.config.ts create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.tests.ts create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.ts create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts create mode 100644 apps/electron-backend/embedded-mpv-linux-linkage.cjs create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-integrity.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-validator.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-closure-validator.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/validation-primitives.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts create mode 100644 docs/superpowers/plans/2026-07-17-linux-embedded-mpv-frame-copy-packaging.md create mode 100644 docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md create mode 100644 tools/embedded-mpv/build-linux-runtime.cjs create mode 100644 tools/embedded-mpv/build-linux-runtime.mjs create mode 100644 tools/embedded-mpv/build-linux-runtime.test.mjs create mode 100644 tools/embedded-mpv/generate-linux-runtime-notices.cjs create mode 100644 tools/embedded-mpv/generate-linux-runtime-notices.test.mjs create mode 100644 tools/embedded-mpv/linux-runtime-manifest.cjs create mode 100644 tools/embedded-mpv/linux-runtime-manifest.test.mjs create mode 100644 tools/embedded-mpv/linux-source-archive-contract.cjs create mode 100644 tools/embedded-mpv/linux-source-archive-contract.d.cts create mode 100644 tools/embedded-mpv/runtime-probe-contract.cjs create mode 100644 tools/embedded-mpv/runtime-probe-contract.d.cts create mode 100644 tools/packaging/configure-linux-frame-copy-build.mjs create mode 100644 tools/packaging/configure-linux-frame-copy-build.test.mjs create mode 100644 tools/packaging/linux-frame-copy-profile.cjs create mode 100644 tools/packaging/linux-frame-copy-profile.test.mjs create mode 100644 tools/packaging/prepare-linux-runtime-source-snapshot.cjs create mode 100644 tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs create mode 100644 tools/packaging/publish-snap-workflow.test.mjs create mode 100644 tools/packaging/release-snap-assets.cjs create mode 100644 tools/packaging/release-snap-assets.test.mjs create mode 100644 tools/packaging/release-snap-source-binding.cjs create mode 100644 tools/packaging/snap-workflow-policy.test-helpers.mjs create mode 100644 tools/packaging/validate-snap-release-boundary.mjs create mode 100644 tools/packaging/verify-linux-frame-copy-runtime.mjs create mode 100644 tools/packaging/verify-linux-frame-copy-runtime.test.mjs diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index bd0abee32..96395ba6e 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -12,11 +12,300 @@ on: workflow_dispatch: jobs: - build: + linux-embedded-mpv-runtime: + name: Build pinned Linux Embedded MPV runtime + runs-on: ubuntu-22.04 + timeout-minutes: 120 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Resolve Linux runtime toolchain cache key + id: linux-runtime-cache-key + shell: bash + run: | + set -euo pipefail + + sudo apt-get update + { + apt-cache policy \ + binutils build-essential cmake curl git gperf \ + libasound2-dev libdrm-dev libegl-dev libgbm-dev \ + libgl-dev libpulse-dev libva-dev make nasm \ + ninja-build patchelf perl pkg-config python3-pip \ + tar xz-utils + echo 'meson=1.7.2' + } > "${RUNNER_TEMP}/linux-runtime-toolchain.txt" + TOOLCHAIN_SHA256="$(sha256sum "${RUNNER_TEMP}/linux-runtime-toolchain.txt" | cut -d ' ' -f 1)" + SOURCE_SHA256="${{ hashFiles('tools/embedded-mpv/build-linux-runtime.cjs', 'tools/embedded-mpv/build-linux-runtime.mjs', 'tools/embedded-mpv/generate-linux-runtime-notices.cjs', 'tools/embedded-mpv/linux-runtime-manifest.cjs', 'tools/embedded-mpv/linux-source-archive-contract.cjs', 'tools/embedded-mpv/stage-runtime.mjs', 'tools/packaging/prepare-linux-runtime-source-snapshot.cjs') }}" + echo "toolchain-sha256=${TOOLCHAIN_SHA256}" >> "${GITHUB_OUTPUT}" + echo "key=linux-frame-copy-runtime-v5-ubuntu-22.04-${TOOLCHAIN_SHA256}-${SOURCE_SHA256}" >> "${GITHUB_OUTPUT}" + + - name: Restore pinned Linux runtime and immutable source inputs + id: linux-runtime-cache + uses: actions/cache@v4 + with: + path: | + vendor/embedded-mpv/linux-x64/include + vendor/embedded-mpv/linux-x64/lib + vendor/embedded-mpv/linux-x64/runtime-manifest.json + dist/linux-frame-copy-runtime-source-inputs + key: ${{ steps.linux-runtime-cache-key.outputs.key }} + + - name: Install pinned Linux runtime build dependencies + if: steps.linux-runtime-cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + + sudo apt-get install --no-install-recommends -y \ + binutils \ + build-essential \ + cmake \ + curl \ + git \ + gperf \ + libasound2-dev \ + libdrm-dev \ + libegl-dev \ + libgbm-dev \ + libgl-dev \ + libpulse-dev \ + libva-dev \ + make \ + nasm \ + ninja-build \ + patchelf \ + perl \ + pkg-config \ + python3-pip \ + tar \ + xz-utils + python3 -m pip install --user 'meson==1.7.2' + + - name: Build and stage pinned LGPL Linux runtime + if: steps.linux-runtime-cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + + export PATH="${HOME}/.local/bin:${PATH}" + export IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-build" + export RUNTIME_PREFIX="${RUNNER_TEMP}/linux-frame-copy-runtime-prefix" + + node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}" + node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}" + + export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs" + rm -rf "${SOURCE_INPUT_ROOT}" + mkdir -p \ + "${SOURCE_INPUT_ROOT}/archives" \ + "${SOURCE_INPUT_ROOT}/git" + + git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \ + submodule foreach --recursive git clean -ffdqx + git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \ + clean -ffdqx + cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/archives/." "${SOURCE_INPUT_ROOT}/archives/" + cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" "${SOURCE_INPUT_ROOT}/git/libplacebo" + node tools/embedded-mpv/generate-linux-runtime-notices.cjs collect \ + --runtime-manifest "${RUNTIME_PREFIX}/runtime-manifest.json" \ + --source-root "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources" \ + --output-root "${SOURCE_INPUT_ROOT}/license-inputs" + + - name: Generate Linux runtime notices and assemble source compliance + shell: bash + run: | + set -euo pipefail + + export RUNTIME_ROOT="${GITHUB_WORKSPACE}/vendor/embedded-mpv/linux-x64" + export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs" + export SOURCE_BUNDLE_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-sources" + export LIBPLACEBO_SOURCE_RECORD="${RUNNER_TEMP}/libplacebo-source-record.json" + + test -f "${RUNTIME_ROOT}/runtime-manifest.json" + test -d "${SOURCE_INPUT_ROOT}/archives" + test -d "${SOURCE_INPUT_ROOT}/git/libplacebo" + test -f "${SOURCE_INPUT_ROOT}/license-inputs/linux-runtime-license-inputs.json" + + rm -rf "${RUNTIME_ROOT}/notices" "${SOURCE_BUNDLE_ROOT}" + node tools/embedded-mpv/generate-linux-runtime-notices.cjs generate \ + --runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \ + --license-input-root "${SOURCE_INPUT_ROOT}/license-inputs" \ + --output-root "${RUNTIME_ROOT}/notices" + + mkdir -p \ + "${SOURCE_BUNDLE_ROOT}/archives" \ + "${SOURCE_BUNDLE_ROOT}/git" \ + "${SOURCE_BUNDLE_ROOT}/license-inputs" \ + "${SOURCE_BUNDLE_ROOT}/metadata" \ + "${SOURCE_BUNDLE_ROOT}/notices" \ + "${SOURCE_BUNDLE_ROOT}/tooling" + + cp -a "${SOURCE_INPUT_ROOT}/archives/." "${SOURCE_BUNDLE_ROOT}/archives/" + cp -a "${SOURCE_INPUT_ROOT}/license-inputs/." "${SOURCE_BUNDLE_ROOT}/license-inputs/" + cp -a "${RUNTIME_ROOT}/notices/." "${SOURCE_BUNDLE_ROOT}/notices/" + cp "${RUNTIME_ROOT}/runtime-manifest.json" "${SOURCE_BUNDLE_ROOT}/metadata/runtime-manifest.json" + node tools/packaging/prepare-linux-runtime-source-snapshot.cjs prepare \ + --runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \ + --checkout "${SOURCE_INPUT_ROOT}/git/libplacebo" \ + --output "${SOURCE_BUNDLE_ROOT}/git/libplacebo" \ + --record-output "${LIBPLACEBO_SOURCE_RECORD}" + cp \ + tools/embedded-mpv/build-linux-runtime.cjs \ + tools/embedded-mpv/build-linux-runtime.mjs \ + tools/embedded-mpv/generate-linux-runtime-notices.cjs \ + tools/embedded-mpv/linux-runtime-manifest.cjs \ + tools/embedded-mpv/linux-source-archive-contract.cjs \ + tools/embedded-mpv/stage-runtime.mjs \ + tools/packaging/prepare-linux-runtime-source-snapshot.cjs \ + "${SOURCE_BUNDLE_ROOT}/tooling/" + test -f "${SOURCE_BUNDLE_ROOT}/notices/THIRD_PARTY_NOTICES.txt" + test -f "${SOURCE_BUNDLE_ROOT}/notices/embedded-mpv-notices.json" + git rev-parse HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/iptvnator-git-revision.txt" + git diff --binary HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/local-changes.patch" + node <<'NODE' + const crypto = require('node:crypto'); + const fs = require('node:fs'); + const path = require('node:path'); + const { + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + validateLinuxRuntimeSourceSnapshot, + } = require('./tools/packaging/prepare-linux-runtime-source-snapshot.cjs'); + + const manifest = JSON.parse( + fs.readFileSync(path.join(process.env.RUNTIME_ROOT, 'runtime-manifest.json'), 'utf8') + ); + const archivesDirectory = path.join(process.env.SOURCE_BUNDLE_ROOT, 'archives'); + const archives = fs.readdirSync(archivesDirectory).sort().map((name) => { + const contents = fs.readFileSync(path.join(archivesDirectory, name)); + return { + name, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + }; + }); + const expectedArchiveHashes = Object.values(manifest.packages) + .map(({ sourceSha256 }) => sourceSha256) + .filter(Boolean) + .sort(); + const actualArchiveHashes = archives.map(({ sha256 }) => sha256).sort(); + if ( + new Set(expectedArchiveHashes).size !== expectedArchiveHashes.length || + new Set(actualArchiveHashes).size !== actualArchiveHashes.length || + archives.length !== expectedArchiveHashes.length || + JSON.stringify(actualArchiveHashes) !== JSON.stringify(expectedArchiveHashes) + ) { + throw new Error( + 'Source bundle archives must match the exact unique pinned archive hash set.' + ); + } + + const libplacebo = JSON.parse( + fs.readFileSync(process.env.LIBPLACEBO_SOURCE_RECORD, 'utf8') + ); + if ( + libplacebo.sourceGitCommit !== manifest.packages.libplacebo.sourceGitCommit || + JSON.stringify(libplacebo.sourceSubmodules) !== + JSON.stringify(manifest.packages.libplacebo.sourceSubmodules) + ) { + throw new Error('Prepared libplacebo source identity does not match the runtime manifest.'); + } + validateLinuxRuntimeSourceSnapshot(libplacebo.sourceSnapshot, { + expectedSha256: + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + }); + + const notices = JSON.parse( + fs.readFileSync( + path.join(process.env.SOURCE_BUNDLE_ROOT, 'notices', 'embedded-mpv-notices.json'), + 'utf8' + ) + ); + const repositoryRevision = fs + .readFileSync( + path.join( + process.env.SOURCE_BUNDLE_ROOT, + 'metadata', + 'iptvnator-git-revision.txt' + ), + 'utf8' + ) + .trim(); + fs.writeFileSync( + path.join(process.env.SOURCE_BUNDLE_ROOT, 'metadata', 'source-index.json'), + `${JSON.stringify( + { + schemaVersion: 3, + repositoryRevision, + sourcePackages: manifest.packages, + archives, + libplacebo, + legal: { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: notices.noticeFile, + packages: notices.packages, + }, + }, + null, + 2 + )}\n` + ); + NODE + ( + cd "${SOURCE_BUNDLE_ROOT}/archives" + sha256sum * > "../metadata/archive-sha256.txt" + ) + node tools/packaging/prepare-linux-runtime-source-snapshot.cjs assert-vcs-free \ + --directory "${SOURCE_BUNDLE_ROOT}" + + mkdir -p dist/compliance + rm -f dist/compliance/linux-frame-copy-runtime-sources.tar.xz + tar \ + --create \ + --xz \ + --sort=name \ + --mtime='UTC 1970-01-01' \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + --file dist/compliance/linux-frame-copy-runtime-sources.tar.xz \ + --directory "${SOURCE_BUNDLE_ROOT}" \ + . + rm -f "${RUNTIME_ROOT}/source-archive-binding.json" + node tools/embedded-mpv/linux-source-archive-contract.cjs create \ + --archive dist/compliance/linux-frame-copy-runtime-sources.tar.xz \ + --repository-revision "$(git rev-parse HEAD)" \ + --output "${RUNTIME_ROOT}/source-archive-binding.json" + test -s "${RUNTIME_ROOT}/source-archive-binding.json" + + - name: Upload staged Linux runtime + uses: actions/upload-artifact@v4 + with: + name: linux-embedded-mpv-runtime + path: vendor/embedded-mpv/linux-x64 + if-no-files-found: error + retention-days: 7 + + - name: Upload Linux runtime source compliance + uses: actions/upload-artifact@v4 + with: + name: linux-frame-copy-runtime-sources + path: dist/compliance/linux-frame-copy-runtime-sources.tar.xz + if-no-files-found: error + retention-days: 7 + + build-cross-platform: name: Build on ${{ matrix.os }} ${{ matrix.arch }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 strategy: + fail-fast: false matrix: include: # macOS builds - separate runners to avoid native module conflicts @@ -32,26 +321,14 @@ jobs: embedded_mpv_platform: darwin embedded_mpv_arch: arm64 embedded_mpv_build_runtime: true - # Linux and Windows - - os: linux - runner: ubuntu-22.04 - linux_profile: standard - embedded_mpv_platform: linux - embedded_mpv_arch: x64 - embedded_mpv_build_runtime: false - - os: linux - runner: ubuntu-24.04 - linux_profile: flatpak - embedded_mpv_platform: linux - embedded_mpv_arch: x64 - embedded_mpv_build_runtime: false - os: windows runner: windows-2022 + arch: x64 embedded_mpv_platform: win32 embedded_mpv_arch: x64 embedded_mpv_build_runtime: false - steps: + steps: &electron-build-steps - name: Checkout code uses: actions/checkout@v4 @@ -68,38 +345,50 @@ jobs: if: matrix.os == 'linux' run: | sudo apt-get update - sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev + sudo apt-get install --no-install-recommends -y \ + appstream \ + binutils \ + dbus-daemon \ + flatpak \ + flatpak-builder \ + libarchive-tools \ + libegl-dev \ + libgbm-dev \ + libgl-dev \ + libx11-dev \ + libxext-dev \ + mpv \ + pkg-config \ + rpm \ + snapd \ + squashfs-tools \ + xauth \ + xvfb + + - name: Configure Flatpak build runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' + run: | + set -euo pipefail - # Configure Flatpak - # 1. Add the Flathub repository (source of runtimes) flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo - - # 2. Install the standard Freedesktop Platform and SDK (required by electron-builder) - # We install version 24.08 as a safe default, electron-builder might pick what it needs flatpak install --user -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 - name: Select Linux packaging targets for CI profile if: matrix.os == 'linux' run: | - node -e " - const fs = require('fs'); - const path = 'electron-builder.json'; - const config = JSON.parse(fs.readFileSync(path, 'utf8')); - const targets = Array.isArray(config.linux?.target) ? config.linux.target : []; - const profile = '${{ matrix.linux_profile }}'; - - if (profile === 'standard') { - config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() !== 'flatpak'); - } else if (profile === 'flatpak') { - config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() === 'flatpak'); - } - - fs.writeFileSync(path, JSON.stringify(config, null, 4) + '\n'); - " + cp electron-builder.json "${RUNNER_TEMP}/electron-builder.base.json" + node tools/packaging/configure-linux-frame-copy-build.mjs --profile "${{ matrix.linux_profile }}" - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Download pinned Linux Embedded MPV runtime + if: matrix.os == 'linux' + uses: actions/download-artifact@v4 + with: + name: linux-embedded-mpv-runtime + path: vendor/embedded-mpv/linux-x64 + - name: Inject TMDB API key # No-op when the secret is unavailable (e.g. fork PRs) — the # app then requires a user-provided key for TMDB enrichment. @@ -113,12 +402,12 @@ jobs: - name: Resolve embedded MPV runtime cache key # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + 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-key shell: bash env: IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || '' }} - IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: a26e28fa55c15ac5b6209d8a9f9c3c8cb39649631a53d3c4774beeae559a078c + IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: 6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0 run: | set -euo pipefail @@ -186,7 +475,7 @@ jobs: - name: Restore embedded MPV runtime cache # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + 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 with: @@ -199,7 +488,7 @@ jobs: - name: Clear stale embedded MPV runtime files # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' + if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail @@ -229,8 +518,8 @@ jobs: env: IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL || '' }} IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || '' }} - IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: https://github.com/zhongfly/mpv-winbuild/releases/download/2026-06-14-7d245fd100/mpv-dev-lgpl-x86_64-20260614-git-7d245fd100.7z - IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: a26e28fa55c15ac5b6209d8a9f9c3c8cb39649631a53d3c4774beeae559a078c + IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: https://github.com/zhongfly/mpv-winbuild/releases/download/2026-07-17-94335ab87a/mpv-dev-lgpl-x86_64-20260717-git-94335ab87a.7z + IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: 6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0 run: | set -euo pipefail @@ -254,47 +543,11 @@ jobs: pnpm embedded-mpv:stage-runtime:windows-archive -- "${WINDOWS_RUNTIME_URL}" "${WINDOWS_RUNTIME_SHA256}" - - name: Stage Linux embedded MPV build inputs - if: matrix.os == 'linux' - shell: bash - run: | - set -euo pipefail - - RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/linux-x64/prefix" - rm -rf "${RUNTIME_PREFIX}" - mkdir -p "${RUNTIME_PREFIX}/include" - - cp -a /usr/include/mpv "${RUNTIME_PREFIX}/include/" - - LIBMPV_DEV_VERSION="$(dpkg-query -W -f='${Version}' libmpv-dev)" - MPV_VERSION="$(dpkg-query -W -f='${Version}' mpv)" - export RUNTIME_PREFIX LIBMPV_DEV_VERSION MPV_VERSION - node <<'NODE' - const fs = require('fs'); - const path = require('path'); - - const manifest = { - linuxBackend: 'process-isolated mpv --wid', - buildInputs: { - libmpvDevPackage: process.env.LIBMPV_DEV_VERSION, - mpvPackage: process.env.MPV_VERSION, - }, - sourceDistribution: - 'Linux CI build inputs come from Ubuntu runner packages. Runtime playback uses the system mpv executable; IPTVnator does not bundle or load libmpv in the Electron process on Linux.', - }; - - fs.writeFileSync( - path.join(process.env.RUNTIME_PREFIX, 'runtime-manifest.json'), - `${JSON.stringify(manifest, null, 2)}\n` - ); - NODE - - pnpm embedded-mpv:stage-runtime -- linux x64 "${RUNTIME_PREFIX}" - - name: Build backend env: IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }} IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }} + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master'))) && '1' || '0' }} run: pnpm run build:backend @@ -331,27 +584,29 @@ jobs: find dist/apps/electron-backend/native -maxdepth 1 \( -name 'mpv-2.dll' -o -name 'libmpv-2.dll' -o -name 'mpv.dll' -o -name 'libmpv.dll' \) -print -quit | grep -q . ;; linux) - node -e "const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); if (manifest.origin !== 'external-mpv-process') { throw new Error('Linux embedded MPV manifest must use external-mpv-process origin.'); }" - if find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print -quit 2>/dev/null | grep -q .; then - echo "::error::Linux embedded MPV packages must not bundle libmpv" - find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print - exit 1 - fi - if ldd dist/apps/electron-backend/native/embedded_mpv.node | grep -q 'libmpv'; then - echo "::error::Linux embedded MPV addon must not link directly to libmpv" - ldd dist/apps/electron-backend/native/embedded_mpv.node - exit 1 - fi - # The frame-copy helper is the inverse: a separate process - # that MUST link libmpv (dev-mode engine; stripped from - # packages until the bundled-runtime staging lands). - # test -f, not -x: the webpack dist asset copy drops file - # modes; consumers restore the bit (after-pack) or require - # it via the X_OK support probe. + node -e "const { execFileSync } = require('node:child_process'); const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); if (manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true || manifest.sourceArchive?.schemaVersion !== 1 || manifest.sourceArchive?.name !== 'linux-frame-copy-runtime-sources.tar.xz' || !/^[a-f0-9]{64}$/.test(manifest.sourceArchive?.sha256 ?? '') || manifest.sourceArchive?.repositoryRevision !== revision) { throw new Error('Linux embedded MPV build manifest must describe the validated source runtime and exact source archive.'); }" + test -f dist/apps/electron-backend/native/lib/libmpv.so.2 test -f dist/apps/electron-backend/native/iptvnator_mpv_helper - if ! ldd dist/apps/electron-backend/native/iptvnator_mpv_helper | grep -q 'libmpv'; then - echo "::error::Linux frame-copy helper must link libmpv" - ldd dist/apps/electron-backend/native/iptvnator_mpv_helper + test -f dist/apps/electron-backend/native/embedded_mpv_frame_reader.node + if readelf -d dist/apps/electron-backend/native/embedded_mpv.node | grep -Eq 'Shared library:.*libmpv\.so'; then + echo "::error::Linux embedded MPV addon must not link directly to libmpv" + readelf -d dist/apps/electron-backend/native/embedded_mpv.node + exit 1 + fi + if readelf -d dist/apps/electron-backend/native/embedded_mpv_frame_reader.node | grep -Eq 'Shared library:.*libmpv\.so'; then + echo "::error::Linux frame reader must not link directly to libmpv" + readelf -d dist/apps/electron-backend/native/embedded_mpv_frame_reader.node + exit 1 + fi + HELPER_DYNAMIC="$(readelf -d dist/apps/electron-backend/native/iptvnator_mpv_helper)" + if ! printf '%s\n' "${HELPER_DYNAMIC}" | grep -Eq 'Shared library: \[libmpv\.so\.2\]'; then + echo "::error::Linux frame-copy helper must need libmpv.so.2" + printf '%s\n' "${HELPER_DYNAMIC}" + exit 1 + fi + if ! printf '%s\n' "${HELPER_DYNAMIC}" | grep -Fq 'Library runpath: [$ORIGIN/lib]'; then + echo "::error::Linux frame-copy helper must keep only the relative runtime path" + printf '%s\n' "${HELPER_DYNAMIC}" exit 1 fi ;; @@ -557,6 +812,7 @@ jobs: env: IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }} IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }} + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} # TEMPORARY PR TEST: change this back to '0' after manually # testing the macOS PR artifact with Embedded MPV included. IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && github.event_name == 'pull_request')) && '1' || '0' }} @@ -567,11 +823,250 @@ jobs: env: PACKAGE_OS: ${{ matrix.os }} PACKAGE_ARCH: ${{ matrix.arch || matrix.embedded_mpv_arch || '' }} + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master'))) && '1' || '0' }} run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH" + - name: Make marker-only foreign-architecture DEB packages + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + env: + IPTVNATOR_EMBEDDED_MPV_PLATFORM: linux + IPTVNATOR_EMBEDDED_MPV_ARCH: x64 + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: '' + IPTVNATOR_REQUIRE_EMBEDDED_MPV: '1' + run: | + set -euo pipefail + + for foreign_arch in armv7l arm64; do + rm -rf dist/executables-linux-foreign + cp "${RUNNER_TEMP}/electron-builder.base.json" electron-builder.json + node tools/packaging/configure-linux-frame-copy-build.mjs \ + --foreign-deb \ + --foreign-arch "${foreign_arch}" + pnpm nx run electron-backend:make \ + --arch="${foreign_arch}" \ + --outputPath=dist/executables-linux-foreign \ + --publishPolicy=never + mapfile -t foreign_debs < <( + find dist/executables-linux-foreign -maxdepth 1 -type f -name '*.deb' -print + ) + test "${#foreign_debs[@]}" -eq 1 + case "${foreign_arch}" in + armv7l) expected_deb_arch=armhf ;; + arm64) expected_deb_arch=arm64 ;; + *) + echo "::error::Unexpected foreign DEB build architecture ${foreign_arch}" + exit 1 + ;; + esac + actual_deb_arch="$(dpkg-deb --field "${foreign_debs[0]}" Architecture)" + test "${actual_deb_arch}" = "${expected_deb_arch}" + mv "${foreign_debs[0]}" dist/executables/ + done + + - name: Verify DEB payloads and x64 system runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + run: | + set -euo pipefail + + found=false + for artifact in dist/executables/*.deb; do + test -f "${artifact}" || continue + found=true + case "$(dpkg-deb --field "${artifact}" Architecture)" in + amd64) + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --volume "$(realpath "${artifact}"):/artifact.deb:ro" \ + --workdir /workspace \ + ubuntu:24.04 \ + bash -euo pipefail -c ' + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ + binutils libegl1 libgbm1 libgl1 libgl1-mesa-dri libmpv2 \ + nodejs squashfs-tools xauth xvfb + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact /artifact.deb --profile system + ' + ;; + arm64|armhf) + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${artifact}" --profile system + ;; + *) + echo "::error::Unexpected DEB architecture in ${artifact}" + exit 1 + ;; + esac + done + test "${found}" = true + + - name: Verify RPM payload and x64 system runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + run: | + set -euo pipefail + + artifact="$(find dist/executables -maxdepth 1 -type f -name '*.rpm' -print -quit)" + test -n "${artifact}" + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --volume "$(realpath "${artifact}"):/artifact.rpm:ro" \ + --workdir /workspace \ + fedora:latest \ + bash -euo pipefail -c ' + dnf install -y \ + binutils bsdtar libglvnd-egl libglvnd-glx mesa-dri-drivers \ + mesa-libgbm mpv-libs nodejs rpm xorg-x11-server-Xvfb \ + xorg-x11-xauth + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact /artifact.rpm --profile system + ' + + - name: Verify Pacman payload and x64 system runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + run: | + set -euo pipefail + + artifact="$(find dist/executables -maxdepth 1 -type f \( -name '*.pacman' -o -name '*.pkg.tar.*' \) -print -quit)" + test -n "${artifact}" + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --volume "$(realpath "${artifact}"):/artifact.pacman:ro" \ + --workdir /workspace \ + archlinux:latest \ + bash -euo pipefail -c ' + pacman -Syu --noconfirm \ + binutils libarchive libglvnd mesa mpv nodejs xorg-server-xvfb \ + xorg-xauth + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact /artifact.pacman --profile system + ' + + - name: Verify AppImage payloads and bundled runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + shell: bash + run: | + set -euo pipefail + + found=false + for artifact in dist/executables/*.AppImage; do + test -f "${artifact}" || continue + found=true + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${artifact}" --profile portable + done + test "${found}" = true + + - name: Verify Snap payloads and strict-confinement runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + shell: bash + run: | + set -euo pipefail + + found=false + installed_x64=false + for artifact in dist/executables/*.snap; do + test -f "${artifact}" || continue + found=true + verification="$( + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${artifact}" --profile portable \ + 2>&1 | tee /dev/stderr + )" + if printf '%s\n' "${verification}" | grep -Fq 'Verified snap x64 Linux'; then + snap list mesa-core22 >/dev/null 2>&1 || sudo snap install mesa-core22 + snap list gnome-3-28-1804 >/dev/null 2>&1 || sudo snap install gnome-3-28-1804 + sudo snap install --dangerous "${artifact}" + installed_x64=true + fi + done + test "${found}" = true + test "${installed_x64}" = true + sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22 + sudo snap connect iptvnator:gnome-3-28-1804 gnome-3-28-1804:gnome-3-28-1804 + sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22 + snap connections iptvnator | awk \ + '$2 == "iptvnator:graphics-core22" && $3 == "-" { found=1 } END { exit !found }' + set +e + disconnected_probe="$( + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + snap run iptvnator --embedded-mpv-runtime-probe 2>&1 + )" + disconnected_status=$? + set -e + printf '%s\n' "${disconnected_probe}" + test "${disconnected_status}" -eq 1 + printf '%s\n' "${disconnected_probe}" | \ + grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}' + sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22 + snap connections iptvnator | awk \ + '$2 == "iptvnator:graphics-core22" && $3 == "mesa-core22:graphics-core22" { found=1 } END { exit !found }' + snap connections iptvnator | awk \ + '$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804" { found=1 } END { exit !found }' + snap connections iptvnator | awk \ + '$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory" { found=1 } END { exit !found }' + xvfb-run -a env \ + LIBGL_ALWAYS_SOFTWARE=1 \ + IPTVNATOR_TRACE_PLAYER=1 \ + EGL_LOG_LEVEL=debug \ + LIBGL_DEBUG=verbose \ + __EGL_VENDOR_LIBRARY_FILENAMES=/tmp/hostile-egl-vendor.json \ + GBM_BACKEND=/tmp/hostile-gbm \ + MESA_LOADER_DRIVER_OVERRIDE=/tmp/hostile-dri \ + LIBVA_DRIVER_NAME=/tmp/hostile-va \ + VDPAU_DRIVER_PATH=/tmp/hostile-vdpau \ + VK_DRIVER_FILES=/tmp/hostile-vulkan-driver.json \ + VK_ICD_FILENAMES=/tmp/hostile-vulkan-icd.json \ + VK_ADD_DRIVER_FILES=/tmp/hostile-vulkan-add-driver.json \ + VK_ADD_LAYER_PATH=/tmp/hostile-vulkan-layers \ + VK_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-implicit-layers \ + VK_ADD_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-add-implicit-layers \ + XDG_CONFIG_HOME=/tmp/hostile-xdg-config-home \ + XDG_CONFIG_DIRS=/tmp/hostile-xdg-config-dirs \ + XDG_DATA_HOME=/tmp/hostile-xdg-data-home \ + XDG_DATA_DIRS=/tmp/hostile-xdg-data-dirs \ + snap run iptvnator --embedded-mpv-runtime-probe + + - name: Run packaged x64 frame-copy and fallback smoke + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + env: + IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1' + IPTVNATOR_E2E_PACKAGED_EXECUTABLE: ${{ github.workspace }}/dist/executables/linux-unpacked/iptvnator + LIBGL_ALWAYS_SOFTWARE: '1' + run: | + xvfb-run -a pnpm nx run \ + electron-backend-e2e:packaged-frame-copy-smoke \ + --skip-nx-cache + + - name: Diagnose packaged x64 frame-copy hardware path + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + continue-on-error: true + env: + IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1' + IPTVNATOR_E2E_PACKAGED_EXECUTABLE: ${{ github.workspace }}/dist/executables/linux-unpacked/iptvnator + run: | + set -euo pipefail + + if [ ! -e /dev/dri/renderD128 ]; then + echo "::notice::No /dev/dri/renderD128 is available; skipping the non-blocking hardware-path diagnostic." + exit 0 + fi + ls -la /dev/dri + xvfb-run -a pnpm nx run \ + electron-backend-e2e:packaged-frame-copy-smoke \ + --skip-nx-cache + - name: Save embedded MPV runtime cache # TEMPORARY ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. @@ -584,7 +1079,7 @@ jobs: vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/runtime-manifest.json key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }} - - name: Smoke test packaged Flatpak launcher + - name: Verify Flatpak payload, launcher, and sandboxed runtime if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' shell: bash run: | @@ -596,8 +1091,12 @@ jobs: exit 1 fi + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${FLATPAK_BUNDLE}" --profile flatpak flatpak install --user --noninteractive -y "${FLATPAK_BUNDLE}" - flatpak run --command=sh com.fourgray.iptvnator -c ' + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + flatpak run --command=sh com.fourgray.iptvnator -c ' set -euo pipefail test -f /app/share/metainfo/com.fourgray.iptvnator.metainfo.xml @@ -609,6 +1108,10 @@ jobs: grep -q '\''readlink -f "$SCRIPT_PATH"'\'' "${LAUNCHER_PATH}" grep -q '\''exec "$SCRIPT_DIR/iptvnator.bin"'\'' "${LAUNCHER_PATH}" ' + xvfb-run -a dbus-run-session -- flatpak run \ + --env=LIBGL_ALWAYS_SOFTWARE=1 \ + com.fourgray.iptvnator \ + --embedded-mpv-runtime-probe - name: Upload artifacts (macOS) if: matrix.os == 'macos' @@ -622,23 +1125,31 @@ jobs: dist/executables/**/*.blockmap retention-days: 7 - - name: Upload artifacts (Linux) - if: matrix.os == 'linux' && matrix.linux_profile == 'standard' + - name: Upload system-runtime Linux artifacts + if: matrix.os == 'linux' && matrix.linux_profile == 'system' uses: actions/upload-artifact@v4 with: - name: linux-artifacts + name: linux-system-artifacts path: | - dist/executables/**/*.deb - dist/executables/**/*.rpm - dist/executables/**/*.snap - dist/executables/**/*.AppImage - dist/executables/**/*.tar.gz - dist/executables/**/*.pacman + dist/executables/*.deb + dist/executables/*.rpm + dist/executables/*.pacman + dist/executables/*.pkg.tar.* + retention-days: 7 + + - name: Upload portable-runtime Linux artifacts + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + uses: actions/upload-artifact@v4 + with: + name: linux-portable-artifacts + path: | + dist/executables/*.AppImage + dist/executables/*.snap dist/executables/**/latest-linux*.yml dist/executables/**/*.blockmap retention-days: 7 - - name: Upload artifacts (Flatpak) + - name: Upload Flatpak-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' uses: actions/upload-artifact@v4 with: @@ -660,9 +1171,44 @@ jobs: dist/executables/**/*.blockmap retention-days: 7 + build-linux: + name: Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }}) + needs: linux-embedded-mpv-runtime + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - os: linux + runner: ubuntu-22.04 + arch: x64 + linux_profile: system + embedded_mpv_platform: linux + embedded_mpv_arch: x64 + embedded_mpv_build_runtime: false + - os: linux + runner: ubuntu-22.04 + arch: x64 + linux_profile: portable + embedded_mpv_platform: linux + embedded_mpv_arch: x64 + embedded_mpv_build_runtime: false + - os: linux + runner: ubuntu-24.04 + arch: x64 + linux_profile: flatpak + embedded_mpv_platform: linux + embedded_mpv_arch: x64 + embedded_mpv_build_runtime: false + + steps: *electron-build-steps + create-release: name: Create Draft Release - needs: build + needs: + - build-cross-platform + - build-linux if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest permissions: @@ -813,15 +1359,16 @@ jobs: artifacts/macos-arm64-artifacts/*-arm64.zip artifacts/macos-arm64-artifacts/*.blockmap artifacts/latest-mac.yml - artifacts/linux-artifacts/*.AppImage - artifacts/linux-artifacts/*.deb - artifacts/linux-artifacts/*.rpm - artifacts/linux-artifacts/*.snap - artifacts/linux-artifacts/*.tar.gz - artifacts/linux-artifacts/*.pacman - artifacts/linux-artifacts/latest-linux*.yml - artifacts/linux-artifacts/*.blockmap + artifacts/linux-system-artifacts/*.deb + artifacts/linux-system-artifacts/*.rpm + artifacts/linux-system-artifacts/*.pacman + artifacts/linux-system-artifacts/*.pkg.tar.* + artifacts/linux-portable-artifacts/*.AppImage + artifacts/linux-portable-artifacts/*.snap + artifacts/linux-portable-artifacts/latest-linux*.yml + artifacts/linux-portable-artifacts/*.blockmap artifacts/linux-flatpak-artifacts/*.flatpak + artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz artifacts/windows-artifacts/*-setup.exe artifacts/windows-artifacts/*.msi artifacts/windows-artifacts/*.zip @@ -829,31 +1376,3 @@ jobs: artifacts/windows-artifacts/*.blockmap env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-snap: - name: Publish to Snapcraft Store - needs: build - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} - - steps: - - name: Download snap artifact - uses: actions/download-artifact@v4 - with: - name: linux-artifacts - path: artifacts - - - name: Setup Snapcraft - uses: samuelmeuli/action-snapcraft@v3 - - - name: Publish all snaps to edge channel - run: | - # Find and publish all snap files - for SNAP_FILE in artifacts/*.snap; do - if [ -f "$SNAP_FILE" ]; then - echo "Publishing: $SNAP_FILE" - snapcraft upload --release=edge "$SNAP_FILE" - fi - done diff --git a/.github/workflows/publish-snap.yaml b/.github/workflows/publish-snap.yaml new file mode 100644 index 000000000..b07fe5469 --- /dev/null +++ b/.github/workflows/publish-snap.yaml @@ -0,0 +1,269 @@ +name: Publish Snap after public release + +on: + release: + types: + - published + +permissions: + contents: read + +jobs: + verify-snap: + name: Verify public-release Snap assets + if: ${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }} + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + SOURCE_ARCHIVE_NAME: linux-frame-copy-runtime-sources.tar.xz + outputs: + receipt-sha256: ${{ steps.bind-transfer.outputs.receipt-sha256 }} + + steps: + - name: Checkout released tooling + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event.release.tag_name }} + persist-credentials: false + + - name: Install release source verifier + shell: bash + run: | + set -euo pipefail + + sudo apt-get update + sudo apt-get install --no-install-recommends -y \ + binutils \ + squashfs-tools \ + xz-utils + + - name: Select exact public release assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + gh api \ + --paginate \ + --slurp \ + "repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets?per_page=100" \ + > "${RUNNER_TEMP}/snap-release-assets.json" + node tools/packaging/release-snap-assets.cjs select \ + --assets-json "${RUNNER_TEMP}/snap-release-assets.json" \ + --output-json "${RUNNER_TEMP}/selected-snap-release-assets.json" + + - name: Download exact public release assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + ASSET_DIRECTORY="${RUNNER_TEMP}/snap-release-downloads" + rm -rf "${ASSET_DIRECTORY}" + mkdir -p "${ASSET_DIRECTORY}" + node -e \ + "const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of [...selected.snapAssets, selected.sourceAsset]) console.log([asset.id, asset.name].join('\\t'));" \ + "${RUNNER_TEMP}/selected-snap-release-assets.json" | + while IFS=$'\t' read -r ASSET_ID ASSET_NAME; do + gh api \ + --header "Accept: application/octet-stream" \ + "repos/${GITHUB_REPOSITORY}/releases/assets/${ASSET_ID}" \ + > "${ASSET_DIRECTORY}/${ASSET_NAME}" + done + + - name: Verify downloaded public release assets + shell: bash + run: | + set -euo pipefail + + VERIFIED_ASSET_STAGING="${RUNNER_TEMP}/verified-snap-release-assets" + SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release" + SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets" + test -s "${RUNNER_TEMP}/snap-release-downloads/${SOURCE_ARCHIVE_NAME}" + test ! -e "${VERIFIED_ASSET_STAGING}" + sudo test ! -e "${SEALED_ASSET_PARENT}" + node tools/packaging/release-snap-assets.cjs verify \ + --manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \ + --directory "${RUNNER_TEMP}/snap-release-downloads" \ + --repository-revision "$(git rev-parse HEAD)" \ + --verified-directory "${VERIFIED_ASSET_STAGING}" + sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}" + sudo mv "${VERIFIED_ASSET_STAGING}" "${SEALED_ASSET_DIRECTORY}" + sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}" + sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} + + sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} + + sudo chmod 0555 "${SEALED_ASSET_PARENT}" + + - name: Reverify sealed public release assets + shell: bash + run: | + set -euo pipefail + + VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets" + node tools/packaging/release-snap-assets.cjs verify-sealed \ + --manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \ + --directory "${VERIFIED_ASSET_DIRECTORY}" \ + --receipt "${VERIFIED_ASSET_DIRECTORY}/verified-release-assets.json" \ + --repository-revision "$(git rev-parse HEAD)" + + - name: Bind verified release transfer + id: bind-transfer + shell: bash + run: | + set -euo pipefail + + RECEIPT_PATH="/var/lib/iptvnator-snap-release/assets/verified-release-assets.json" + RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")" + RECEIPT_SHA256="${RECEIPT_RECORD%% *}" + [[ "${RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]] + printf 'receipt-sha256=%s\n' "${RECEIPT_SHA256}" >> "${GITHUB_OUTPUT}" + + - name: Transfer verified release assets + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: verified-snap-release-assets + path: /var/lib/iptvnator-snap-release/assets + if-no-files-found: error + retention-days: 1 + compression-level: 0 + include-hidden-files: true + + publish-snap: + name: Publish verified public-release Snap to edge + needs: verify-snap + if: ${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }} + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Download verified release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: verified-snap-release-assets + path: ${{ runner.temp }}/verified-snap-release-assets + + - name: Seal transferred public release assets + shell: bash + env: + EXPECTED_RECEIPT_SHA256: ${{ needs.verify-snap.outputs.receipt-sha256 }} + run: | + set -euo pipefail + + TRANSFERRED_ASSET_DIRECTORY="${RUNNER_TEMP}/verified-snap-release-assets" + SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release" + SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets" + test -d "${TRANSFERRED_ASSET_DIRECTORY}" + test ! -L "${TRANSFERRED_ASSET_DIRECTORY}" + shopt -s nullglob dotglob + TRANSFERRED_FILES=("${TRANSFERRED_ASSET_DIRECTORY}"/*) + TRANSFERRED_SNAPS=("${TRANSFERRED_ASSET_DIRECTORY}"/*.snap) + test "${#TRANSFERRED_SNAPS[@]}" -gt 0 + test "${#TRANSFERRED_FILES[@]}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 2 ))" + test -f "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz" + test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz" + test -f "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json" + test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json" + for ASSET_FILE in "${TRANSFERRED_FILES[@]}"; do + test -f "${ASSET_FILE}" + test ! -L "${ASSET_FILE}" + done + RECEIPT_PATH="${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json" + RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")" + ACTUAL_RECEIPT_SHA256="${RECEIPT_RECORD%% *}" + [[ "${EXPECTED_RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]] + test "${ACTUAL_RECEIPT_SHA256}" = "${EXPECTED_RECEIPT_SHA256}" + /usr/bin/jq --exit-status ' + type == "object" and + (keys == ["assets", "repositoryRevision", "schemaVersion"]) and + (.schemaVersion == 1) and + (.repositoryRevision | + type == "string" and test("^[a-f0-9]{40,64}$")) and + (.assets | type == "array" and length >= 2) and + (.assets | all(.[]; + type == "object" and + (keys == ["id", "name", "sha256", "size"]) and + (.id | + type == "number" and . > 0 and + . <= 9007199254740991 and . == floor) and + (.name | + type == "string" and length > 0 and + . != "." and . != ".." and + (contains("/") | not) and + (contains("\\") | not) and + (explode | all(.[]; . > 31 and . != 127))) and + (.sha256 | + type == "string" and test("^[a-f0-9]{64}$")) and + (.size | + type == "number" and . > 0 and + . <= 9007199254740991 and . == floor))) and + ([.assets[].name] | length == (unique | length)) and + ([.assets[] | + select(.name == "linux-frame-copy-runtime-sources.tar.xz")] | + length == 1) and + ([.assets[] | select(.name | endswith(".snap"))] | + length >= 1) and + (.assets | all(.[]; + .name == "linux-frame-copy-runtime-sources.tar.xz" or + (.name | endswith(".snap")))) + ' "${RECEIPT_PATH}" > /dev/null + RECEIPT_ASSET_COUNT="$(/usr/bin/jq --raw-output '.assets | length' "${RECEIPT_PATH}")" + test "${RECEIPT_ASSET_COUNT}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 1 ))" + SIZE_MANIFEST="${RUNNER_TEMP}/verified-release-asset-sizes.tsv" + CHECKSUM_MANIFEST="${RUNNER_TEMP}/verified-release-asset-checksums.txt" + umask 077 + /usr/bin/jq --raw-output \ + '.assets[] | [.name, (.size | tostring)] | @tsv' \ + "${RECEIPT_PATH}" > "${SIZE_MANIFEST}" + while IFS=$'\t' read -r ASSET_NAME EXPECTED_SIZE; do + ASSET_PATH="${TRANSFERRED_ASSET_DIRECTORY}/${ASSET_NAME}" + ACTUAL_SIZE="$(/usr/bin/stat --format=%s -- "${ASSET_PATH}")" + test "${ACTUAL_SIZE}" = "${EXPECTED_SIZE}" + done < "${SIZE_MANIFEST}" + /usr/bin/jq --raw-output \ + '.assets[] | "\(.sha256) \(.name)"' \ + "${RECEIPT_PATH}" > "${CHECKSUM_MANIFEST}" + ( + cd "${TRANSFERRED_ASSET_DIRECTORY}" + /usr/bin/sha256sum --strict --check "${CHECKSUM_MANIFEST}" + ) + rm -f "${SIZE_MANIFEST}" "${CHECKSUM_MANIFEST}" + shopt -u nullglob dotglob + sudo test ! -e "${SEALED_ASSET_PARENT}" + sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}" + sudo mv "${TRANSFERRED_ASSET_DIRECTORY}" "${SEALED_ASSET_DIRECTORY}" + sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}" + sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} + + sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} + + sudo chmod 0555 "${SEALED_ASSET_PARENT}" + + - name: Install Snapcraft + shell: bash + run: | + set -euo pipefail + + sudo snap install snapcraft --classic --channel=stable + + - name: Publish all public-release snaps to edge + shell: bash + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} + run: | + set -euo pipefail + + VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets" + STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}" + unset SNAPCRAFT_STORE_CREDENTIALS + shopt -s nullglob dotglob + SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap) + test "${#SNAP_FILES[@]}" -gt 0 + for SNAP_FILE in "${SNAP_FILES[@]}"; do + SNAP_NAME="${SNAP_FILE##*/}" + echo "Publishing public release asset: ${SNAP_NAME}" + # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke. + # GitHub Actions never promotes automatically. + SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}" + done + unset STORE_CREDENTIALS + shopt -u nullglob dotglob diff --git a/.gitignore b/.gitignore index ea63467e3..c2263e287 100644 --- a/.gitignore +++ b/.gitignore @@ -84,4 +84,5 @@ apps/electron-backend/src/app/options/electron-builder.metadata.generated.json vendor/embedded-mpv/*/bin/ vendor/embedded-mpv/*/include/ vendor/embedded-mpv/*/lib/ +vendor/embedded-mpv/*/notices/ vendor/embedded-mpv/*/runtime-manifest.json diff --git a/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md b/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md new file mode 100644 index 000000000..dd616baea --- /dev/null +++ b/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md @@ -0,0 +1,90 @@ +# Linux Embedded MPV Frame-Copy Packaging Plan + +## Audited baseline + +- Linux frame-copy already has an isolated `iptvnator_mpv_helper`, a frame + reader addon, shared controls, native-view fallback, and runtime capability + probes. +- Existing packaged Linux builds intentionally remove the helper/runtime and + retain only system `mpv --wid` native-view. +- Electron, Electron libraries, `embedded_mpv.node`, and + `embedded_mpv_frame_reader.node` must never load or link libmpv. Only the + helper may link it. +- Electron Builder produces AppImage, DEB, RPM, Pacman, Snap, and Flatpak + Linux targets. The available reproducible native/runtime toolchain is x64. + +## Decisions + +1. Support official frame-copy artifacts on Linux x64 only. Keep every non-x64 + artifact marker-only and fail closed to native-view; never accept an + architecture override that injects x64 native files. +2. Use three isolated packaging profiles: + - `system`: DEB/RPM/Pacman use declared distribution libmpv/GL dependencies + and contain no private `native/lib`. + - `portable`: AppImage/Snap contain a pinned LGPL-compatible shared-library + closure with `$ORIGIN`-relative helper loading. + - `flatpak`: Flatpak contains the same pinned closure, validated in the + exact `/app` runtime context. +3. Treat the package manifest as necessary but insufficient. Frame-copy is + available only after exact manifest/schema/profile checks, executable-mode + checks, artifact hashes, dependency-closure/process-isolation checks, and a + bounded helper runtime probe. No environment flag bypasses this gate. +4. Publish exact source archives, recursive source identities, build flags, + licenses, notices, patches/tooling, and pinned display data for bundled + runtimes. Bind every bundled x64 package manifest to the final compliance + archive bytes and released repository revision. +5. Keep Snap Store credentials isolated from release-tag code on a fresh + runner. Store publication is edge-only; candidate/stable promotion remains + manual after installed-package smoke. + +## TDD implementation phases + +1. Add failing tests for target/profile partitioning, x64 and marker-only + layouts, exact dependency declarations, RPATH/SONAME rules, executable + modes, and Electron/libmpv isolation. +2. Implement profile-aware build and packaging hooks that stage the helper, + frame reader, runtime manifest, private closure where applicable, and legal + payload without weakening native-view. +3. Add failing runtime-policy tests for missing/tampered files, wrong + architecture/profile, malformed manifests, loader failures, hostile + environments, probe timeout/output bounds, and stable fallback reasons. +4. Implement one sanitized helper environment shared by probe and playback, + including Snap graphics-provider handling and Flatpak runtime paths. +5. Add failing compliance/release tests for exact recursive submodule records, + VCS-free source inventory, archive member/type layout, source checksums, + license/notices completeness, package-to-source byte binding, sealed asset + receipts, and credential boundaries. +6. Implement deterministic source generation, package bindings, static Snap + inspection, fresh-runner artifact transfer, and minimal direct Store + upload. +7. Add packaged x64 smoke for actual frame-copy playback plus missing-runtime + native-view fallback. Run fixture-contract tests before the smoke and allow + CI llvmpipe through Chromium's GPU blocklist without bypassing the runtime + gate. +8. Update canonical architecture/maintenance documentation and mirrored + `AGENTS.md`/`CLAUDE.md` contracts. + +## Acceptance and verification matrix + +- Local/macOS: + - Nx discovery + - packaging and Electron backend unit/integration tests + - packaged-smoke fixture tests + - affected lint targets + - production backend build + - formatting, syntax, and `git diff --check` +- Linux x64 CI: + - build the pinned runtime/helper/frame reader + - verify helper links/resolves libmpv and Electron/addons do not + - extract and statically validate all six package families + - run system, portable, Snap-installed, and Flatpak application probes + - run packaged frame-copy playback and missing-runtime native-view fallback + - regenerate and bind the exact compliance source archive +- Non-x64 CI: + - build selected ARM package targets independently + - require marker-only layout and absence of every x64 native/runtime artifact +- Merge gate: + - exact-head CI green + - no unresolved review findings + - fresh code review clean + - no automatic Snap promotion beyond edge diff --git a/AGENTS.md b/AGENTS.md index a8069f79e..a81b7cba1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - - `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output + - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal - GPU/compositor debugging: @@ -216,6 +216,143 @@ Key files: - Canonical docs: `docs/architecture/player-controls-contract.md` and `docs/architecture/embedded-mpv-native.md` +## Linux Embedded MPV Packaging + +- Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM, + Pacman, Snap, and Flatpak are supported; non-x64 Linux packages must remain + marker-only and must never inherit x64 native artifacts from environment + overrides. +- Packaging runs three isolated profiles: + - `system`: DEB/RPM/Pacman, no private `native/lib`, with package + dependencies DEB=`libmpv2,libegl1,libgl1,libgbm1`, + RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and + Pacman=`mpv,libglvnd,mesa` + - `portable`: AppImage/Snap with the pinned LGPL-compatible closure + - `flatpak`: Flatpak with the same pinned closure +- The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04 + provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the + package dependency or advertising frame-copy without a compatible runtime. +- Only `iptvnator_mpv_helper` may link libmpv. The Electron executable, + Electron libraries, `embedded_mpv.node`, and + `embedded_mpv_frame_reader.node` must not load or link it. Preserve this + process-isolation contract in build, package, and smoke checks. +- `electron-backend/native{,/**/*}` is excluded from `app.asar`; `afterPack` + exclusively writes the profile-normalized unpacked native tree. Layout and + final-artifact checks must reject every archived + `/electron-backend/native/**` entry so system and marker-only packages cannot + hide stale x64 artifacts. +- Packaged addon, frame-reader, and helper discovery is package-owned + `app.asar.unpacked` only. Writable cwd/dist candidates are development-only + and must never satisfy packaged native-view support or the frame-copy gate. +- Pristine afterPack/unpacked layouts scan Electron libraries recursively. + Extracted Snap payloads exclude only the package-manager `lib/**` and + `usr/lib/**` trees that Snap overlays into the same root; every other + directory remains recursive, and Electron-library symlinks still fail + closed. +- Linux frame-copy availability is fail-closed. The packaged manifest, + artifact modes, declared bundled hashes/closure, and bounded + `--runtime-probe` must all succeed before frame-copy can relax the renderer + sandbox. Any failure reports a stable reason and falls back to native-view + without crashing; an environment flag never bypasses this gate. +- Snap is `core22`/strict and uses an exact private `shared-memory` plug plus + the `graphics-core22` content plug at an empty mode-0755 `$SNAP/graphics`, + with `mesa-core22` as default provider. It declares only the canonical + provider layouts: `/usr/share/libdrm` binds from + `$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to + `$SNAP/graphics/drirc.d`. The provider is external shared content, not part + of IPTVnator's package size, source archive, or notices. Installed-Snap CI + must prove controlled unavailable exit after disconnect, then reconnect and + prove success. The helper links `libGL.so.1` rather than `libOpenGL.so.0`. +- The probe and playback helper share one sanitized loader environment: + ambient audit, preload, library, graphics-driver, and shell-startup overrides + are removed; the validated private closure wins; trusted Snap GL, + `graphics-core22`, the core22 base x64 root, and exact GNOME-platform roots + precede generic in-snap roots. The core22 base must precede GNOME so its + `libedit.so.2` cannot be replaced by the older copy requiring + `libtinfo.so.5`. The extracted-artifact verifier removes the identical + unsafe loader/graphics/shell set before direct helper smoke while preserving + feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the + wrapper `PATH`, removes exported `BASH_FUNC_*` functions, and launches + probe/playback through the regular executable + `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or + disconnected provider returns `snap-graphics-provider-unavailable` before + helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch + runs the complete cached manifest/hash/helper gate before BrowserWindow + startup and exits with one availability JSON line. A nonzero helper exit + keeps top-level reason `helper-probe-failed`; `helperReason` is present only + for an exact protocol-v1 line carrying a fixed allowlisted reason, and its + optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid + detail suppresses both helper fields. Every probe uses an explicit 16 MiB + aggregate captured-output ceiling independent of tracing. With + `IPTVNATOR_TRACE_PLAYER=1`, a non-empty helper stderr capture is emitted + separately as one JSON-escaped stderr line whose `stderr` field is limited + to 16,384 characters and whose `truncated` field is always explicit; + trace-write failure cannot change the capability result. Installed-Snap CI + enables Mesa EGL/GL diagnostics through this bounded channel. Any loader + failure remains a stable native-view fallback, never a flag-enabled success. +- In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop + Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL + extension loader path comes from the sandbox cache. Flatpak CI must invoke + the application-level `--embedded-mpv-runtime-probe`, not a direct helper + probe that bypasses capability detection. +- The packaged x64 Playwright smoke runs its fixture-contract target first and + passes Chromium `--ignore-gpu-blocklist` so CI llvmpipe can expose WebGL2. + This launch-only flag does not bypass the manifest, hash, loader, or helper + capability gate; `--no-sandbox` remains root-only. +- Bundled Linux releases must publish the exact source archives/git records, + checksums, licenses, flags, patches, build scripts, and the pinned hwdata + `pnp.ids` input. Each bundled package carries + `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and the exact + `licenses/**` files. CI may cache immutable source inputs, but regenerates + notices and a VCS-metadata-free + `linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every + run while retaining the exact pinned six recursive libplacebo submodule + records. Each record is canonical `full-commit safe/path`; clone-depth + dependent `git describe` annotations are discarded and never form part of + the provenance identity. Its source index carries the globally sorted libplacebo + directory/file/symlink inventory; file hashes, sizes, executable bits, link + targets, aggregates, and canonical tree digest must match the trusted pinned + checkout. The archive has an exact member/type layout and its + `metadata/archive-sha256.txt` records must match the actual source archives. + Concatenated tar/xz streams are inspected past every end marker. The final + archive's SHA-256 and repository revision are copied into every bundled x64 + package manifest; system and marker-only packages carry no source-archive + binding. + Automated Snap Store publication is allowed only after a public `v*` GitHub + release contains both the Snap assets and exactly one matching source + archive. Before any upload, the workflow hashes and inspects that archive, + verifies its exact member/type set and size bounds, clean tag revision, + pinned sources including the six recursive submodule records and exact + libplacebo tree digest, legal files, and exact released tooling, then + performs bounded extraction and static package validation for every Snap. + That public-release boundary independently revalidates the exact strict + `meta/snap.yaml` graphics/shared-memory contract and enumerates + `resources/app.asar`, rejecting any archived + `electron-backend/native/**` payload before publication. Its bounded ASAR + header reader uses only Node built-ins and released local tooling, so the + clean tag checkout does not require `node_modules`. + Exactly one x64 Snap must have matching + `sourceArchive` and `sourceRuntime`; any non-x64 Snap must remain + marker-only. Checkout and the artifact-transfer actions are pinned to full + commits; checkout does not persist credentials, and repository credentials + are limited to download steps. A secretless verification job copies assets + through no-follow descriptors, checks pre/post hashes, writes an exact + receipt, repeats the complete source/package verification on a root-owned + read-only snapshot, and transfers only that data through the pinned artifact + service while its receipt digest travels separately through a job output. + The dependent publish job runs on a bounded `ubuntu-latest` runner with no + checkout or release-tag code, verifies that digest plus the exact receipt, + asset hashes, and file-only layout, root-seals the data again, and installs + Snapcraft directly. Store credentials exist only in its final fixed shell + step, which resolves no PATH command, executes no released code, and exposes + the credential only to each exact + `/snap/bin/snapcraft upload --release=edge` process. + Candidate/stable promotion is manual after installed-Snap frame-copy and + missing-runtime fallback smoke; GitHub Actions never promotes automatically. + Canonical maintenance docs: + `docs/architecture/embedded-mpv-native.md` and + `tools/embedded-mpv/README.md`. + ## Repo Skills - `iptvnator-ui-design` diff --git a/CLAUDE.md b/CLAUDE.md index a640accb1..7b404d099 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,7 @@ Useful narrower flags: - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle -- `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output +- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal For GPU/compositor debugging: @@ -617,7 +617,131 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer - External players: MPV, VLC (via IPC to Electron backend) - Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`. -- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer ``, so controls/dialogs are ordinary DOM above the video. Frame-copy is the first runtime consumer of shared `app-player-controls`: `PlayerControlsComponent` and its surface/shortcut/fullscreen collaborators own the DOM UI interactions, while the component-scoped `EmbeddedMpvControlsAdapter` maps session state and commands and coordinates correlated recording state; native-view retains the legacy fixed dock. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Backend process adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine"). +- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy +engine` (restart required) or + `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV + experiment flag): a per-session helper renders mpv offscreen (CGL on macOS, + EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the + preload frame pump uploads them to + ``. Shared `app-player-controls` owns the DOM + UI; native-view retains the legacy dock. On Linux, only + `iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the + addon, and frame reader must not. Pristine afterPack/unpacked layouts scan + Electron libraries recursively; extracted Snap payloads exclude only the + package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root. + Every other directory remains recursive, and Electron-library symlinks still + fail closed. `electron-backend/native{,/**/*}` is excluded from `app.asar`; + `afterPack` alone owns the profile-normalized unpacked native tree, and + package checks reject every archived `/electron-backend/native/**` entry. + Packaged addon, frame-reader, and helper discovery uses only package-owned + `app.asar.unpacked` paths; cwd/dist candidates remain development-only. + Official x64 packages use three separate profiles: + DEB/RPM/Pacman depend on system libmpv plus the helper's direct + EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and + Flatpak bundles the same closure. Exact system dependencies are + DEB=`libmpv2,libegl1,libgl1,libgbm1`, + RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and + Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+; + Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`. + ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the + fail-closed packaged manifest/file/hash gate and bounded `--runtime-probe`; + any failure keeps the sandbox enabled, records a stable reason, and falls + back to native-view without crashing. Snap is `core22`/strict and uses an + exact private `shared-memory` plug plus the `graphics-core22` content plug at + a real empty mode-0755 `$SNAP/graphics`, with external `mesa-core22` as the + default provider. Its only provider-data layouts bind `/usr/share/libdrm` + from `$SNAP/graphics/libdrm` and symlink `/usr/share/drirc.d` to + `$SNAP/graphics/drirc.d`. Installed-Snap CI requires controlled unavailable + status after disconnect, then reconnects and requires success. The helper + links `libGL.so.1`, and probe/playback share a sanitized loader environment + in which ambient audit, preload, library, graphics-driver, and shell-startup + overrides are removed; the validated private closure plus trusted host GL, + graphics-content, core22 base x64, and exact GNOME-platform roots have + explicit precedence. The core22 base stays ahead of GNOME so the older + `libedit.so.2` requiring `libtinfo.so.5` cannot shadow the base ABI. The + extracted-artifact verifier removes the identical unsafe loader/graphics/ + shell set before direct helper smoke while preserving selectors such as + `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the wrapper `PATH`, + removes exported `BASH_FUNC_*` functions, and + launches probe/playback through the regular executable + `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or + disconnected provider returns `snap-graphics-provider-unavailable` before + helper spawn. The packaging-only + `--embedded-mpv-runtime-probe` app switch runs the complete packaged gate + before BrowserWindow startup and emits one availability JSON line. A nonzero + helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is + present only for an exact protocol-v1 line carrying a fixed allowlisted + reason, and its optional `helperDetail` must be 1–1024 printable ASCII + characters. Invalid detail suppresses both helper fields. Every probe uses + an explicit 16 MiB aggregate captured-output ceiling independent of tracing. + With `IPTVNATOR_TRACE_PLAYER=1`, non-empty helper stderr is emitted separately + as one JSON-escaped stderr line with a 16,384-character `stderr` limit and an + explicit `truncated` field; trace-write failure cannot change availability. + Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded + channel. The exact packaged Flatpak `/app` context reconstructs only + Freedesktop Platform 24.08's immutable + `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that + application-level probe instead of the helper directly. The packaged x64 + Playwright smoke runs its fixture-contract target first and passes Chromium + `--ignore-gpu-blocklist` so CI llvmpipe exposes WebGL2; this does not bypass + the runtime gate, and `--no-sandbox` remains root-only. Bundled Linux + packages carry hash-validated + `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`. + CI caches the staged runtime plus immutable source inputs, never finished + notices or the compliance tarball; it regenerates those notices and the + VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current + checkout while preserving the exact pinned six recursive libplacebo + submodule records. Each record is canonical `full-commit safe/path`; + clone-depth dependent `git describe` annotations are discarded and never + form part of the provenance identity. Its source index carries the globally sorted libplacebo + directory/file/symlink inventory; file hashes, sizes, executable bits, link + targets, aggregates, and canonical tree digest must match the trusted pinned + checkout. The archive has an exact member/type layout and its + `metadata/archive-sha256.txt` records must match the actual source archives. + Concatenated tar/xz streams are inspected past every end marker. Every + bundled x64 package manifest binds the final archive's SHA-256 and repository + revision; system and marker-only packages do not carry that binding. Snap + Store + publication runs only from a public `v*` GitHub release that already + contains the Snap assets and exactly one source archive. Before any upload, + the workflow hashes and checks the archive's exact member/type set and size + bounds, verifies its clean tag revision, pinned sources including the six + recursive submodule records and exact libplacebo tree digest, legal payload, + and exact released tooling, then performs bounded extraction and static + validation for every Snap. That public-release boundary independently + revalidates the exact strict `meta/snap.yaml` graphics/shared-memory + contract and enumerates `resources/app.asar`, rejecting any archived + `electron-backend/native/**` payload before publication. Its bounded ASAR + header reader uses only Node built-ins and released local tooling, so the + clean tag checkout does not require `node_modules`. Exactly one x64 Snap + must have matching + `sourceArchive` and `sourceRuntime`; any non-x64 Snap remains marker-only. + Checkout and artifact-transfer actions are pinned to full commits; checkout + does not persist credentials, and repository credentials are scoped to + download steps. A secretless verification job copies assets through + no-follow descriptors, checks them before and after inspection, writes an + exact receipt, fully reverifies a root-owned read-only snapshot, and + transfers only that data through the pinned artifact service while passing + the receipt digest separately through a job output. The dependent publish + job uses a bounded `ubuntu-latest` runner with no checkout or release-tag + code, verifies that digest plus the exact receipt, asset hashes, and + file-only layout, root-seals the data again, and installs Snapcraft directly. + Its final fixed shell step alone receives the Store credential, resolves no + PATH command, executes no released code, and exposes that credential only to + each exact + `/snap/bin/snapcraft upload --release=edge` process. Candidate/stable + promotion is manual after installed-Snap frame-copy and missing-runtime + fallback smoke; GitHub Actions never promotes automatically. On Windows, + package validation requires the exact MPV DLL named by the helper's PE import + table beside the executable. + Backend adapter: + `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; + shared-controls adapter: + `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; + helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime + contracts: `docs/architecture/embedded-mpv-native.md` and + `tools/embedded-mpv/README.md`. - Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`. - Shared web picture-in-picture stays inside that default-off rollout. `PlayerController` exposes capability `pictureInPicture`, state diff --git a/apps/electron-backend-e2e/playwright.packaged.config.ts b/apps/electron-backend-e2e/playwright.packaged.config.ts new file mode 100644 index 000000000..d5f67f26a --- /dev/null +++ b/apps/electron-backend-e2e/playwright.packaged.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from '@playwright/test'; +import baseConfig from './playwright.config'; + +export default defineConfig({ + ...baseConfig, + outputDir: + '../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke', + reporter: [ + ['list'], + [ + 'html', + { + outputFolder: + '../../dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke', + }, + ], + [ + 'json', + { + outputFile: + '../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/results.json', + }, + ], + ], + timeout: 120000, + webServer: [], +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 1e886ba34..cdaa4ec01 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -12,6 +12,26 @@ "e2e": { "dependsOn": ["electron-backend:build-e2e"] }, + "packaged-frame-copy-smoke": { + "dependsOn": ["test-packaged-frame-copy-fixtures"], + "executor": "nx:run-commands", + "cache": false, + "outputs": [ + "{workspaceRoot}/dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke", + "{workspaceRoot}/dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke" + ], + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.packaged.config.ts src/embedded-mpv-frame-copy-packaged.e2e.ts" + } + }, + "test-packaged-frame-copy-fixtures": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec tsx --test src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index c0439b37f..a7c2f6c66 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -9,14 +9,18 @@ import { } from '@playwright/test'; import { createServer, Server } from 'http'; import { + accessSync, + constants as fsConstants, existsSync, mkdtempSync, + readdirSync, readFileSync, rmSync, + statSync, writeFileSync, } from 'fs'; import { tmpdir } from 'os'; -import { join, resolve } from 'path'; +import { dirname, join, resolve } from 'path'; export const workspaceRoot = resolve(__dirname, '../../..'); export const electronMainPath = join( @@ -70,7 +74,7 @@ type ElectronFixtures = { dataDir: string; }; -type LaunchElectronAppOptions = { +export type LaunchElectronAppOptions = { env?: Record; }; @@ -171,7 +175,142 @@ export async function launchElectronApp( }; } -function attachElectronProcessDiagnostics(electronApp: ElectronApplication): void { +/** + * Resolve the x64 unpacked Linux executable produced by electron-builder. + * An explicit path wins so CI can point at an AppImage/Flatpak extraction + * without relying on electron-builder's local output directory names. + */ +export function resolvePackagedLinuxExecutable( + explicitPath = process.env['IPTVNATOR_E2E_PACKAGED_EXECUTABLE'] +): string | undefined { + if (explicitPath?.trim()) { + return resolve(explicitPath.trim()); + } + + const executablesRoot = join(workspaceRoot, 'dist', 'executables'); + if (!existsSync(executablesRoot)) { + return undefined; + } + + const unpackedDirectories = readdirSync(executablesRoot, { + withFileTypes: true, + }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.startsWith('linux') && + entry.name.endsWith('-unpacked') && + !entry.name.includes('arm') + ) + .sort((left, right) => { + const leftPriority = left.name === 'linux-unpacked' ? 0 : 1; + const rightPriority = right.name === 'linux-unpacked' ? 0 : 1; + return ( + leftPriority - rightPriority || + left.name.localeCompare(right.name) + ); + }); + + for (const directory of unpackedDirectories) { + for (const executableName of ['IPTVnator', 'iptvnator']) { + const candidate = join( + executablesRoot, + directory.name, + executableName + ); + try { + accessSync(candidate, fsConstants.X_OK); + if (statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Keep looking for the next unpacked x64 layout. + } + } + } + + return undefined; +} + +export function getPackagedLinuxNativeDir(executablePath: string): string { + return join( + dirname(resolve(executablePath)), + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); +} + +export function resolvePackagedElectronLaunchArgs( + getuid: (() => number) | undefined +): string[] { + const args = ['--ignore-gpu-blocklist']; + if (typeof getuid === 'function' && getuid() === 0) { + args.push('--no-sandbox'); + } + return args; +} + +/** + * Launch a real packaged Linux executable. Unlike the regular source E2E + * launcher, this deliberately keeps Chromium's GPU path enabled and ignores + * its GPU blocklist: the frame-copy smoke sets LIBGL_ALWAYS_SOFTWARE=1, and + * CI's llvmpipe WebGL2 context must prove that the shared-memory frame reaches + * the renderer canvas. + */ +export async function launchPackagedElectronApp( + executablePath: string, + dataDir: string, + options: LaunchElectronAppOptions = {} +): Promise { + if (process.platform !== 'linux') { + throw new Error( + 'The packaged embedded-MPV launcher is available on Linux only.' + ); + } + + const resolvedExecutablePath = resolve(executablePath); + try { + accessSync(resolvedExecutablePath, fsConstants.X_OK); + if (!statSync(resolvedExecutablePath).isFile()) { + throw new Error('not a regular file'); + } + } catch (error) { + throw new Error( + `Packaged Linux executable is not a regular executable file at ${resolvedExecutablePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + const electronApp = await electron.launch({ + executablePath: resolvedExecutablePath, + args: resolvePackagedElectronLaunchArgs(process.getuid), + env: { + ...process.env, + IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: + process.env['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? '1', + ...options.env, + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }, + }); + attachElectronProcessDiagnostics(electronApp); + + const mainWindow = await findMainWindow(electronApp); + await waitForAppReady(mainWindow); + + return { + electronApp, + mainWindow, + }; +} + +function attachElectronProcessDiagnostics( + electronApp: ElectronApplication +): void { if (!process.env['CI']) { return; } @@ -235,10 +374,7 @@ async function waitForPromiseWithTimeout( return await Promise.race([ promise.then(() => true), new Promise((resolvePromise) => { - timeoutId = setTimeout( - () => resolvePromise(false), - timeoutMs - ); + timeoutId = setTimeout(() => resolvePromise(false), timeoutMs); }), ]); } finally { @@ -297,11 +433,11 @@ async function waitForAppReady(page: Page): Promise { } catch (error) { const diagnostics = await page.evaluate(() => ({ appRootLength: - document.querySelector('app-root')?.innerHTML.trim().length ?? 0, + document.querySelector('app-root')?.innerHTML.trim().length ?? + 0, baseHref: - document - .querySelector('base') - ?.getAttribute('href') ?? '', + document.querySelector('base')?.getAttribute('href') ?? + '', readyState: document.readyState, title: document.title, url: location.href, @@ -357,7 +493,7 @@ export async function importM3uPlaylistFromNativeDialog( const fileInput = dialog.locator('input[type="file"][name="playlist"]'); await fileInput.evaluate((element, selectedFilePath) => { - (element as HTMLInputElement).dataset.filePathOverride = + (element as HTMLInputElement).dataset['filePathOverride'] = selectedFilePath; }, filePath); await fileInput.setInputFiles(filePath); @@ -501,15 +637,17 @@ async function clickDialogMethodOption( label: RegExp, legacySelector?: string ): Promise { - const optionByRadio = dialog - .getByRole('radio', { name: label }) - .first(); + const optionByRadio = dialog.getByRole('radio', { name: label }).first(); if ((await optionByRadio.count()) > 0) { await optionByRadio.click(); return; } - for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) { + for (const tablistLabel of [ + 'Source method', + 'Playlist category', + 'M3U source', + ]) { const tablist = dialog .locator(`[role="tablist"][aria-label="${tablistLabel}"]`) .first(); @@ -541,9 +679,7 @@ async function clickDialogMethodOption( } if (!legacySelector) { - throw new Error( - `Could not find dialog option matching ${label}.` - ); + throw new Error(`Could not find dialog option matching ${label}.`); } await dialog.locator(legacySelector).click(); @@ -704,9 +840,7 @@ export function buildM3uContent(channels: M3uTestChannel[]): string { const attributes = [ channel.tvgId ? `tvg-id="${channel.tvgId}"` : '', channel.tvgCountry ? `tvg-country="${channel.tvgCountry}"` : '', - channel.tvgLanguage - ? `tvg-language="${channel.tvgLanguage}"` - : '', + channel.tvgLanguage ? `tvg-language="${channel.tvgLanguage}"` : '', channel.tvgName ? `tvg-name="${channel.tvgName}"` : '', channel.logo ? `tvg-logo="${channel.logo}"` : '', channel.groupTitle ? `group-title="${channel.groupTitle}"` : '', @@ -889,9 +1023,7 @@ export async function switchUnifiedCollectionContent( await clickButtonToggleOption(toggleGroup, contentLabel); } -export async function clearCurrentUnifiedCollection( - page: Page -): Promise { +export async function clearCurrentUnifiedCollection(page: Page): Promise { await page .getByRole('button', { name: /Clear .* (favorites|recently viewed)/i, diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.tests.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.tests.ts new file mode 100644 index 000000000..191f7688d --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.tests.ts @@ -0,0 +1,288 @@ +import assert = require('node:assert/strict'); +import { + chmodSync, + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; +import { + createDisposablePackagedLinuxApp, + createPackagedEntryGuard, + readPackagedRuntimeIdentity, +} from './embedded-mpv-frame-copy-packaged-filesystem'; + +const temporaryDirectories = new Set(); + +type PackageFixture = { + executablePath: string; + libmpvAliasPath: string; + libmpvSonamePath: string; + nativeDir: string; + packageRoot: string; + payloadPath: string; + runtimeManifestPath: string; +}; + +function createPackageFixture(): PackageFixture { + const packageRoot = mkdtempSync( + join(tmpdir(), 'iptvnator-packaged-fixture-source-') + ); + temporaryDirectories.add(packageRoot); + const executablePath = join(packageRoot, 'IPTVnator'); + const nativeDir = join( + packageRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const payloadPath = join(packageRoot, 'resources', 'payload.bin'); + const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json'); + const runtimeLibraryDir = join(nativeDir, 'lib'); + const libmpvSonamePath = join(runtimeLibraryDir, 'libmpv.so.2'); + const libmpvAliasPath = join(runtimeLibraryDir, 'libmpv.so'); + + mkdirSync(runtimeLibraryDir, { recursive: true }); + writeFileSync(executablePath, '#!/bin/sh\nexit 0\n'); + chmodSync(executablePath, 0o755); + writeFileSync(payloadPath, 'packaged payload'); + chmodSync(payloadPath, 0o640); + writeFileSync(libmpvSonamePath, 'packaged libmpv'); + symlinkSync('libmpv.so.2', libmpvAliasPath); + writeFileSync( + runtimeManifestPath, + JSON.stringify({ + arch: 'x64', + libmpvSoname: 'libmpv.so.2', + platform: 'linux', + profile: 'portable', + runtimeMode: 'bundled', + }) + ); + + if (process.platform !== 'win32') { + symlinkSync( + 'payload.bin', + join(packageRoot, 'resources', 'payload-link') + ); + } + + return { + executablePath, + libmpvAliasPath, + libmpvSonamePath, + nativeDir, + packageRoot, + payloadPath, + runtimeManifestPath, + }; +} + +function entryExists(entryPath: string): boolean { + try { + lstatSync(entryPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +} + +afterEach(() => { + for (const directory of temporaryDirectories) { + rmSync(directory, { force: true, recursive: true }); + } + temporaryDirectories.clear(); +}); + +describe('disposable unpacked package clone', () => { + it('requires a safe versioned libmpv target in the packaged manifest', () => { + const source = createPackageFixture(); + + assert.equal( + readPackagedRuntimeIdentity(source.nativeDir).libmpvSoname, + 'libmpv.so.2' + ); + + writeFileSync( + source.runtimeManifestPath, + JSON.stringify({ + arch: 'x64', + libmpvSoname: '../libmpv.so.2', + platform: 'linux', + profile: 'portable', + runtimeMode: 'bundled', + }) + ); + assert.throws( + () => readPackagedRuntimeIdentity(source.nativeDir), + /libmpvSoname/ + ); + }); + + it('hides and restores a cloned regular dependency without changing the source package', () => { + const source = createPackageFixture(); + const clone = createDisposablePackagedLinuxApp(source.executablePath); + temporaryDirectories.add(clone.temporaryRoot); + + const clonedLibmpvPath = join(clone.nativeDir, 'lib', 'libmpv.so.2'); + const guard = createPackagedEntryGuard(clonedLibmpvPath, { + expectedKind: 'regular-file', + hiddenDirectory: clone.temporaryRoot, + }); + + try { + assert.equal(lstatSync(clonedLibmpvPath).isFile(), true); + assert.equal( + statSync(clonedLibmpvPath).ino, + statSync(source.libmpvSonamePath).ino + ); + + guard.hide(); + + assert.equal(entryExists(clonedLibmpvPath), false); + assert.equal(lstatSync(source.libmpvSonamePath).isFile(), true); + assert.equal( + readFileSync(source.libmpvSonamePath, 'utf8'), + 'packaged libmpv' + ); + + guard.restore(); + + assert.equal(lstatSync(clonedLibmpvPath).isFile(), true); + assert.equal( + statSync(clonedLibmpvPath).ino, + statSync(source.libmpvSonamePath).ino + ); + } finally { + guard.restore(); + clone.cleanup(); + temporaryDirectories.delete(clone.temporaryRoot); + } + + assert.equal(entryExists(source.libmpvSonamePath), true); + }); + + it('hides and restores a cloned symbolic link without dereferencing it', () => { + const source = createPackageFixture(); + const clone = createDisposablePackagedLinuxApp(source.executablePath); + temporaryDirectories.add(clone.temporaryRoot); + + const clonedAliasPath = join(clone.nativeDir, 'lib', 'libmpv.so'); + const guard = createPackagedEntryGuard(clonedAliasPath, { + expectedKind: 'symbolic-link', + hiddenDirectory: clone.temporaryRoot, + }); + + try { + guard.hide(); + assert.equal(entryExists(clonedAliasPath), false); + assert.equal( + lstatSync(source.libmpvAliasPath).isSymbolicLink(), + true + ); + assert.equal(readlinkSync(source.libmpvAliasPath), 'libmpv.so.2'); + + guard.restore(); + assert.equal(lstatSync(clonedAliasPath).isSymbolicLink(), true); + assert.equal(readlinkSync(clonedAliasPath), 'libmpv.so.2'); + } finally { + guard.restore(); + clone.cleanup(); + temporaryDirectories.delete(clone.temporaryRoot); + } + }); + + it('hardlinks regular files and preserves modes, symlinks, and the source manifest', () => { + const source = createPackageFixture(); + const clone = createDisposablePackagedLinuxApp(source.executablePath); + temporaryDirectories.add(clone.temporaryRoot); + + try { + assert.notEqual(clone.packageRoot, source.packageRoot); + assert.equal( + statSync(clone.executablePath).mode & 0o777, + statSync(source.executablePath).mode & 0o777 + ); + + const clonedPayloadPath = join( + clone.packageRoot, + 'resources', + 'payload.bin' + ); + assert.equal( + statSync(clonedPayloadPath).ino, + statSync(source.payloadPath).ino + ); + assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640); + + if (process.platform !== 'win32') { + const clonedLinkPath = join( + clone.packageRoot, + 'resources', + 'payload-link' + ); + assert.equal(lstatSync(clonedLinkPath).isSymbolicLink(), true); + assert.equal(readlinkSync(clonedLinkPath), 'payload.bin'); + } + + const clonedRuntimeManifestPath = join( + clone.nativeDir, + 'embedded-mpv-runtime.json' + ); + assert.equal(existsSync(clonedRuntimeManifestPath), true); + assert.equal(existsSync(source.runtimeManifestPath), true); + } finally { + clone.cleanup(); + temporaryDirectories.delete(clone.temporaryRoot); + } + + assert.equal(existsSync(clone.temporaryRoot), false); + assert.equal(existsSync(source.runtimeManifestPath), true); + }); + + it('copies a regular file when hardlinking is unavailable', () => { + const source = createPackageFixture(); + const clone = createDisposablePackagedLinuxApp(source.executablePath, { + linkFile() { + throw Object.assign(new Error('cross-device hardlink'), { + code: 'EXDEV', + }); + }, + }); + temporaryDirectories.add(clone.temporaryRoot); + + try { + assert.equal(statSync(clone.executablePath).mode & 0o777, 0o755); + const clonedPayloadPath = join( + clone.packageRoot, + 'resources', + 'payload.bin' + ); + assert.equal( + readFileSync(clonedPayloadPath, 'utf8'), + readFileSync(source.payloadPath, 'utf8') + ); + assert.notEqual( + statSync(clonedPayloadPath).ino, + statSync(source.payloadPath).ino + ); + assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640); + } finally { + clone.cleanup(); + temporaryDirectories.delete(clone.temporaryRoot); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.ts new file mode 100644 index 000000000..69d0c0a19 --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.ts @@ -0,0 +1,255 @@ +import { + chmodSync, + copyFileSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + renameSync, + rmSync, + symlinkSync, + type Stats, +} from 'fs'; +import { tmpdir } from 'os'; +import { basename, dirname, join, resolve } from 'path'; +import { getPackagedLinuxNativeDir } from './electron-test-fixtures'; + +export type DisposablePackagedLinuxApp = { + cleanup: () => void; + executablePath: string; + nativeDir: string; + packageRoot: string; + temporaryRoot: string; +}; + +export type DisposablePackagedLinuxAppOptions = { + linkFile?: (existingPath: string, newPath: string) => void; +}; + +export type PackagedRuntimeIdentity = { + arch: string; + libmpvSoname: string; + platform: string; + profile: string; + runtimeMode: string; +}; + +export type PackagedEntryKind = 'regular-file' | 'symbolic-link'; + +export type PackagedEntryGuard = { + hide: () => void; + restore: () => void; +}; + +export type PackagedEntryGuardOptions = { + expectedKind: PackagedEntryKind; + hiddenDirectory: string; +}; + +const HARDLINK_COPY_FALLBACK_CODES = new Set([ + 'EACCES', + 'EMLINK', + 'ENOSYS', + 'ENOTSUP', + 'EPERM', + 'EXDEV', +]); +const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; + +function entryKindMatches( + stats: Stats, + expectedKind: PackagedEntryKind +): boolean { + return expectedKind === 'regular-file' + ? stats.isFile() && !stats.isSymbolicLink() + : stats.isSymbolicLink(); +} + +function entryExistsByLstat(entryPath: string): boolean { + try { + lstatSync(entryPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +} + +function clonePackagedEntry( + sourcePath: string, + destinationPath: string, + linkFile: (existingPath: string, newPath: string) => void +): void { + const sourceStats = lstatSync(sourcePath); + + if (sourceStats.isDirectory()) { + mkdirSync(destinationPath); + for (const entry of readdirSync(sourcePath)) { + clonePackagedEntry( + join(sourcePath, entry), + join(destinationPath, entry), + linkFile + ); + } + chmodSync(destinationPath, sourceStats.mode & 0o7777); + return; + } + + if (sourceStats.isSymbolicLink()) { + symlinkSync(readlinkSync(sourcePath), destinationPath); + return; + } + + if (!sourceStats.isFile()) { + throw new Error( + `Unsupported packaged runtime entry type at ${sourcePath}.` + ); + } + + try { + linkFile(sourcePath, destinationPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !HARDLINK_COPY_FALLBACK_CODES.has(code)) { + throw error; + } + copyFileSync(sourcePath, destinationPath); + chmodSync(destinationPath, sourceStats.mode & 0o7777); + } +} + +export function createDisposablePackagedLinuxApp( + executablePath: string, + options: DisposablePackagedLinuxAppOptions = {} +): DisposablePackagedLinuxApp { + const sourceExecutablePath = resolve(executablePath); + const sourcePackageRoot = dirname(sourceExecutablePath); + const temporaryRoot = mkdtempSync( + join(tmpdir(), 'iptvnator-packaged-frame-copy-') + ); + const packageRoot = join(temporaryRoot, basename(sourcePackageRoot)); + let cleaned = false; + + try { + clonePackagedEntry( + sourcePackageRoot, + packageRoot, + options.linkFile ?? linkSync + ); + } catch (error) { + rmSync(temporaryRoot, { force: true, recursive: true }); + throw error; + } + + const clonedExecutablePath = join( + packageRoot, + basename(sourceExecutablePath) + ); + return { + cleanup() { + if (cleaned) { + return; + } + rmSync(temporaryRoot, { force: true, recursive: true }); + cleaned = true; + }, + executablePath: clonedExecutablePath, + nativeDir: getPackagedLinuxNativeDir(clonedExecutablePath), + packageRoot, + temporaryRoot, + }; +} + +export function createPackagedEntryGuard( + entryPath: string, + options: PackagedEntryGuardOptions +): PackagedEntryGuard { + const guardedEntryPath = resolve(entryPath); + const hiddenDirectory = resolve(options.hiddenDirectory); + const hiddenEntryPath = join( + hiddenDirectory, + `.${basename(guardedEntryPath)}.e2e-hidden-${process.pid}` + ); + let hidden = false; + + return { + hide() { + const entryStats = lstatSync(guardedEntryPath); + if (!entryKindMatches(entryStats, options.expectedKind)) { + throw new Error( + `Packaged entry is not a ${options.expectedKind}: ${guardedEntryPath}` + ); + } + const hiddenDirectoryStats = lstatSync(hiddenDirectory); + if ( + hiddenDirectoryStats.isSymbolicLink() || + !hiddenDirectoryStats.isDirectory() + ) { + throw new Error( + `Packaged entry stash is not a regular directory: ${hiddenDirectory}` + ); + } + if (entryExistsByLstat(hiddenEntryPath)) { + throw new Error( + `Stale hidden packaged entry exists: ${hiddenEntryPath}` + ); + } + renameSync(guardedEntryPath, hiddenEntryPath); + hidden = true; + }, + restore() { + if (!hidden) { + return; + } + if (!entryExistsByLstat(hiddenEntryPath)) { + throw new Error( + `Hidden packaged entry disappeared before restore: ${hiddenEntryPath}` + ); + } + if (entryExistsByLstat(guardedEntryPath)) { + throw new Error( + `Refusing to overwrite packaged entry during restore: ${guardedEntryPath}` + ); + } + renameSync(hiddenEntryPath, guardedEntryPath); + hidden = false; + }, + }; +} + +export function readPackagedRuntimeIdentity( + nativeDir: string +): PackagedRuntimeIdentity { + const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json'); + const parsed = JSON.parse( + readFileSync(runtimeManifestPath, 'utf8') + ) as Partial; + + for (const field of [ + 'arch', + 'platform', + 'profile', + 'runtimeMode', + ] as const) { + if (typeof parsed[field] !== 'string' || !parsed[field]) { + throw new Error( + `Packaged runtime manifest ${field} is invalid at ${runtimeManifestPath}.` + ); + } + } + if ( + typeof parsed.libmpvSoname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(parsed.libmpvSoname) + ) { + throw new Error( + `Packaged runtime manifest libmpvSoname is invalid at ${runtimeManifestPath}.` + ); + } + + return parsed as PackagedRuntimeIdentity; +} diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts new file mode 100644 index 000000000..7e9130f5e --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts @@ -0,0 +1,191 @@ +import assert = require('node:assert/strict'); +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { isMeaningfulNativePlaybackSnapshot } from './embedded-mpv-frame-copy-packaged-fixtures'; +import './embedded-mpv-frame-copy-packaged-filesystem.tests'; +import { resolvePackagedElectronLaunchArgs } from './electron-test-fixtures'; +import packagedPlaywrightConfig from '../playwright.packaged.config'; + +const projectRoot = resolve(__dirname, '..'); + +describe('packaged Electron launch arguments', () => { + it('keeps WebGL enabled for software rendering while disabling the sandbox only as root', () => { + assert.deepEqual( + resolvePackagedElectronLaunchArgs(() => 1000), + ['--ignore-gpu-blocklist'] + ); + assert.deepEqual(resolvePackagedElectronLaunchArgs(undefined), [ + '--ignore-gpu-blocklist', + ]); + assert.deepEqual( + resolvePackagedElectronLaunchArgs(() => 0), + ['--ignore-gpu-blocklist', '--no-sandbox'] + ); + }); +}); + +describe('native-view playback proof', () => { + it('requires the loaded URL, a playing or paused state, and positive duration', () => { + const expectedUrl = 'http://127.0.0.1:3210/fixture.y4m'; + const baseSnapshot = { + durationSeconds: 2, + status: 'playing', + streamUrl: expectedUrl, + }; + + assert.equal( + isMeaningfulNativePlaybackSnapshot(baseSnapshot, expectedUrl), + true + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, status: 'paused' }, + `${expectedUrl}#ignored` + ), + true + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, durationSeconds: null }, + expectedUrl + ), + false + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, status: 'idle' }, + expectedUrl + ), + false + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, streamUrl: `${expectedUrl}?other=1` }, + expectedUrl + ), + false + ); + }); + + it('loads the fixture and observes playback before disposing the native session', () => { + const source = readFileSync( + join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'), + 'utf8' + ); + const fallbackStart = source.indexOf('const launchedFallbackApp'); + const captureIndex = source.indexOf( + 'installEmbeddedMpvSessionCapture', + fallbackStart + ); + const loadIndex = source.indexOf( + 'loadEmbeddedMpvPlayback', + fallbackStart + ); + const proofIndex = source.indexOf( + 'isMeaningfulNativePlaybackSnapshot', + fallbackStart + ); + const exitCodeIndex = source.indexOf( + 'electronApp.process().exitCode', + fallbackStart + ); + const disposeIndex = source.indexOf( + 'disposeEmbeddedMpvSession', + fallbackStart + ); + + assert.ok(fallbackStart >= 0); + assert.ok(captureIndex > fallbackStart); + assert.ok(loadIndex > captureIndex); + assert.ok(proofIndex > loadIndex); + assert.ok(exitCodeIndex > proofIndex); + assert.ok(disposeIndex > exitCodeIndex); + }); + + it('removes the manifest-declared libmpv target and expects the stable missing-library reason', () => { + const source = readFileSync( + join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'), + 'utf8' + ); + const manifestIdentityIndex = source.indexOf( + 'const runtimeIdentity = readPackagedRuntimeIdentity' + ); + const guardIndex = source.indexOf( + 'createPackagedEntryGuard(', + manifestIdentityIndex + ); + const sonameIndex = source.indexOf( + 'runtimeIdentity.libmpvSoname', + guardIndex + ); + const hideIndex = source.indexOf('.hide()', sonameIndex); + const fallbackStart = source.indexOf( + 'const launchedFallbackApp', + hideIndex + ); + const missingReasonIndex = source.indexOf( + "frameCopyUnavailableReason: 'runtime-library-missing'", + fallbackStart + ); + + assert.ok(manifestIdentityIndex >= 0); + assert.ok(guardIndex > manifestIdentityIndex); + assert.ok(sonameIndex > guardIndex); + assert.ok(hideIndex > sonameIndex); + assert.ok(fallbackStart > hideIndex); + assert.ok(missingReasonIndex > fallbackStart); + assert.doesNotMatch(source, /createRuntimeManifestGuard/); + assert.doesNotMatch(source, /runtimeManifest\.hide/); + }); +}); + +describe('dedicated packaged smoke target', () => { + it('inherits the GL mode from the workflow environment', () => { + const source = readFileSync( + join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'), + 'utf8' + ); + + assert.doesNotMatch(source, /LIBGL_ALWAYS_SOFTWARE\s*:/); + }); + + it('does not build the backend or start portal mock servers', () => { + const project = JSON.parse( + readFileSync(join(projectRoot, 'project.json'), 'utf8') + ) as { + targets?: Record< + string, + { + cache?: boolean; + dependsOn?: unknown; + options?: { command?: string }; + } + >; + }; + const target = project.targets?.['packaged-frame-copy-smoke']; + const packagedConfig = readFileSync( + join(projectRoot, 'playwright.packaged.config.ts'), + 'utf8' + ); + + if (!target) { + throw new Error('The packaged frame-copy smoke target is missing.'); + } + assert.equal(target.cache, false); + assert.deepEqual(target.dependsOn, [ + 'test-packaged-frame-copy-fixtures', + ]); + assert.match( + target.options?.command ?? '', + /playwright\.packaged\.config\.ts/ + ); + assert.match( + target.options?.command ?? '', + /embedded-mpv-frame-copy-packaged\.e2e\.ts/ + ); + assert.match(packagedConfig, /timeout:\s*120000/); + assert.match(packagedConfig, /webServer:\s*\[\]/); + assert.deepEqual(packagedPlaywrightConfig.webServer, []); + }); +}); diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts new file mode 100644 index 000000000..31c888631 --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts @@ -0,0 +1,344 @@ +import type { + EmbeddedMpvSession, + EmbeddedMpvSupport, +} from '@iptvnator/shared/interfaces'; +import { spawnSync } from 'child_process'; +import { createServer, type Server } from 'http'; +import sharp = require('sharp'); +import { + closeElectronApp, + expect, + type LaunchedElectronApp, +} from './electron-test-fixtures'; +import type { + DisposablePackagedLinuxApp, + PackagedEntryGuard, +} from './embedded-mpv-frame-copy-packaged-filesystem'; + +declare global { + interface Window { + __packagedEmbeddedMpvSessions?: EmbeddedMpvSession[]; + __packagedEmbeddedMpvUnsubscribe?: () => void; + } +} + +export type LocalMediaServer = { + close: () => Promise; + url: string; +}; + +function createTwoSecondY4mFixture(): Buffer { + const width = 64; + const height = 36; + const framesPerSecond = 10; + const frameCount = framesPerSecond * 2; + const yPlaneBytes = width * height; + const chromaPlaneBytes = (width / 2) * (height / 2); + const chunks: Buffer[] = [ + Buffer.from( + `YUV4MPEG2 W${width} H${height} F${framesPerSecond}:1 Ip A1:1 C420jpeg\n`, + 'ascii' + ), + ]; + + for (let index = 0; index < frameCount; index += 1) { + const evenFrame = index % 2 === 0; + chunks.push( + Buffer.from('FRAME\n', 'ascii'), + Buffer.alloc(yPlaneBytes, evenFrame ? 76 : 150), + Buffer.alloc(chromaPlaneBytes, evenFrame ? 84 : 44), + Buffer.alloc(chromaPlaneBytes, evenFrame ? 255 : 21) + ); + } + + return Buffer.concat(chunks); +} + +async function listen(server: Server): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const onError = (error: Error) => { + server.off('listening', onListening); + rejectPromise(error); + }; + const onListening = () => { + server.off('error', onError); + resolvePromise(); + }; + + server.once('error', onError); + server.once('listening', onListening); + server.listen(0, '127.0.0.1'); + }); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error) { + rejectPromise(error); + return; + } + resolvePromise(); + }); + }); +} + +export async function createLocalMediaServer(): Promise { + const body = createTwoSecondY4mFixture(); + const resourcePath = '/embedded-mpv-frame-copy-smoke.y4m'; + const server = createServer((request, response) => { + const pathname = (request.url ?? '').split('?')[0]; + if (pathname !== resourcePath) { + response.writeHead(404).end(); + return; + } + + const range = request.headers.range?.match(/^bytes=(\d+)-(\d*)$/); + if (!range) { + response.writeHead(200, { + 'Accept-Ranges': 'bytes', + 'Content-Length': body.length, + 'Content-Type': 'video/x-yuv4mpeg', + }); + response.end(request.method === 'HEAD' ? undefined : body); + return; + } + + const start = Number(range[1]); + const requestedEnd = range[2] ? Number(range[2]) : body.length - 1; + const end = Math.min(requestedEnd, body.length - 1); + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + start > end || + start >= body.length + ) { + response.writeHead(416, { + 'Content-Range': `bytes */${body.length}`, + }); + response.end(); + return; + } + + response.writeHead(206, { + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Range': `bytes ${start}-${end}/${body.length}`, + 'Content-Type': 'video/x-yuv4mpeg', + }); + response.end( + request.method === 'HEAD' + ? undefined + : body.subarray(start, end + 1) + ); + }); + + await listen(server); + const address = server.address(); + if (!address || typeof address === 'string') { + await closeServer(server); + throw new Error('Unable to resolve the local media server address.'); + } + + return { + close: () => closeServer(server), + url: `http://127.0.0.1:${address.port}${resourcePath}`, + }; +} + +export function assertNativeFallbackPrerequisites(): void { + if (!process.env['DISPLAY']) { + throw new Error( + 'The packaged native-view fallback smoke requires DISPLAY (run it under Xvfb/X11).' + ); + } + + const mpv = spawnSync('mpv', ['--version'], { + stdio: 'ignore', + timeout: 3000, + }); + if (mpv.status !== 0) { + throw new Error( + 'The packaged native-view fallback smoke requires a working system mpv CLI on PATH.' + ); + } +} + +export async function installEmbeddedMpvSessionCapture( + app: LaunchedElectronApp +): Promise { + await app.mainWindow.evaluate(() => { + window.__packagedEmbeddedMpvUnsubscribe?.(); + window.__packagedEmbeddedMpvSessions = []; + window.__packagedEmbeddedMpvUnsubscribe = + window.electron.onEmbeddedMpvSessionUpdate?.((session) => { + window.__packagedEmbeddedMpvSessions?.push(session); + }); + }); +} + +export async function installFrameCanvasAndSessionCapture( + app: LaunchedElectronApp +): Promise { + await installEmbeddedMpvSessionCapture(app); + await app.mainWindow.evaluate(() => { + document.querySelector('canvas[data-embedded-mpv-frame]')?.remove(); + const canvas = document.createElement('canvas'); + canvas.dataset['embeddedMpvFrame'] = ''; + canvas.dataset['testId'] = 'packaged-embedded-mpv-frame'; + Object.assign(canvas.style, { + background: '#000', + height: '180px', + left: '0', + position: 'fixed', + top: '0', + width: '320px', + zIndex: '2147483647', + }); + document.body.append(canvas); + }); +} + +export async function getEmbeddedMpvSupport( + app: LaunchedElectronApp +): Promise { + return app.mainWindow.evaluate(async () => { + return window.electron.getEmbeddedMpvSupport(); + }); +} + +export async function getLatestSession( + app: LaunchedElectronApp, + sessionId: string +): Promise { + return app.mainWindow.evaluate((id) => { + const sessions = + window.__packagedEmbeddedMpvSessions?.filter( + (session) => session.id === id + ) ?? []; + return sessions.at(-1) ?? null; + }, sessionId); +} + +export function isMeaningfulNativePlaybackSnapshot( + snapshot: { + durationSeconds: number | null; + error?: string; + status: string; + streamUrl: string; + } | null, + expectedUrl: string +): boolean { + if ( + !snapshot || + snapshot.error || + !['paused', 'playing'].includes(snapshot.status) || + typeof snapshot.durationSeconds !== 'number' || + !Number.isFinite(snapshot.durationSeconds) || + snapshot.durationSeconds <= 0 + ) { + return false; + } + + const normalizeUrl = (value: string): string => { + try { + const url = new URL(value); + url.hash = ''; + return url.href; + } catch { + return value.trim(); + } + }; + + return normalizeUrl(snapshot.streamUrl) === normalizeUrl(expectedUrl); +} + +export async function renderedFrameSignal( + app: LaunchedElectronApp +): Promise { + const canvas = app.mainWindow.getByTestId('packaged-embedded-mpv-frame'); + const png = await canvas.screenshot(); + const { data, info } = await sharp(png) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + let signal = 0; + + for (let offset = 0; offset < data.length; offset += info.channels) { + if (data[offset] + data[offset + 1] + data[offset + 2] > 30) { + signal += 1; + } + } + + return signal; +} + +export async function closeAndWaitForExit( + app: LaunchedElectronApp +): Promise { + const processHandle = app.electronApp.process(); + await closeElectronApp(app); + await expect + .poll( + () => + processHandle.exitCode !== null || + processHandle.signalCode !== null, + { timeout: 10000 } + ) + .toBe(true); +} + +export async function cleanupPackagedFrameCopySmoke(options: { + apps: Array; + hiddenRuntimeEntry?: PackagedEntryGuard; + media?: LocalMediaServer; + packageClone?: DisposablePackagedLinuxApp; +}): Promise { + const errors: unknown[] = []; + + for (const app of options.apps) { + if (!app) { + continue; + } + try { + await closeAndWaitForExit(app); + } catch (error) { + errors.push(error); + } + } + + if (options.hiddenRuntimeEntry) { + try { + options.hiddenRuntimeEntry.restore(); + } catch (error) { + errors.push(error); + } + } + + if (options.media) { + try { + await options.media.close(); + } catch (error) { + errors.push(error); + } + } + + if (options.packageClone) { + try { + options.packageClone.cleanup(); + } catch (error) { + errors.push(error); + } + } + + if (errors.length > 0) { + throw new Error( + `Packaged frame-copy smoke cleanup failed: ${errors + .map((error) => + error instanceof Error ? error.message : String(error) + ) + .join('; ')}` + ); + } +} diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts new file mode 100644 index 000000000..b9a43f860 --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts @@ -0,0 +1,311 @@ +import { + expect, + launchPackagedElectronApp, + resolvePackagedLinuxExecutable, + test, + type LaunchedElectronApp, +} from './electron-test-fixtures'; +import { join } from 'path'; +import { + assertNativeFallbackPrerequisites, + cleanupPackagedFrameCopySmoke, + closeAndWaitForExit, + createLocalMediaServer, + getEmbeddedMpvSupport, + getLatestSession, + installEmbeddedMpvSessionCapture, + installFrameCanvasAndSessionCapture, + isMeaningfulNativePlaybackSnapshot, + renderedFrameSignal, + type LocalMediaServer, +} from './embedded-mpv-frame-copy-packaged-fixtures'; +import { + createDisposablePackagedLinuxApp, + createPackagedEntryGuard, + readPackagedRuntimeIdentity, + type DisposablePackagedLinuxApp, + type PackagedEntryGuard, +} from './embedded-mpv-frame-copy-packaged-filesystem'; + +const PACKAGED_FRAME_COPY_REQUIRED_ENV = + 'IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY'; +const FRAME_COPY_OPT_IN_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY'; +const packagedExecutable = resolvePackagedLinuxExecutable(); +const packagedFrameCopyRequired = isTruthy( + process.env[PACKAGED_FRAME_COPY_REQUIRED_ENV] +); + +function isTruthy(value: string | undefined): boolean { + return ['1', 'true', 'yes', 'on'].includes( + (value ?? '').trim().toLowerCase() + ); +} + +// This smoke drives the public preload API directly so it exercises the real +// packaged main process, helper, frame reader, WebGL pump, and pause controls +// without coupling runtime validation to playlist import/settings UI state. +test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { + test.skip( + process.platform !== 'linux', + 'The packaged frame-copy runtime is Linux-only.' + ); + test.skip( + !packagedExecutable && !packagedFrameCopyRequired, + `Set IPTVNATOR_E2E_PACKAGED_EXECUTABLE or ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1 in the dedicated packaged-runtime job.` + ); + + test('@critical @electron @embedded-mpv uses packaged frame-copy and fails closed to native-view', async ({ + dataDir, + }) => { + expect( + process.arch, + 'The official Linux frame-copy runtime is x64-only.' + ).toBe('x64'); + expect( + packagedExecutable, + `The dedicated packaged-runtime job must provide a real unpacked x64 executable with IPTVNATOR_E2E_PACKAGED_EXECUTABLE when ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1.` + ).toBeTruthy(); + + const sourceExecutablePath = packagedExecutable as string; + assertNativeFallbackPrerequisites(); + let packageClone: DisposablePackagedLinuxApp | undefined; + let hiddenRuntimeEntry: PackagedEntryGuard | undefined; + let media: LocalMediaServer | undefined; + let frameCopyApp: LaunchedElectronApp | undefined; + let fallbackApp: LaunchedElectronApp | undefined; + + try { + packageClone = + createDisposablePackagedLinuxApp(sourceExecutablePath); + const executablePath = packageClone.executablePath; + const nativeDir = packageClone.nativeDir; + const runtimeIdentity = readPackagedRuntimeIdentity(nativeDir); + hiddenRuntimeEntry = createPackagedEntryGuard( + join(nativeDir, 'lib', runtimeIdentity.libmpvSoname), + { + expectedKind: 'regular-file', + hiddenDirectory: packageClone.temporaryRoot, + } + ); + const mediaServer = await createLocalMediaServer(); + media = mediaServer; + + expect(runtimeIdentity).toMatchObject({ + arch: 'x64', + platform: 'linux', + runtimeMode: 'bundled', + }); + expect(['portable', 'flatpak']).toContain(runtimeIdentity.profile); + + const launchedFrameCopyApp = await launchPackagedElectronApp( + executablePath, + dataDir, + { + env: { + [FRAME_COPY_OPT_IN_ENV]: '1', + }, + } + ); + frameCopyApp = launchedFrameCopyApp; + + await expect + .poll(() => getEmbeddedMpvSupport(launchedFrameCopyApp)) + .toMatchObject({ + engine: 'frame-copy', + frameCopyAvailable: true, + platform: 'linux', + supported: true, + }); + + await installFrameCanvasAndSessionCapture(launchedFrameCopyApp); + const created = await launchedFrameCopyApp.mainWindow.evaluate( + async () => { + return window.electron.createEmbeddedMpvSession( + { x: 0, y: 0, width: 320, height: 180 }, + 'Packaged frame-copy smoke', + 0 + ); + } + ); + + await launchedFrameCopyApp.mainWindow.evaluate( + async ({ sessionId, streamUrl }) => { + await window.electron.setEmbeddedMpvPaused(sessionId, true); + await window.electron.loadEmbeddedMpvPlayback(sessionId, { + streamUrl, + title: 'Two-second generated Y4M fixture', + isLive: false, + }); + }, + { sessionId: created.id, streamUrl: mediaServer.url } + ); + + await expect + .poll( + () => getLatestSession(launchedFrameCopyApp, created.id), + { + timeout: 15000, + } + ) + .toMatchObject({ + status: 'paused', + streamUrl: mediaServer.url, + videoHeight: 36, + videoWidth: 64, + }); + + const attached = await launchedFrameCopyApp.mainWindow.evaluate( + async (sessionId) => { + return window.electron.attachEmbeddedMpvFrameView?.( + sessionId + ); + }, + created.id + ); + expect(attached).toBe(true); + await expect( + launchedFrameCopyApp.mainWindow.getByTestId( + 'packaged-embedded-mpv-frame' + ) + ).toHaveAttribute('width', '320'); + await expect( + launchedFrameCopyApp.mainWindow.getByTestId( + 'packaged-embedded-mpv-frame' + ) + ).toHaveAttribute('height', '180'); + await expect + .poll(() => renderedFrameSignal(launchedFrameCopyApp), { + timeout: 15000, + }) + .toBeGreaterThan(0); + + await launchedFrameCopyApp.mainWindow.evaluate( + (sessionId) => + window.electron.setEmbeddedMpvPaused(sessionId, false), + created.id + ); + await expect + .poll( + async () => + ( + await getLatestSession( + launchedFrameCopyApp, + created.id + ) + )?.status, + { timeout: 10000 } + ) + .toBe('playing'); + + await launchedFrameCopyApp.mainWindow.evaluate( + (sessionId) => + window.electron.setEmbeddedMpvPaused(sessionId, true), + created.id + ); + await expect + .poll( + async () => + ( + await getLatestSession( + launchedFrameCopyApp, + created.id + ) + )?.status, + { timeout: 10000 } + ) + .toBe('paused'); + await launchedFrameCopyApp.mainWindow.evaluate( + async (sessionId) => { + window.electron.detachEmbeddedMpvFrameView?.(); + await window.electron.disposeEmbeddedMpvSession(sessionId); + window.__packagedEmbeddedMpvUnsubscribe?.(); + }, + created.id + ); + + await closeAndWaitForExit(launchedFrameCopyApp); + frameCopyApp = undefined; + + hiddenRuntimeEntry.hide(); + expect(readPackagedRuntimeIdentity(nativeDir)).toEqual( + runtimeIdentity + ); + + const launchedFallbackApp = await launchPackagedElectronApp( + executablePath, + dataDir, + { + env: { + [FRAME_COPY_OPT_IN_ENV]: '1', + }, + } + ); + fallbackApp = launchedFallbackApp; + const fallbackSupport = + await getEmbeddedMpvSupport(launchedFallbackApp); + + expect(fallbackSupport).toMatchObject({ + engine: 'native', + frameCopyAvailable: false, + frameCopyUnavailableReason: 'runtime-library-missing', + platform: 'linux', + supported: true, + }); + + await installEmbeddedMpvSessionCapture(launchedFallbackApp); + const nativeSession = await launchedFallbackApp.mainWindow.evaluate( + async () => { + return window.electron.createEmbeddedMpvSession( + { x: 0, y: 0, width: 320, height: 180 }, + 'Native-view fallback smoke', + 0 + ); + } + ); + expect(nativeSession.id).toMatch(/^embedded-mpv-/); + await launchedFallbackApp.mainWindow.evaluate( + async ({ sessionId, streamUrl }) => { + await window.electron.loadEmbeddedMpvPlayback(sessionId, { + streamUrl, + title: 'Native-view generated Y4M fixture', + isLive: false, + }); + }, + { sessionId: nativeSession.id, streamUrl: mediaServer.url } + ); + await expect + .poll( + async () => + isMeaningfulNativePlaybackSnapshot( + await getLatestSession( + launchedFallbackApp, + nativeSession.id + ), + mediaServer.url + ), + { timeout: 15000 } + ) + .toBe(true); + expect( + launchedFallbackApp.electronApp.process().exitCode + ).toBeNull(); + expect( + launchedFallbackApp.electronApp.process().signalCode + ).toBeNull(); + await launchedFallbackApp.mainWindow.evaluate(async (sessionId) => { + await window.electron.disposeEmbeddedMpvSession(sessionId); + window.__packagedEmbeddedMpvUnsubscribe?.(); + }, nativeSession.id); + await expect + .poll(() => launchedFallbackApp.mainWindow.title()) + .toContain('IPTVnator'); + } finally { + await cleanupPackagedFrameCopySmoke({ + apps: [frameCopyApp, fallbackApp], + hiddenRuntimeEntry, + media, + packageClone, + }); + } + }); +}); diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index 432f46486..eac33083a 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); @@ -10,6 +11,25 @@ const { const { removeStaleFrameCopyArtifacts, } = require('../../tools/packaging/embedded-mpv-frame-copy-files.cjs'); +const { + isLinuxSystemBuildInputManifest, + validateLinuxRuntimeManifest, + validateLinuxSystemBuildInputManifest, +} = require('../../tools/embedded-mpv/linux-runtime-manifest.cjs'); +const { + SOURCE_ARCHIVE_BINDING_NAME, + validateLinuxSourceArchiveBinding, +} = require('../../tools/embedded-mpv/linux-source-archive-contract.cjs'); +const { + resolveLinuxFrameCopyLinkageInputs, + resolveVerifiedLinuxLibMpvSoname, + runWithCleanup, + validateLinuxFrameCopyLinkage, +} = require('./embedded-mpv-linux-linkage.cjs'); + +const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']); +const LINUX_STAGED_RUNTIME_ORIGIN = 'vendored-lgpl'; +const LINUX_SOURCE_RUNTIME_ORIGIN = 'vendored-lgpl-source-build'; const workspaceRoot = process.cwd(); const addonRoot = path.join( @@ -109,6 +129,158 @@ function readRuntimeManifest(runtimeRoot) { return JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } +function readLinuxSourceArchiveBinding(runtimeRoot, errors) { + const bindingPath = path.join(runtimeRoot, SOURCE_ARCHIVE_BINDING_NAME); + if (!fs.existsSync(bindingPath)) { + return null; + } + let binding; + try { + const stat = fs.lstatSync(bindingPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('binding must be a regular file'); + } + binding = JSON.parse(fs.readFileSync(bindingPath, 'utf8')); + } catch (error) { + errors.push( + `source archive binding is invalid: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + } + errors.push( + ...validateLinuxSourceArchiveBinding(binding).map( + (error) => `source archive binding is invalid: ${error}` + ) + ); + return binding; +} + +function validatedLinuxSourceRuntime(runtimeRoot) { + const stagedManifest = readRuntimeManifest(runtimeRoot); + if ( + stagedManifest === null || + typeof stagedManifest !== 'object' || + Array.isArray(stagedManifest) + ) { + throw new Error( + `Staged Linux runtime manifest must be an object: ${path.join( + runtimeRoot, + 'runtime-manifest.json' + )}` + ); + } + + const envelopeErrors = []; + if (stagedManifest.origin !== LINUX_STAGED_RUNTIME_ORIGIN) { + envelopeErrors.push(`origin must be "${LINUX_STAGED_RUNTIME_ORIGIN}"`); + } + if (stagedManifest.platform !== 'linux') { + envelopeErrors.push('platform must be "linux"'); + } + if (stagedManifest.arch !== targetArch) { + envelopeErrors.push(`arch must be "${targetArch}"`); + } + if ( + typeof stagedManifest.stagedAt !== 'string' || + stagedManifest.stagedAt.trim().length === 0 + ) { + envelopeErrors.push('stagedAt must be a non-empty string'); + } + if (!Array.isArray(stagedManifest.runtimeFiles)) { + envelopeErrors.push('runtimeFiles must be an array'); + } + + const systemBuildInputs = isLinuxSystemBuildInputManifest(stagedManifest); + let buildInputMode; + let sourceArchive = null; + let sourceRuntimeManifest; + if (systemBuildInputs) { + buildInputMode = 'system-build-inputs'; + sourceRuntimeManifest = { + linuxBackend: stagedManifest.linuxBackend, + buildInputs: stagedManifest.buildInputs, + sourceDistribution: stagedManifest.sourceDistribution, + }; + if ( + Array.isArray(stagedManifest.runtimeFiles) && + stagedManifest.runtimeFiles.length !== 0 + ) { + envelopeErrors.push( + 'system build inputs must not declare staged runtime files' + ); + } + if (stagedManifest.sourceBuildOrigin !== undefined) { + envelopeErrors.push( + 'system build inputs must not declare sourceBuildOrigin' + ); + } + } else { + buildInputMode = 'bundled-runtime'; + sourceArchive = readLinuxSourceArchiveBinding( + runtimeRoot, + envelopeErrors + ); + const sourceBuildOrigin = stagedManifest.sourceBuildOrigin; + const sourceMetadata = { ...stagedManifest }; + delete sourceMetadata.sourceBuildOrigin; + delete sourceMetadata.stagedAt; + sourceRuntimeManifest = { + ...sourceMetadata, + origin: sourceBuildOrigin, + }; + if (sourceBuildOrigin !== LINUX_SOURCE_RUNTIME_ORIGIN) { + envelopeErrors.push( + `sourceBuildOrigin must be "${LINUX_SOURCE_RUNTIME_ORIGIN}"` + ); + } + } + + const sourceManifestErrors = + buildInputMode === 'system-build-inputs' + ? validateLinuxSystemBuildInputManifest(sourceRuntimeManifest) + : validateLinuxRuntimeManifest(sourceRuntimeManifest); + const declaredRuntimeFileNames = Array.isArray( + sourceRuntimeManifest.runtimeFiles + ) + ? sourceRuntimeManifest.runtimeFiles + .map((runtimeFile) => runtimeFile.name) + .sort() + : []; + const stagedRuntimeFileNames = listRuntimeFiles( + path.join(runtimeRoot, 'lib'), + runtimeFilePredicate + ) + .map((runtimeFile) => path.basename(runtimeFile)) + .sort(); + if ( + JSON.stringify(stagedRuntimeFileNames) !== + JSON.stringify(declaredRuntimeFileNames) + ) { + envelopeErrors.push( + 'staged lib directory must exactly match manifest runtimeFiles' + ); + } + + const errors = [...envelopeErrors, ...sourceManifestErrors]; + if (errors.length > 0) { + throw new Error( + [ + `Invalid staged Linux runtime at ${runtimeRoot}:`, + ...errors.map((error) => `- ${error}`), + ].join('\n') + ); + } + + return { + buildInputMode, + sourceArchive, + sourceRuntimeManifest, + sourceRuntimeValidated: buildInputMode === 'bundled-runtime', + }; +} + function fileExists(filePath) { return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); } @@ -183,9 +355,9 @@ function findWindowsLibMpv(runtimeRoot) { } /* Debian/Ubuntu install linker targets under the multiarch triple dir. The - * compiler's built-in search paths cover it for -l resolution either way; - * this keeps the -L flag and the helper's baked rpath pointing somewhere - * real. */ + * compiler's built-in search paths cover it for -l resolution either way. + * This directory is a link-time input only; the helper runtime intentionally + * stays on the sanitized system loader contract. */ function defaultLinuxSystemLibDir() { const multiarchTriples = { arm: 'arm-linux-gnueabihf', @@ -211,15 +383,19 @@ function findLinuxLibMpv(libDir) { } function resolveRuntime() { + const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h'); + const stagedLinuxRuntime = + targetPlatform === 'linux' && fs.existsSync(vendoredHeader) + ? validatedLinuxSourceRuntime(vendoredRuntimeRoot) + : null; const vendoredLibMpv = targetPlatform === 'darwin' ? findLibMpv(vendoredLibDir) : targetPlatform === 'win32' ? findWindowsLibMpv(vendoredRuntimeRoot) : targetPlatform === 'linux' - ? true + ? stagedLinuxRuntime : null; - const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h'); if (vendoredLibMpv && fs.existsSync(vendoredHeader)) { const windowsImportLib = @@ -237,17 +413,23 @@ function resolveRuntime() { binDir: vendoredBinDir, manifest: readRuntimeManifest(vendoredRuntimeRoot), windowsImportLib, + ...(stagedLinuxRuntime ?? {}), }; } if (targetPlatform === 'linux') { // Dev-first Linux flow (frame-copy helper links system libmpv): a // distro libmpv-dev install is a full runtime — no staging needed. - // LIBMPV_INCLUDE_DIR / LINUX_NATIVE_LIBRARY_DIR override the system - // paths for machines with a local (non-root) libmpv prefix. + // LIBMPV_INCLUDE_DIR overrides the header path. + // LINUX_NATIVE_LIBRARY_DIR overrides the link-time library directory, + // which must already be visible to the system dynamic loader. const systemIncludeDir = process.env.LIBMPV_INCLUDE_DIR || '/usr/include'; if (fs.existsSync(path.join(systemIncludeDir, 'mpv', 'client.h'))) { + const sourceRuntimeManifest = { + linuxBackend: 'process-isolated mpv --wid', + warning: 'Development-only unmanaged system libmpv toolchain.', + }; return { origin: 'system-dev', includeDir: systemIncludeDir, @@ -255,10 +437,10 @@ function resolveRuntime() { process.env.LINUX_NATIVE_LIBRARY_DIR || defaultLinuxSystemLibDir(), binDir: undefined, - manifest: { - warning: - 'Development-only system libmpv toolchain. Release packaging keeps the external-mpv-process contract.', - }, + manifest: sourceRuntimeManifest, + buildInputMode: 'system-dev', + sourceRuntimeManifest, + sourceRuntimeValidated: false, windowsImportLib: null, }; } @@ -288,19 +470,141 @@ function resolveRuntime() { return null; } -function writeLinuxProcessRuntimeManifest(runtime) { +function hasStagedLinuxLibMpvLinkerInput(runtime) { + return ( + Array.isArray(runtime.sourceRuntimeManifest?.runtimeFiles) && + runtime.sourceRuntimeManifest.runtimeFiles.some( + (runtimeFile) => runtimeFile.name === 'libmpv.so' + ) + ); +} + +function assertRequiredLinuxFrameCopyRuntime(runtime) { + if (targetPlatform !== 'linux' || !embeddedMpvRequired) { + return; + } + + if ( + runtime.buildInputMode !== 'bundled-runtime' || + runtime.sourceRuntimeValidated !== true || + !runtime.sourceArchive || + !hasStagedLinuxLibMpvLinkerInput(runtime) + ) { + cleanOutput(); + throw new Error( + 'Required Linux builds must use the validated bundled source runtime containing staged libmpv.' + ); + } +} + +function copyLinuxRuntimeClosureToNativeBuild(runtime) { fs.rmSync(outputLibDir, { recursive: true, force: true }); + const declaredRuntimeFiles = + runtime.buildInputMode === 'bundled-runtime' + ? runtime.sourceRuntimeManifest.runtimeFiles + : []; + if (declaredRuntimeFiles.length === 0) { + return []; + } + + fs.mkdirSync(outputLibDir, { recursive: true }); + const copiedRuntimeFiles = []; + for (const runtimeFile of declaredRuntimeFiles) { + const sourcePath = path.join(runtime.libDir, runtimeFile.name); + let descriptor; + try { + descriptor = fs.openSync( + sourcePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW + ); + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) { + throw new Error( + `Staged Linux runtime path is not a regular file: ${sourcePath}` + ); + } + + const contents = fs.readFileSync(descriptor); + if (contents.byteLength !== runtimeFile.size) { + throw new Error( + `Size mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.` + ); + } + const actualSha256 = crypto + .createHash('sha256') + .update(contents) + .digest('hex'); + if (actualSha256 !== runtimeFile.sha256) { + throw new Error( + `SHA-256 mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.` + ); + } + + const destinationPath = path.join(outputLibDir, runtimeFile.name); + fs.writeFileSync(destinationPath, contents, { mode: 0o755 }); + copiedRuntimeFiles.push({ ...runtimeFile }); + } finally { + if (descriptor !== undefined) { + fs.closeSync(descriptor); + } + } + } + + return copiedRuntimeFiles; +} + +function writeLinuxFrameCopyBuildManifest(runtime) { + const copiedRuntimeFiles = copyLinuxRuntimeClosureToNativeBuild(runtime); + const libmpvSoname = + runtime.sourceRuntimeValidated === true && + runtime.buildInputMode === 'bundled-runtime' && + copiedRuntimeFiles.length > 0 + ? resolveVerifiedLinuxLibMpvSoname({ + outputLibDir, + runtimeFiles: copiedRuntimeFiles, + runtimeDependencyClosure: + runtime.sourceRuntimeManifest.runtimeDependencyClosure, + readDynamicSection: readLinuxDynamicSection, + }) + : null; + const packageRuntimeAvailable = + runtime.sourceRuntimeValidated === true && + runtime.buildInputMode === 'bundled-runtime' && + copiedRuntimeFiles.length > 0 && + libmpvSoname !== null; const manifest = { - ...runtime.manifest, - origin: 'external-mpv-process', + schemaVersion: 1, + origin: 'linux-frame-copy-build', generatedAt: new Date().toISOString(), - runtimeFiles: [], - linuxBackend: - runtime.manifest.linuxBackend ?? 'process-isolated mpv --wid', - mpvExecutable: 'mpv', platform: targetPlatform, - targetArch, + arch: targetArch, + buildInputMode: runtime.buildInputMode, + sourceRuntimeValidated: runtime.sourceRuntimeValidated, + allowedPackageRuntimeModes: [...LINUX_PACKAGE_RUNTIME_MODES], + packageRuntimeAvailability: { + system: packageRuntimeAvailable, + bundled: packageRuntimeAvailable, + }, + artifacts: { + addon: 'embedded_mpv.node', + frameReader: 'embedded_mpv_frame_reader.node', + helper: 'iptvnator_mpv_helper', + }, + processIsolation: { + addonLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + libmpvSoname, + runtimeFiles: copiedRuntimeFiles, + runtimeTotalBytes: copiedRuntimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ), + sourceArchive: runtime.sourceArchive ?? null, + sourceRuntime: runtime.sourceRuntimeManifest, }; fs.writeFileSync( @@ -428,12 +732,33 @@ function runNodeGyp(command, env) { } } +function readLinuxDynamicSection(filePath) { + const result = spawnSync('readelf', ['-d', filePath], { + cwd: workspaceRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error) { + throw new Error( + `Unable to run readelf for ${filePath}: ${result.error.message}` + ); + } + if (result.status !== 0) { + throw new Error( + `readelf -d failed for ${filePath} with status ${ + result.status ?? 1 + }: ${(result.stderr ?? '').trim()}` + ); + } + return result.stdout; +} + function main() { fs.mkdirSync(outputDir, { recursive: true }); cleanDistNativeOutput(); + cleanOutput(); if (targetPlatform !== process.platform) { - cleanOutput(); if (embeddedMpvRequired) { throw new Error( `Embedded MPV is required for ${targetPlatform}-${targetArch}, but this host is ${process.platform}-${process.arch}.` @@ -465,58 +790,114 @@ function main() { return; } - const runtimeManifest = - targetPlatform === 'darwin' - ? copyRuntimeToNativeBuild({ - runtimeLibDir: runtime.libDir, - outputLibDir, - runtimeOrigin: runtime.origin, - runtimeManifest: { - targetArch, - ...runtime.manifest, - }, - }) - : targetPlatform === 'linux' - ? writeLinuxProcessRuntimeManifest(runtime) - : copyGenericRuntimeToNativeBuild(runtime); - fs.rmSync(unavailableMarkerFile, { force: true }); + assertRequiredLinuxFrameCopyRuntime(runtime); - const electronPackageJson = require( - path.join(workspaceRoot, 'node_modules', 'electron', 'package.json') - ); - const electronVersion = electronPackageJson.version; - const env = { - ...process.env, - npm_config_runtime: 'electron', - npm_config_target: electronVersion, - npm_config_arch: targetArch, - npm_config_disturl: 'https://electronjs.org/headers', - npm_config_build_from_source: 'true', - npm_config_update_binary: 'false', - LIBMPV_INCLUDE_DIR: runtime.includeDir, - ...(targetPlatform === 'linux' - ? { - LINUX_NATIVE_LIBRARY_DIR: - process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir, - } - : { LIBMPV_LIBRARY_DIR: outputLibDir }), - ...(runtime.windowsImportLib - ? { - LIBMPV_IMPORT_LIB: path.join( + const buildNativeArtifacts = () => { + const runtimeManifest = + targetPlatform === 'darwin' + ? copyRuntimeToNativeBuild({ + runtimeLibDir: runtime.libDir, outputLibDir, - path.basename(runtime.windowsImportLib) - ), - } - : {}), - }; + runtimeOrigin: runtime.origin, + runtimeManifest: { + targetArch, + ...runtime.manifest, + }, + }) + : targetPlatform === 'linux' + ? writeLinuxFrameCopyBuildManifest(runtime) + : copyGenericRuntimeToNativeBuild(runtime); + const linuxLinkageInputs = + targetPlatform === 'linux' + ? resolveLinuxFrameCopyLinkageInputs({ + buildInputMode: runtime.buildInputMode, + outputLibDir, + packagedLibmpvSoname: runtimeManifest.libmpvSoname, + readDynamicSection: readLinuxDynamicSection, + runtimeLibDir: runtime.libDir, + }) + : null; - log( - `Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...` - ); - cleanNativeBuildIntermediates(); - try { + const electronPackageJson = require( + path.join(workspaceRoot, 'node_modules', 'electron', 'package.json') + ); + const electronVersion = electronPackageJson.version; + const env = { + ...process.env, + npm_config_runtime: 'electron', + npm_config_target: electronVersion, + npm_config_arch: targetArch, + npm_config_disturl: 'https://electronjs.org/headers', + npm_config_build_from_source: 'true', + npm_config_update_binary: 'false', + LIBMPV_INCLUDE_DIR: runtime.includeDir, + ...(targetPlatform === 'linux' + ? { + LINUX_VERIFIED_RUNTIME_LIBRARY_DIR: + linuxLinkageInputs.linkerLibraryDir, + } + : { LIBMPV_LIBRARY_DIR: outputLibDir }), + ...(runtime.windowsImportLib + ? { + LIBMPV_IMPORT_LIB: path.join( + outputLibDir, + path.basename(runtime.windowsImportLib) + ), + } + : {}), + }; + + log( + `Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...` + ); + cleanNativeBuildIntermediates(); runNodeGyp('configure', env); runNodeGyp('build', env); + + if (!fs.existsSync(outputFile)) { + throw new Error(`Build finished without producing ${outputFile}.`); + } + + if (targetPlatform === 'linux') { + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: linuxLinkageInputs.expectedLibmpvSoname, + outputDir, + readDynamicSection: readLinuxDynamicSection, + }); + } + + if (targetPlatform === 'darwin') { + patchAddonForBundledRuntime(outputFile, outputLibDir); + // The frame-copy helper executable links libmpv too and sits next + // to the same lib/ directory, so it gets the identical + // dependency-path rewrite + ad-hoc re-sign. + const frameHelperFile = path.join( + outputDir, + 'iptvnator_mpv_helper' + ); + if (fs.existsSync(frameHelperFile)) { + patchAddonForBundledRuntime(frameHelperFile, outputLibDir); + } + const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([ + outputFile, + ...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []), + ...runtimeManifest.dylibs.map((dylib) => + path.join(outputLibDir, dylib) + ), + ]); + if ( + runtime.origin === 'vendored-lgpl' && + forbiddenLinkErrors.length > 0 + ) { + throw new Error(forbiddenLinkErrors.join('\n')); + } + } + + fs.rmSync(unavailableMarkerFile, { force: true }); + }; + + try { + runWithCleanup(buildNativeArtifacts, cleanOutput); } catch (error) { if (!embeddedMpvRequired && runtime.origin === 'system-dev') { // The system-dev fallback triggers on any machine with @@ -528,40 +909,11 @@ function main() { error instanceof Error ? error.message : String(error) }` ); - cleanOutput(); return; } throw error; } - if (!fs.existsSync(outputFile)) { - throw new Error(`Build finished without producing ${outputFile}.`); - } - - if (targetPlatform === 'darwin') { - patchAddonForBundledRuntime(outputFile, outputLibDir); - // The frame-copy helper executable links libmpv too and sits next to - // the same lib/ directory, so it gets the identical dependency-path - // rewrite + ad-hoc re-sign. - const frameHelperFile = path.join(outputDir, 'iptvnator_mpv_helper'); - if (fs.existsSync(frameHelperFile)) { - patchAddonForBundledRuntime(frameHelperFile, outputLibDir); - } - const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([ - outputFile, - ...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []), - ...runtimeManifest.dylibs.map((dylib) => - path.join(outputLibDir, dylib) - ), - ]); - if ( - runtime.origin === 'vendored-lgpl' && - forbiddenLinkErrors.length > 0 - ) { - throw new Error(forbiddenLinkErrors.join('\n')); - } - } - log(`Built ${path.relative(workspaceRoot, outputFile)}.`); } diff --git a/apps/electron-backend/embedded-mpv-linux-linkage.cjs b/apps/electron-backend/embedded-mpv-linux-linkage.cjs new file mode 100644 index 000000000..3cfa0b197 --- /dev/null +++ b/apps/electron-backend/embedded-mpv-linux-linkage.cjs @@ -0,0 +1,340 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; +const LIBMPV_NEEDED_PATTERN = /^libmpv\.so(?:\..*)?$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; + +const LINUX_FRAME_COPY_ARTIFACTS = Object.freeze([ + Object.freeze({ + fileName: 'embedded_mpv.node', + label: 'embedded MPV addon', + mayLinkLibmpv: false, + }), + Object.freeze({ + fileName: 'embedded_mpv_frame_reader.node', + label: 'embedded MPV frame reader', + mayLinkLibmpv: false, + }), + Object.freeze({ + fileName: 'iptvnator_mpv_helper', + label: 'embedded MPV frame-copy helper', + mayLinkLibmpv: true, + }), +]); + +function parseReadelfDynamic(output) { + if (typeof output !== 'string') { + throw new TypeError('readelf dynamic output must be a string.'); + } + + const dynamic = { + needed: [], + rpath: [], + runpath: [], + soname: [], + }; + const dynamicEntryPattern = + /\((NEEDED|RPATH|RUNPATH|SONAME)\)[^[]*\[([^\]]*)\]/g; + for (const [, tag, value] of output.matchAll(dynamicEntryPattern)) { + if (tag === 'NEEDED') { + dynamic.needed.push(value); + continue; + } + if (tag === 'SONAME') { + dynamic.soname.push(value); + continue; + } + dynamic[tag.toLowerCase()].push( + ...value.split(':').filter((entry) => entry.length > 0) + ); + } + + return dynamic; +} + +function exactlyOneRuntimeFile(runtimeFiles, name) { + if (!Array.isArray(runtimeFiles)) { + throw new Error('Linux runtimeFiles metadata must be an array.'); + } + const matchingRecords = runtimeFiles.filter( + (runtimeFile) => runtimeFile?.name === name + ); + if (matchingRecords.length !== 1) { + throw new Error( + `Linux runtime must contain exactly one exact runtimeFiles record for ${name}.` + ); + } + + const [runtimeFile] = matchingRecords; + if ( + !Number.isInteger(runtimeFile.size) || + runtimeFile.size <= 0 || + typeof runtimeFile.sha256 !== 'string' || + !SHA256_PATTERN.test(runtimeFile.sha256) + ) { + throw new Error( + `Linux runtimeFiles record for ${name} has invalid size or SHA-256 metadata.` + ); + } + return runtimeFile; +} + +function readVerifiedRuntimeFile(filePath, runtimeFile) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + throw new Error(`Missing copied Linux runtime file: ${filePath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Copied Linux runtime file must be a regular non-symbolic-link file: ${filePath}` + ); + } + + let descriptor; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + ); + const descriptorStat = fs.fstatSync(descriptor); + if (!descriptorStat.isFile()) { + throw new Error( + `Copied Linux runtime path is not a regular file: ${filePath}` + ); + } + const contents = fs.readFileSync(descriptor); + if (contents.byteLength !== runtimeFile.size) { + throw new Error( + `Size mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.` + ); + } + const actualSha256 = crypto + .createHash('sha256') + .update(contents) + .digest('hex'); + if (actualSha256 !== runtimeFile.sha256) { + throw new Error( + `SHA-256 mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.` + ); + } + } finally { + if (descriptor !== undefined) { + fs.closeSync(descriptor); + } + } +} + +function closureLibMpvSoname(runtimeDependencyClosure) { + if (!Array.isArray(runtimeDependencyClosure?.entries)) { + throw new Error( + 'Validated Linux runtime dependency closure entries are required.' + ); + } + + const libMpvEntries = runtimeDependencyClosure.entries.filter( + (entry) => + entry?.name === 'libmpv.so' || + VERSIONED_LIBMPV_PATTERN.test(entry?.name) + ); + const declaredSonames = libMpvEntries.map((entry) => entry.soname); + const uniqueSonames = new Set(declaredSonames); + if ( + declaredSonames.length === 0 || + declaredSonames.some( + (soname) => + typeof soname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(soname) + ) || + uniqueSonames.size !== 1 + ) { + throw new Error( + 'Validated Linux runtime closure must declare exactly one versioned libmpv SONAME.' + ); + } + + return declaredSonames[0]; +} + +function resolveVerifiedLinuxLibMpvSoname({ + outputLibDir, + runtimeFiles, + runtimeDependencyClosure, + readDynamicSection, +}) { + if (typeof readDynamicSection !== 'function') { + throw new TypeError('Linux readelf dynamic reader is required.'); + } + + const expectedSoname = closureLibMpvSoname(runtimeDependencyClosure); + const linkerInputRecord = exactlyOneRuntimeFile(runtimeFiles, 'libmpv.so'); + const exactSonameRecord = exactlyOneRuntimeFile( + runtimeFiles, + expectedSoname + ); + const linkerInputPath = path.join(outputLibDir, 'libmpv.so'); + const exactSonamePath = path.join(outputLibDir, expectedSoname); + + readVerifiedRuntimeFile(linkerInputPath, linkerInputRecord); + readVerifiedRuntimeFile(exactSonamePath, exactSonameRecord); + + const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath)); + if ( + dynamic.soname.length !== 1 || + !VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0]) + ) { + throw new Error( + 'Copied Linux libmpv.so must contain exactly one DT_SONAME with a versioned libmpv basename.' + ); + } + if (dynamic.soname[0] !== expectedSoname) { + throw new Error( + `Copied Linux libmpv.so DT_SONAME ${dynamic.soname[0]} does not match validated closure SONAME ${expectedSoname}.` + ); + } + + return expectedSoname; +} + +function resolveLinuxFrameCopyLinkageInputs({ + buildInputMode, + outputLibDir, + packagedLibmpvSoname, + readDynamicSection, + runtimeLibDir, +}) { + const systemDevelopment = buildInputMode === 'system-dev'; + const linkerLibraryDir = systemDevelopment ? runtimeLibDir : outputLibDir; + if ( + typeof linkerLibraryDir !== 'string' || + linkerLibraryDir.trim().length === 0 + ) { + throw new Error( + 'Linux frame-copy linkage requires a non-empty linker library directory.' + ); + } + + if (!systemDevelopment) { + return { + expectedLibmpvSoname: packagedLibmpvSoname, + linkerLibraryDir, + }; + } + if (typeof readDynamicSection !== 'function') { + throw new TypeError('Linux readelf dynamic reader is required.'); + } + + const linkerInputPath = path.join(linkerLibraryDir, 'libmpv.so'); + const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath)); + if ( + dynamic.soname.length !== 1 || + !VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0]) + ) { + throw new Error( + 'The system-development libmpv linker input must contain exactly one versioned libmpv SONAME.' + ); + } + + return { + expectedLibmpvSoname: dynamic.soname[0], + linkerLibraryDir, + }; +} + +function assertRegularArtifact(filePath, label) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + throw new Error(`Missing ${label}: ${filePath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `${label} must be a regular non-symbolic-link file: ${filePath}` + ); + } +} + +function validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname, + outputDir, + readDynamicSection, +}) { + if ( + typeof expectedLibmpvSoname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(expectedLibmpvSoname) + ) { + throw new Error( + 'Linux frame-copy linkage validation requires an exact versioned libmpv SONAME.' + ); + } + if (typeof readDynamicSection !== 'function') { + throw new TypeError('Linux readelf dynamic reader is required.'); + } + + for (const artifact of LINUX_FRAME_COPY_ARTIFACTS) { + const artifactPath = path.join(outputDir, artifact.fileName); + assertRegularArtifact(artifactPath, artifact.label); + const dynamic = parseReadelfDynamic(readDynamicSection(artifactPath)); + const libMpvDependencies = dynamic.needed.filter((dependency) => + LIBMPV_NEEDED_PATTERN.test(dependency) + ); + + if (!artifact.mayLinkLibmpv) { + if (libMpvDependencies.length > 0) { + throw new Error( + `${artifact.label} must not have a direct libmpv DT_NEEDED entry; found ${libMpvDependencies.join(', ')}.` + ); + } + continue; + } + + if ( + libMpvDependencies.length !== 1 || + libMpvDependencies[0] !== expectedLibmpvSoname + ) { + throw new Error( + `${artifact.label} DT_NEEDED must contain exactly ${expectedLibmpvSoname}; found ${ + libMpvDependencies.join(', ') || '' + }.` + ); + } + if (dynamic.rpath.length !== 0) { + throw new Error( + `${artifact.label} must not contain RPATH; found ${dynamic.rpath.join(':')}.` + ); + } + if ( + dynamic.runpath.length !== 1 || + dynamic.runpath[0] !== '$ORIGIN/lib' + ) { + throw new Error( + `${artifact.label} RUNPATH must be exactly $ORIGIN/lib; found ${ + dynamic.runpath.join(':') || '' + }.` + ); + } + } +} + +function runWithCleanup(operation, cleanup) { + try { + return operation(); + } catch (error) { + cleanup(); + throw error; + } +} + +module.exports = { + parseReadelfDynamic, + resolveLinuxFrameCopyLinkageInputs, + resolveVerifiedLinuxLibMpvSoname, + runWithCleanup, + validateLinuxFrameCopyLinkage, +}; diff --git a/apps/electron-backend/native/binding.gyp b/apps/electron-backend/native/binding.gyp index 4ab060049..fa8bd4b2c 100644 --- a/apps/electron-backend/native/binding.gyp +++ b/apps/electron-backend/native/binding.gyp @@ -158,14 +158,14 @@ ], "ldflags": [ "-pthread", - "-Wl,-rpath,'$$ORIGIN/lib'", - "-Wl,-rpath,> 16) + "." + + std::to_string(version & 0xffff); +} + +std::string runtimeProbeShmName() { +#if defined(_WIN32) + const uint64_t processId = (uint64_t)GetCurrentProcessId(); +#else + const uint64_t processId = (uint64_t)getpid(); +#endif + return "/impv-fc-runtime-probe-" + std::to_string(processId); +} + +/* + * Bounded startup capability probe: initialize an idle libmpv client and + * create the platform GL + mpv OpenGL render contexts, then create, validate, + * and destroy a minimal shared-memory ring. It deliberately does not create + * an FBO, open media, or enter either command loop. + */ +int runRuntimeProbe() { + mpv_handle* mpv = mpv_create(); + if (!mpv) { + return runtimeProbeFailure("mpv-create-failed"); + } + + mpv_set_option_string(mpv, "vo", "libmpv"); + mpv_set_option_string(mpv, "idle", "yes"); + mpv_set_option_string(mpv, "input-default-bindings", "no"); + mpv_set_option_string(mpv, "osc", "no"); + const int initializeResult = mpv_initialize(mpv); + if (initializeResult < 0) { + const std::string error = mpv_error_string(initializeResult); + mpv_destroy(mpv); + return runtimeProbeFailure("mpv-initialize-failed", error); + } + + GlContext gl; + std::string error; + if (!gl.create(error)) { + gl.destroy(); + mpv_terminate_destroy(mpv); + return runtimeProbeFailure("gl-context-create-failed", error); + } + if (!gl.makeCurrent(error)) { + gl.destroy(); + mpv_terminate_destroy(mpv); + return runtimeProbeFailure("gl-context-bind-failed", error); + } + + mpv_opengl_init_params glInit = { + gl.procLoader(), + gl.procLoaderCtx(), + }; + mpv_render_param renderParams[] = { + {MPV_RENDER_PARAM_API_TYPE, + const_cast(MPV_RENDER_API_TYPE_OPENGL)}, + {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + mpv_render_context* renderContext = nullptr; + const int renderResult = + mpv_render_context_create(&renderContext, mpv, renderParams); + if (renderResult < 0 || !renderContext) { + const std::string renderError = + renderResult < 0 ? mpv_error_string(renderResult) + : "render context unavailable"; + if (renderContext) mpv_render_context_free(renderContext); + gl.destroy(); + mpv_terminate_destroy(mpv); + return runtimeProbeFailure("mpv-render-context-failed", renderError); + } + + frame_helper::ShmRing runtimeProbeRing; + const std::string shmName = runtimeProbeShmName(); + if (!runtimeProbeRing.create(shmName, 16, 16, 1)) { + runtimeProbeRing.destroy(); + mpv_render_context_free(renderContext); + gl.destroy(); + mpv_terminate_destroy(mpv); + return runtimeProbeFailure("shared-memory-create-failed"); + } + const bool sharedMemoryInitialized = + runtimeProbeRing.base != nullptr && + runtimeProbeRing.header != nullptr && + runtimeProbeRing.header->magic == FRAME_SHM_MAGIC && + runtimeProbeRing.header->version == FRAME_SHM_VERSION && + runtimeProbeRing.header->width == 16 && + runtimeProbeRing.header->height == 16 && + runtimeProbeRing.header->generation == 1; + runtimeProbeRing.destroy(); + if (!sharedMemoryInitialized) { + mpv_render_context_free(renderContext); + gl.destroy(); + mpv_terminate_destroy(mpv); + return runtimeProbeFailure("shared-memory-initialize-failed"); + } + + const std::string libmpvVersion = libmpvClientApiVersion(); + mpv_render_context_free(renderContext); + gl.destroy(); + mpv_terminate_destroy(mpv); + emitLine(JsonWriter() + .num("protocol", 1) + .boolean("usable", true) + .str("libmpv", libmpvVersion) + .str("renderApi", gl.renderApiName()) + .finish()); + return 0; +} + } // namespace int main(int argc, char** argv) { @@ -641,6 +767,9 @@ int main(int argc, char** argv) { signal(SIGPIPE, SIG_IGN); #endif const HelperArgs args = parseArgs(argc, argv); + if (args.runtimeProbe) { + return runRuntimeProbe(); + } g_state.mpv = mpv_create(); if (!g_state.mpv) { diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index fc23eea78..8e6b9febb 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -36,7 +36,11 @@ "inputs": [ "production", "^production", - "{workspaceRoot}/apps/electron-backend/native/build/Release/**" + "{workspaceRoot}/apps/electron-backend/native/build/Release/**", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" ], "options": { "outputPath": "dist/apps/electron-backend", @@ -98,7 +102,11 @@ "inputs": [ "production", "^production", - "{workspaceRoot}/apps/electron-backend/native/build/Release/**" + "{workspaceRoot}/apps/electron-backend/native/build/Release/**", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" ], "options": { "outputPath": "dist/apps/electron-backend", @@ -193,11 +201,27 @@ } }, "lint": { - "command": "eslint apps/electron-backend/**/*.ts" + "inputs": [ + "default", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" + ], + "command": "eslint apps/electron-backend/**/*.ts \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts\" \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/**/*.ts\"" }, "test": { "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "inputs": [ + "default", + "^production", + "{workspaceRoot}/jest.preset.js", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" + ], "options": { "jestConfig": "apps/electron-backend/jest.config.ts" } diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 9658f5607..639d86d48 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -133,8 +133,34 @@ describe('Electron app security helpers', () => { it('keeps the renderer sandboxed when frame-copy is requested without a usable runtime', () => { process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true); expect(getMainWindowWebPreferences()?.sandbox).toBe(true); + expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, '0'])( + 'does not probe frame-copy runtime for an inactive %s opt-in', + (explicitFrameCopy) => { + mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true); + if (explicitFrameCopy === undefined) { + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY; + } else { + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = + explicitFrameCopy; + } + + expect(getMainWindowWebPreferences()?.sandbox).toBe(true); + expect(mockIsFrameCopyRuntimeUsable).not.toHaveBeenCalled(); + } + ); + + it('probes frame-copy runtime for an explicit opt-in', () => { + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true); + + expect(getMainWindowWebPreferences()?.sandbox).toBe(true); + expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1); }); it('keeps the renderer sandboxed when frame-copy is requested but embedded MPV is disabled', () => { @@ -215,9 +241,9 @@ describe('Electron app security helpers', () => { expect(mainWindow.loadFile).toHaveBeenCalledWith( expect.stringContaining('index.html') ); - expect( - mockClearStorageData.mock.invocationCallOrder[0] - ).toBeLessThan(mainWindow.loadFile.mock.invocationCallOrder[0]); + expect(mockClearStorageData.mock.invocationCallOrder[0]).toBeLessThan( + mainWindow.loadFile.mock.invocationCallOrder[0] + ); }); it('continues packaged renderer loading when Electron service worker cleanup fails', async () => { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts index 77986aeb0..9e41fc0db 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts @@ -10,10 +10,17 @@ const mockElectronApp = { jest.mock('electron', () => ({ app: mockElectronApp })); import { + getEmbeddedMpvAddonCandidatePaths, + getFrameCopyRuntimeAvailability, isFrameCopyPlatformSupported, + isFrameCopyRuntimeUsable, resolveFrameCopyHelperPath, + shouldPromotePersistedFrameCopyOptIn, } from './embedded-mpv-frame-copy-platform.util'; -import * as frameCopyPlatform from './embedded-mpv-frame-copy-platform.util'; +import type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeResult, +} from './embedded-mpv-frame-copy-runtime'; describe('embedded-mpv-frame-copy-platform.util', () => { describe('isFrameCopyPlatformSupported', () => { @@ -31,7 +38,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => { ['darwin', 'arm64', true], ['darwin', 'x64', false], ['linux', 'x64', true], - ['linux', 'arm64', true], + ['linux', 'arm64', false], + ['linux', 'arm', false], ['win32', 'x64', true], ['freebsd', 'x64', false], ])('%s/%s -> %s', (platform, arch, expected) => { @@ -61,8 +69,7 @@ describe('embedded-mpv-frame-copy-platform.util', () => { process.platform === 'win32' ? 'iptvnator_mpv_helper.exe' : 'iptvnator_mpv_helper'; - const helperPath = () => - path.join(releaseDir(), helperFileName()); + const helperPath = () => path.join(releaseDir(), helperFileName()); const readerPath = () => path.join(releaseDir(), 'embedded_mpv_frame_reader.node'); @@ -152,24 +159,184 @@ describe('embedded-mpv-frame-copy-platform.util', () => { expect(resolveFrameCopyHelperPath()).toBe(packagedHelper); }); + + it('limits packaged native-view addon discovery to package-owned paths', () => { + mockElectronApp.isPackaged = true; + const resourcesPath = path.join(tempDir, 'IPTVnator', 'Resources'); + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: resourcesPath, + }); + mockElectronApp.getAppPath.mockReturnValue( + path.join(resourcesPath, 'app.asar') + ); + + expect(getEmbeddedMpvAddonCandidatePaths()).toEqual([ + path.join( + resourcesPath, + 'app.asar.unpacked', + 'electron-backend', + 'native', + 'embedded_mpv.node' + ), + ]); + }); }); - it('promotes a stored opt-in only without an explicit env override and with a usable runtime', () => { - const shouldPromote = ( - frameCopyPlatform as typeof frameCopyPlatform & { - shouldPromotePersistedFrameCopyOptIn?: ( - storedEnabled: boolean, - explicitEnv: string | undefined, - runtimeUsable: boolean - ) => boolean; - } - ).shouldPromotePersistedFrameCopyOptIn; + describe('isFrameCopyRuntimeUsable', () => { + const originalPlatform = process.platform; + const originalArch = process.arch; - expect(shouldPromote).toBeDefined(); - expect(shouldPromote?.(true, undefined, true)).toBe(true); - expect(shouldPromote?.(true, undefined, false)).toBe(false); - expect(shouldPromote?.(true, '0', true)).toBe(false); - expect(shouldPromote?.(true, '1', true)).toBe(false); - expect(shouldPromote?.(false, undefined, true)).toBe(false); + beforeEach(() => { + mockElectronApp.isPackaged = false; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + Object.defineProperty(process, 'arch', { value: originalArch }); + mockElectronApp.isPackaged = false; + }); + + it('requires a successful Linux x64 runtime probe', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper'); + const probeRuntime = jest.fn< + EmbeddedMpvFrameCopyRuntimeResult, + [string, EmbeddedMpvFrameCopyManifestContract] + >(() => ({ + usable: true, + profile: 'system', + runtimeMode: 'system', + libmpv: '2.3', + renderApi: 'egl', + })); + + expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe( + true + ); + expect(probeRuntime).toHaveBeenCalledWith( + '/native/iptvnator_mpv_helper', + 'development' + ); + + probeRuntime.mockReturnValueOnce({ + usable: false, + reason: 'helper-probe-failed', + }); + expect( + getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime) + ).toEqual({ + usable: false, + reason: 'helper-probe-failed', + }); + }); + + it('selects the packaged manifest contract only from app.isPackaged', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + mockElectronApp.isPackaged = true; + const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper'); + const probeRuntime = jest.fn< + EmbeddedMpvFrameCopyRuntimeResult, + [string, EmbeddedMpvFrameCopyManifestContract] + >(() => ({ + usable: true, + profile: 'system', + runtimeMode: 'system', + libmpv: '2.3', + renderApi: 'egl', + })); + + expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe( + true + ); + expect(probeRuntime).toHaveBeenCalledWith( + '/native/iptvnator_mpv_helper', + 'packaged' + ); + }); + + it.each<[NodeJS.Platform, string]>([ + ['darwin', 'arm64'], + ['win32', 'x64'], + ])( + 'keeps the existing helper-presence gate on %s', + (platform, arch) => { + Object.defineProperty(process, 'platform', { + value: platform, + }); + Object.defineProperty(process, 'arch', { value: arch }); + const resolveHelper = jest.fn( + () => '/native/iptvnator_mpv_helper' + ); + const probeRuntime = jest.fn(); + + expect( + isFrameCopyRuntimeUsable(resolveHelper, probeRuntime) + ).toBe(true); + expect(probeRuntime).not.toHaveBeenCalled(); + } + ); + + it('rejects Linux ARM before helper discovery or probing', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'arm64' }); + const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper'); + const probeRuntime = jest.fn(); + + expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe( + false + ); + expect(resolveHelper).not.toHaveBeenCalled(); + expect(probeRuntime).not.toHaveBeenCalled(); + }); + + it('reports unsupported architecture for Intel macOS', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper'); + const probeRuntime = jest.fn(); + + expect( + getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime) + ).toEqual({ + usable: false, + reason: 'unsupported-architecture', + }); + expect(resolveHelper).not.toHaveBeenCalled(); + expect(probeRuntime).not.toHaveBeenCalled(); + }); + }); + + it('lazily probes only a stored opt-in without an explicit env override', () => { + const runtimeUsable = jest.fn(() => true); + expect( + shouldPromotePersistedFrameCopyOptIn( + false, + undefined, + runtimeUsable + ) + ).toBe(false); + expect( + shouldPromotePersistedFrameCopyOptIn(true, '0', runtimeUsable) + ).toBe(false); + expect( + shouldPromotePersistedFrameCopyOptIn(true, '1', runtimeUsable) + ).toBe(false); + expect(runtimeUsable).not.toHaveBeenCalled(); + + expect( + shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable) + ).toBe(true); + expect(runtimeUsable).toHaveBeenCalledTimes(1); + + runtimeUsable.mockReturnValue(false); + expect( + shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable) + ).toBe(false); + expect(runtimeUsable).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts index b412c5c98..f4844d0df 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts @@ -1,6 +1,11 @@ import { app } from 'electron'; import { accessSync, constants as fsConstants, statSync } from 'fs'; import path from 'path'; +import { probeEmbeddedMpvFrameCopyRuntime } from './embedded-mpv-frame-copy-runtime'; +import type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeResult, +} from './embedded-mpv-frame-copy-runtime'; /** * Platform gate + helper discovery for the embedded MPV frame-copy engine, @@ -9,15 +14,15 @@ import path from 'path'; * callable before app.whenReady(). * * macOS: Apple Silicon only (owner decision 2026-07-10) — Intel Macs keep - * the docked native engine. Linux: any arch — the helper renders offscreen - * through headless EGL and links libmpv out of process, so neither window - * embedding nor the in-process-libmpv ban constrains it. Windows: any arch - * with a helper binary (WGL offscreen render; in practice x64, the only - * vendored runtime) — the helper-presence check below is the real gate. + * the docked native engine. Linux: x64 only — official packages validate a + * profile manifest and run the helper's bounded EGL/libmpv capability probe; + * ARM packages remain honestly unavailable. Windows: any arch with a helper + * binary (WGL offscreen render; in practice x64, the only vendored runtime) + * — the helper-presence check below is the real gate. */ export function isFrameCopyPlatformSupported(): boolean { return ( - process.platform === 'linux' || + (process.platform === 'linux' && process.arch === 'x64') || process.platform === 'win32' || (process.platform === 'darwin' && process.arch === 'arm64') ); @@ -77,7 +82,7 @@ export function getEmbeddedMpvAddonCandidatePaths(): string[] { return dedupeDefinedPaths( app.isPackaged - ? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath] + ? packagedAddonPaths : [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths] ); } @@ -104,10 +109,7 @@ export function resolveFrameCopyHelperPath(): string | null { .map((candidatePath) => path.dirname(candidatePath)) .map((nativeDir) => ({ helper: path.join(nativeDir, helperFileName), - reader: path.join( - nativeDir, - 'embedded_mpv_frame_reader.node' - ), + reader: path.join(nativeDir, 'embedded_mpv_frame_reader.node'), })) .find(({ helper, reader }) => { try { @@ -127,16 +129,80 @@ export function resolveFrameCopyHelperPath(): string | null { ); } +type FrameCopyRuntimeProbe = ( + helperPath: string, + manifestContract: EmbeddedMpvFrameCopyManifestContract +) => EmbeddedMpvFrameCopyRuntimeResult; + +export type FrameCopyRuntimeAvailability = + | EmbeddedMpvFrameCopyRuntimeResult + | { usable: true }; + +let cachedDefaultRuntimeAvailability: FrameCopyRuntimeAvailability | undefined; + +export function getFrameCopyRuntimeAvailability( + resolveHelper: () => string | null = resolveFrameCopyHelperPath, + probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime +): FrameCopyRuntimeAvailability { + const usesProcessDecision = + resolveHelper === resolveFrameCopyHelperPath && + probeRuntime === probeEmbeddedMpvFrameCopyRuntime; + if (usesProcessDecision && cachedDefaultRuntimeAvailability !== undefined) { + return cachedDefaultRuntimeAvailability; + } + + let availability: FrameCopyRuntimeAvailability; + if (!isFrameCopyPlatformSupported()) { + availability = { + usable: false, + reason: + process.platform === 'linux' || process.platform === 'darwin' + ? 'unsupported-architecture' + : 'unsupported-platform', + }; + } else { + const helperPath = resolveHelper(); + if (!helperPath) { + availability = { + usable: false, + reason: 'runtime-artifact-missing', + }; + } else if (process.platform !== 'linux') { + availability = { usable: true }; + } else { + try { + // app.isPackaged is the trusted boundary. Environment flags + // can request the feature, but cannot weaken its manifest + // contract or make development artifacts package-trusted. + availability = probeRuntime( + helperPath, + app.isPackaged ? 'packaged' : 'development' + ); + } catch { + availability = { + usable: false, + reason: 'runtime-probe-internal-error', + }; + } + } + } + if (usesProcessDecision) { + cachedDefaultRuntimeAvailability = availability; + } + return availability; +} + export function isFrameCopyRuntimeUsable( - resolveHelper: () => string | null = resolveFrameCopyHelperPath + resolveHelper: () => string | null = resolveFrameCopyHelperPath, + probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime ): boolean { - return isFrameCopyPlatformSupported() && resolveHelper() !== null; + return getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime).usable; } export function shouldPromotePersistedFrameCopyOptIn( storedEnabled: boolean, explicitEnv: string | undefined, - runtimeUsable = isFrameCopyRuntimeUsable() + runtimeUsable: () => boolean = isFrameCopyRuntimeUsable ): boolean { - return explicitEnv === undefined && storedEnabled && runtimeUsable; + return explicitEnv === undefined && storedEnabled && runtimeUsable(); } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts new file mode 100644 index 000000000..349f0ef52 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts @@ -0,0 +1,16 @@ +export { createLinuxFrameCopyHelperEnvironment } from './embedded-mpv-frame-copy-runtime/helper-environment'; +export { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime/helper-launch'; +export { + createEmbeddedMpvFrameCopyRuntimeProbe, + probeEmbeddedMpvFrameCopyRuntime, +} from './embedded-mpv-frame-copy-runtime/probe'; +export type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeDependencies, + EmbeddedMpvFrameCopyRuntimeFailureReason, + EmbeddedMpvFrameCopyRuntimeFileSystem, + EmbeddedMpvFrameCopyRuntimeMode, + EmbeddedMpvFrameCopyRuntimeResult, + EmbeddedMpvHelperRuntimeProbeFailureReason, +} from './embedded-mpv-frame-copy-runtime/types'; +export type { LinuxFrameCopyHelperLaunchFileSystem } from './embedded-mpv-frame-copy-runtime/helper-launch'; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts new file mode 100644 index 000000000..4a094e217 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts @@ -0,0 +1,330 @@ +import runtimeProbeContract = require('../../../../../../tools/embedded-mpv/runtime-probe-contract.cjs'); +import sourceArchiveContract = require('../../../../../../tools/embedded-mpv/linux-source-archive-contract.cjs'); +import type { EmbeddedMpvFrameCopyRuntimeMode, RuntimeProfile } from './types'; + +interface RuntimeProfileContract { + origin: string; + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode; + targets: ReadonlySet; +} + +export const RUNTIME_MANIFEST_NAME = 'embedded-mpv-runtime.json'; +export const FRAME_COPY_ADDON_NAME = 'embedded_mpv.node'; +export const FRAME_COPY_READER_NAME = 'embedded_mpv_frame_reader.node'; +export const FRAME_COPY_HELPER_NAME = 'iptvnator_mpv_helper'; +export const RUNTIME_PROBE_PROTOCOL = 1; +export const { RUNTIME_PROBE_MAX_BUFFER_BYTES, RUNTIME_PROBE_TIMEOUT_MS } = + runtimeProbeContract; +export const { + SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + SOURCE_ARCHIVE_NAME, + validateLinuxSourceArchiveBinding, +} = sourceArchiveContract; +export const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; +export const SAFE_RUNTIME_NAME_PATTERN = /^[A-Za-z0-9_+.-]+$/; +export const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/; +export const PINNED_LIBPLACEBO_SOURCE_SUBMODULES = [ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear', +] as const; +export const SHA256_PATTERN = /^[a-f0-9]{64}$/; +export const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +export const SUBMODULE_RECORD_PATTERN = + /^[a-f0-9]{40,64}\s+([A-Za-z0-9_+./-]+)$/; +export const VERSION_PATTERN = /^\d+(?:\.\d+)+$/; + +export const GLIBC_TOOLCHAIN_ALLOWLIST = [ + 'ld-linux-x86-64.so.2', + 'libc.so.6', + 'libdl.so.2', + 'libgcc_s.so.1', + 'libm.so.6', + 'libpthread.so.0', + 'librt.so.1', + 'libstdc++.so.6', +] as const; + +export const EXPECTED_EXTERNAL_SYSTEM_LIBRARIES = [ + { + name: 'libEGL.so.1', + interface: 'EGL', + reason: 'System graphics-driver interface used by the frame-copy helper.', + }, + { + name: 'libGL.so.1', + interface: 'OpenGL', + reason: 'System OpenGL compatibility interface supplied by the graphics stack.', + }, + { + name: 'libGLX.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.', + }, + { + name: 'libOpenGL.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL interface supplied by the graphics stack.', + }, + { + name: 'libasound.so.2', + interface: 'ALSA', + reason: 'Linux system audio interface intentionally used by libmpv.', + }, + { + name: 'libdrm.so.2', + interface: 'DRM', + reason: 'Kernel graphics interface used by system GBM and VA-API drivers.', + }, + { + name: 'libgbm.so.1', + interface: 'GBM', + reason: 'System graphics-buffer interface used by headless EGL rendering.', + }, + { + name: 'libpulse.so.0', + interface: 'PulseAudio', + reason: 'Linux desktop audio interface intentionally used by libmpv.', + }, + { + name: 'libva-drm.so.2', + interface: 'VA-API DRM', + reason: 'System VA-API DRM interface used for hardware decoding.', + }, + { + name: 'libva.so.2', + interface: 'VA-API', + reason: 'System video-acceleration interface used for hardware decoding.', + }, +] as const; + +export const ALLOWED_EXTERNAL_LIBRARY_NAMES = new Set([ + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...EXPECTED_EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), +]); + +export const PORTABLE_ABI_BASELINE = { + distribution: 'Ubuntu 22.04', + glibcMaximum: '2.35', + glibcxxMaximum: '3.4.30', +} as const; + +export const PINNED_SOURCE_PACKAGE_IDENTITIES = { + freetype: { + version: '2.13.3', + sourceUrl: + 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz', + sourceSha256: + '0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289', + license: 'FreeType License (FTL)', + }, + fribidi: { + version: '1.0.16', + sourceUrl: + 'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz', + sourceSha256: + '1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c', + license: 'LGPL-2.1-or-later', + }, + harfbuzz: { + version: '8.5.0', + sourceUrl: + 'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz', + sourceSha256: + '77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27', + license: 'MIT', + }, + expat: { + version: '2.8.2', + sourceUrl: + 'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz', + sourceSha256: + '3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4', + license: 'MIT', + }, + fontconfig: { + version: '2.16.0', + sourceUrl: + 'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz', + sourceSha256: + '6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220', + license: 'MIT', + }, + libass: { + version: '0.17.3', + sourceUrl: + 'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz', + sourceSha256: + 'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959', + license: 'ISC', + }, + openssl: { + version: '3.5.7', + sourceUrl: + 'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz', + sourceSha256: + 'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8', + license: 'Apache-2.0', + }, + ffmpeg: { + version: '8.1', + sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz', + sourceSha256: + 'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a', + license: 'LGPL-2.1-or-later', + }, + libplacebo: { + version: '7.360.1', + sourceUrl: 'https://github.com/haasn/libplacebo.git', + sourceTag: 'v7.360.1', + sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4', + sourceSubmodules: PINNED_LIBPLACEBO_SOURCE_SUBMODULES, + license: 'LGPL-2.1-or-later', + }, + hwdata: { + version: '0.409', + sourceUrl: + 'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz', + sourceSha256: + '23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd', + buildInput: { + consumer: 'libdisplay-info', + relativePath: 'pnp.ids', + purpose: 'PNP vendor lookup table compiled into libdisplay-info.', + }, + license: 'GPL-2.0-or-later OR XFree86-1.0', + }, + 'libdisplay-info': { + version: '0.1.1', + sourceUrl: + 'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz', + sourceSha256: + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + license: 'MIT', + }, + mpv: { + version: '0.41.0', + sourceUrl: + 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz', + sourceSha256: + 'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209', + license: 'LGPL-2.1-or-later with -Dgpl=false', + }, +} as const; + +export const EXPECTED_ARTIFACTS = { + addon: { + name: FRAME_COPY_ADDON_NAME, + regularFile: true, + readable: true, + }, + frameReader: { + name: FRAME_COPY_READER_NAME, + regularFile: true, + readable: true, + }, + helper: { + name: FRAME_COPY_HELPER_NAME, + regularFile: true, + readable: true, + executable: true, + }, +}; + +export const EXPECTED_PROCESS_ISOLATION = { + addonLoadsLibmpv: false, + readerLoadsLibmpv: false, + electronLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], +}; + +export const EXPECTED_DEVELOPMENT_ARTIFACTS = { + addon: FRAME_COPY_ADDON_NAME, + frameReader: FRAME_COPY_READER_NAME, + helper: FRAME_COPY_HELPER_NAME, +}; + +export const EXPECTED_DEVELOPMENT_PROCESS_ISOLATION = { + addonLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], +}; + +export const DEVELOPMENT_MANIFEST_FIELDS = [ + 'allowedPackageRuntimeModes', + 'arch', + 'artifacts', + 'buildInputMode', + 'generatedAt', + 'libmpvSoname', + 'nativeViewFallback', + 'origin', + 'packageRuntimeAvailability', + 'platform', + 'processIsolation', + 'runtimeFiles', + 'runtimeTotalBytes', + 'schemaVersion', + 'sourceArchive', + 'sourceRuntime', + 'sourceRuntimeValidated', +] as const; + +export const SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({ + deb: Object.freeze(['libmpv2', 'libegl1', 'libgl1', 'libgbm1']), + rpm: Object.freeze([ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-glx', + 'mesa-libgbm', + ]), + pacman: Object.freeze(['mpv', 'libglvnd', 'mesa']), +}); + +export const PROFILE_CONTRACTS = { + system: { + origin: 'system-libmpv-frame-copy', + runtimeMode: 'system', + targets: new Set(['deb', 'rpm', 'pacman']), + }, + portable: { + origin: 'bundled-lgpl-frame-copy', + runtimeMode: 'bundled', + targets: new Set(['appimage', 'snap']), + }, + flatpak: { + origin: 'bundled-lgpl-frame-copy', + runtimeMode: 'bundled', + targets: new Set(['flatpak']), + }, +} as const satisfies Record; + +export const BASE_MANIFEST_FIELDS = [ + 'arch', + 'artifacts', + 'generatedAt', + 'libmpvSoname', + 'nativeViewFallback', + 'origin', + 'packageDependencies', + 'platform', + 'processIsolation', + 'profile', + 'runtimeFiles', + 'runtimeMode', + 'runtimeTotalBytes', + 'schemaVersion', + 'targets', +] as const; + +export const BUNDLED_MANIFEST_FIELDS = [ + ...BASE_MANIFEST_FIELDS, + 'externalSystemLibraries', + 'runtimeDependencyClosure', + 'sourceArchive', + 'sourceRuntime', +] as const; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts new file mode 100644 index 000000000..57fd6bc06 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts @@ -0,0 +1,172 @@ +import { mkdirSync } from 'fs'; +import path from 'path'; +import { + cloneManifest, + createDevelopmentFixture, + probeDevelopmentRuntime, + writeManifest, +} from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy development manifest', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it.each(['system-dev', 'system-build-inputs', 'bundled-runtime'] as const)( + 'accepts an exact unpackaged %s build manifest and still runs the helper probe', + (buildInputMode) => { + const fixture = createDevelopmentFixture( + context.rootDir, + buildInputMode + ); + + expect( + probeDevelopmentRuntime( + context.createProbe(), + fixture.helperPath + ) + ).toEqual( + expect.objectContaining({ + usable: true, + runtimeMode: + buildInputMode === 'bundled-runtime' + ? 'bundled' + : 'system', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + fixture.helperPath, + ['--runtime-probe'], + expect.objectContaining({ + env: + buildInputMode === 'bundled-runtime' + ? { + PATH: '/usr/bin', + LD_LIBRARY_PATH: path.join( + fixture.nativeDir, + 'lib' + ), + } + : { + PATH: '/usr/bin', + }, + }) + ); + } + ); + + it('keeps the packaged manifest contract strict when a development manifest is present', () => { + const fixture = createDevelopmentFixture( + context.rootDir, + 'bundled-runtime' + ); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it('accepts a local bundled runtime before a release source archive is assembled', () => { + const fixture = createDevelopmentFixture( + context.rootDir, + 'bundled-runtime' + ); + const manifest = cloneManifest(fixture.manifest); + manifest.sourceArchive = null; + writeManifest(fixture.manifestPath, manifest); + + expect( + probeDevelopmentRuntime(context.createProbe(), fixture.helperPath) + ).toEqual( + expect.objectContaining({ + usable: true, + runtimeMode: 'bundled', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalled(); + }); + + it.each([ + { + label: 'origin', + mutate(manifest: Record) { + manifest.origin = 'system-libmpv-frame-copy'; + }, + }, + { + label: 'architecture', + mutate(manifest: Record) { + manifest.arch = 'arm64'; + }, + }, + { + label: 'artifact set', + mutate(manifest: Record) { + const artifacts = manifest.artifacts as Record; + artifacts.helper = 'other-helper'; + }, + }, + { + label: 'package availability', + mutate(manifest: Record) { + manifest.packageRuntimeAvailability = { + system: true, + bundled: false, + }; + }, + }, + { + label: 'unexpected field', + mutate(manifest: Record) { + manifest.manifestContract = 'packaged'; + }, + }, + ])( + 'rejects a development manifest with an invalid $label', + ({ mutate }) => { + const fixture = createDevelopmentFixture( + context.rootDir, + 'system-dev' + ); + const manifest = cloneManifest(fixture.manifest); + mutate(manifest); + writeManifest(fixture.manifestPath, manifest); + + expect( + probeDevelopmentRuntime( + context.createProbe(), + fixture.helperPath + ) + ).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + } + ); + + it('rejects a system development manifest with a private runtime directory', () => { + const fixture = createDevelopmentFixture(context.rootDir, 'system-dev'); + mkdirSync(path.join(fixture.nativeDir, 'lib')); + + expect( + probeDevelopmentRuntime(context.createProbe(), fixture.helperPath) + ).toEqual({ + usable: false, + reason: 'runtime-library-directory-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts new file mode 100644 index 000000000..27b743f6e --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts @@ -0,0 +1,148 @@ +import { accessSync, lstatSync, readFileSync, readdirSync } from 'fs'; +import path from 'path'; +import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime'; +import { createFixture } from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +const FLATPAK_NATIVE_DIR = + '/app/iptvnator/resources/app.asar.unpacked/electron-backend/native'; +const FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [ + '/etc/egl/egl_external_platform.d', + '/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d', + '/usr/share/egl/egl_external_platform.d', +].join(':'); + +describe('Flatpak embedded MPV frame-copy runtime', () => { + it('reconstructs the immutable Freedesktop GL metadata inside the packaged app', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/tmp/hostile-egl-platform.json', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + }, + FLATPAK_NATIVE_DIR, + 'bundled' + ) + ).toEqual({ + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS, + LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'), + }); + }); + + it.each([ + ['wrong app id', 'com.example.other', '/app/iptvnator/native'], + [ + 'helper outside /app', + 'com.fourgray.iptvnator', + '/opt/iptvnator/native', + ], + ])( + 'does not reconstruct Flatpak GL metadata for %s', + (_label, flatpakId, nativeDir) => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + FLATPAK_ID: flatpakId, + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + }, + nativeDir, + 'bundled' + ) + ).toEqual({ + FLATPAK_ID: flatpakId, + LD_LIBRARY_PATH: path.join(nativeDir, 'lib'), + }); + } + ); + + describe('packaged capability probe', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it('uses reconstructed Freedesktop GL metadata through the application gate', () => { + const fixture = createFixture(context.rootDir, 'flatpak'); + const virtualHelperPath = path.join( + FLATPAK_NATIVE_DIR, + 'iptvnator_mpv_helper' + ); + const translatePath = (candidatePath: string): string => { + if ( + candidatePath === FLATPAK_NATIVE_DIR || + candidatePath.startsWith(`${FLATPAK_NATIVE_DIR}${path.sep}`) + ) { + return path.join( + fixture.nativeDir, + path.relative(FLATPAK_NATIVE_DIR, candidatePath) + ); + } + return candidatePath; + }; + const probeRuntime = context.createProbe({ + env: { + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/tmp/hostile-egl-platform.json', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + }, + fileSystem: { + accessSync: (candidatePath, mode) => + accessSync(translatePath(candidatePath), mode), + lstatSync: (candidatePath) => + lstatSync(translatePath(candidatePath)), + readFileSync: (candidatePath) => + readFileSync(translatePath(candidatePath)), + readdirSync: (candidatePath) => + readdirSync(translatePath(candidatePath)), + }, + }); + + expect(probeRuntime(virtualHelperPath)).toEqual( + expect.objectContaining({ + usable: true, + profile: 'flatpak', + runtimeMode: 'bundled', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + virtualHelperPath, + ['--runtime-probe'], + expect.objectContaining({ + env: { + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS, + LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'), + }, + }) + ); + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts new file mode 100644 index 000000000..168ccc144 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts @@ -0,0 +1,369 @@ +import path from 'path'; +import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime'; + +const HOSTILE_LOADER_ENVIRONMENT = { + BASH_ENV: '/tmp/hostile-bash-env', + ENV: '/tmp/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/tmp/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/tmp/hostile-cdpath', + 'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_ORIGIN_PATH: '/tmp/hostile-origin', + LD_PRELOAD: '/tmp/inject.so', + __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json', + GBM_BACKEND: '../../../../../tmp/hostile-gbm', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', + MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', + LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', + VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', + VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', + VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', + VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', + VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', + VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', + VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers', + VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', +} as const; + +const GRAPHICS_SELECTOR_ENVIRONMENT = { + LIBGL_ALWAYS_SOFTWARE: '1', + EGL_PLATFORM: 'x11', + DRI_PRIME: '1', + GALLIUM_DRIVER: 'llvmpipe', + VDPAU_DRIVER: 'mesa', + __GLX_VENDOR_LIBRARY_NAME: 'mesa', + __GLX_FORCE_VENDOR_LIBRARY_0: 'mesa', + VK_INSTANCE_LAYERS: 'VK_LAYER_MESA_overlay', + VK_LOADER_DRIVERS_SELECT: '*mesa*', +} as const; + +describe('createLinuxFrameCopyHelperEnvironment', () => { + it('removes ambient loader overrides for system packages', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/usr/bin', + HOME: '/home/user', + ...HOSTILE_LOADER_ENVIRONMENT, + }, + '/opt/iptvnator/native', + 'system' + ) + ).toEqual({ + PATH: '/usr/bin', + HOME: '/home/user', + }); + }); + + it('preserves graphics feature and debug selectors', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + GRAPHICS_SELECTOR_ENVIRONMENT, + '/opt/iptvnator/native', + 'system' + ) + ).toEqual(GRAPHICS_SELECTOR_ENVIRONMENT); + }); + + it('keeps trusted Snap GL and core22 roots ahead of older and generic libraries', () => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: [ + '/var/lib/snapd/lib/gl', + '/tmp/hostile-gl', + '/var/lib/snapd/lib/gl/nvidia', + '/var/lib/snapd/lib/gl-evil', + ].join(':'), + SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home', + XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs', + XDG_DATA_HOME: '/tmp/hostile-xdg-data-home', + XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + nativeDir, + 'bundled' + ) + ).toEqual({ + PATH: '/usr/sbin:/usr/bin:/sbin:/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: [ + '/var/lib/snapd/lib/gl', + '/var/lib/snapd/lib/gl/nvidia', + ].join(':'), + SNAP_ARCH: 'amd64', + SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + ...GRAPHICS_SELECTOR_ENVIRONMENT, + GBM_BACKENDS_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'gbm' + ), + '/var/lib/snapd/lib/gl/gbm', + ].join(':'), + LIBGL_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + LIBVA_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'egl', + 'egl_external_platform.d' + ), + __EGL_VENDOR_LIBRARY_DIRS: [ + '/var/lib/snapd/lib/glvnd/egl_vendor.d', + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'glvnd', + 'egl_vendor.d' + ), + ].join(':'), + VK_LAYER_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'implicit_layer.d' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'explicit_layer.d' + ), + ].join(':'), + XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'), + XDG_CONFIG_DIRS: [ + path.join(snapRoot, 'etc', 'xdg'), + '/etc/xdg', + ].join(':'), + XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'), + XDG_DATA_DIRS: [ + path.join(snapRoot, 'graphics', 'usr', 'share'), + path.join(snapRoot, 'gnome-platform', 'usr', 'share'), + path.join(snapRoot, 'usr', 'share'), + '/usr/share', + ].join(':'), + LD_LIBRARY_PATH: [ + path.join(nativeDir, 'lib'), + '/var/lib/snapd/lib/gl', + '/var/lib/snapd/lib/gl/nvidia', + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'vdpau' + ), + '/usr/lib/x86_64-linux-gnu', + path.join( + snapRoot, + 'gnome-platform', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa-egl' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'pulseaudio' + ), + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + ].join(':'), + }); + }); + + it.each([ + '/tmp/gnome-platform', + '/snap/iptvnator/42/gnome-platform-evil', + 'gnome-platform', + ])( + 'ignores an untrusted Snap desktop runtime declaration: %s', + (declaredDesktopRuntime) => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + + const helperEnvironment = createLinuxFrameCopyHelperEnvironment( + { + SNAP: snapRoot, + SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', + SNAP_DESKTOP_RUNTIME: declaredDesktopRuntime, + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + }, + nativeDir, + 'bundled' + ); + + expect(helperEnvironment.LD_LIBRARY_PATH?.split(':')).toEqual([ + path.join(nativeDir, 'lib'), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'vdpau' + ), + '/usr/lib/x86_64-linux-gnu', + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + ]); + expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain( + declaredDesktopRuntime + ); + expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain( + 'hostile-linux-gnu' + ); + expect(helperEnvironment.SNAP_DESKTOP_RUNTIME).toBeUndefined(); + expect(helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET).toBe( + 'x86_64-linux-gnu' + ); + expect(helperEnvironment.SNAP_ARCH).toBe('amd64'); + } + ); + + it('does not trust Snap loader paths when nativeDir is outside the declared mount', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/usr/bin', + SNAP: '/snap/iptvnator/42', + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_PRELOAD: '/tmp/inject.so', + }, + '/opt/iptvnator/native', + 'bundled' + ) + ).toEqual({ + PATH: '/usr/bin', + SNAP: '/snap/iptvnator/42', + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + LD_LIBRARY_PATH: '/opt/iptvnator/native/lib', + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts new file mode 100644 index 000000000..ecf7d5c08 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts @@ -0,0 +1,281 @@ +import path from 'path'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './types'; +import { resolveTrustedSnapRoot } from './trusted-snap-root'; + +const TRUSTED_SNAP_GL_ROOT = '/var/lib/snapd/lib/gl'; +const TRUSTED_SNAP_EGL_VENDOR_ROOT = '/var/lib/snapd/lib/glvnd/egl_vendor.d'; +const SNAP_DESKTOP_RUNTIME_DIRECTORY = 'gnome-platform'; +const SNAP_GRAPHICS_RUNTIME_DIRECTORY = 'graphics'; +const SNAP_X64_LIBRARY_TRIPLET = 'x86_64-linux-gnu'; +const TRUSTED_SNAP_BASE_LIBRARY_ROOT = '/usr/lib/x86_64-linux-gnu'; +const TRUSTED_SNAP_HELPER_PATH = '/usr/sbin:/usr/bin:/sbin:/bin'; +const TRUSTED_FLATPAK_APP_ID = 'com.fourgray.iptvnator'; +const TRUSTED_FLATPAK_APP_ROOT = '/app'; +const TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [ + '/etc/egl/egl_external_platform.d', + '/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d', + '/usr/share/egl/egl_external_platform.d', +].join(':'); +const UNSAFE_HELPER_ENVIRONMENT_VARIABLES = [ + 'BASH_ENV', + 'ENV', + 'BASHOPTS', + 'SHELLOPTS', + 'PS4', + 'BASH_XTRACEFD', + 'CDPATH', + 'LD_AUDIT', + 'LD_LIBRARY_PATH', + 'LD_ORIGIN_PATH', + 'LD_PRELOAD', + '__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS', + '__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES', + '__EGL_VENDOR_LIBRARY_DIRS', + '__EGL_VENDOR_LIBRARY_FILENAMES', + 'GBM_BACKEND', + 'GBM_BACKENDS_PATH', + 'LIBGL_DRIVERS_PATH', + 'MESA_LOADER_DRIVER_OVERRIDE', + 'LIBVA_DRIVER_NAME', + 'LIBVA_DRIVERS_PATH', + 'VDPAU_DRIVER_PATH', + 'VK_DRIVER_FILES', + 'VK_ICD_FILENAMES', + 'VK_ADD_DRIVER_FILES', + 'VK_ADD_LAYER_PATH', + 'VK_IMPLICIT_LAYER_PATH', + 'VK_ADD_IMPLICIT_LAYER_PATH', + 'VK_LAYER_PATH', +] as const; + +function isPathInside( + parentPath: string, + candidatePath: string, + allowEqual: boolean +): boolean { + const relativePath = path.relative(parentPath, candidatePath); + if (relativePath === '') { + return allowEqual; + } + return ( + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ); +} + +function getTrustedSnapLibraryPaths( + environment: NodeJS.ProcessEnv, + snapRoot: string +): string[] { + const snapLibraryPaths = getTrustedSnapHostGlLibraryPaths(environment); + const graphicsLibraryRoot = path.join( + snapRoot, + SNAP_GRAPHICS_RUNTIME_DIRECTORY, + 'usr', + 'lib', + SNAP_X64_LIBRARY_TRIPLET + ); + const desktopLibraryPaths = getTrustedSnapDesktopLibraryPaths( + environment, + snapRoot + ); + + return [ + ...snapLibraryPaths, + graphicsLibraryRoot, + path.join(graphicsLibraryRoot, 'vdpau'), + // The core22 libedit ABI must win over gnome-3-28's libtinfo5 build. + TRUSTED_SNAP_BASE_LIBRARY_ROOT, + ...desktopLibraryPaths, + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', SNAP_X64_LIBRARY_TRIPLET), + path.join(snapRoot, 'usr', 'lib', SNAP_X64_LIBRARY_TRIPLET), + ]; +} + +function getTrustedSnapHostGlLibraryPaths( + environment: NodeJS.ProcessEnv +): string[] { + return (environment.SNAP_LIBRARY_PATH ?? '') + .split(':') + .filter(Boolean) + .filter((libraryPath) => path.isAbsolute(libraryPath)) + .map((libraryPath) => path.resolve(libraryPath)) + .filter((libraryPath) => + isPathInside(TRUSTED_SNAP_GL_ROOT, libraryPath, true) + ); +} + +function getTrustedSnapDesktopLibraryPaths( + environment: NodeJS.ProcessEnv, + snapRoot: string +): string[] { + const desktopRuntime = resolveTrustedSnapDesktopRuntime( + environment, + snapRoot + ); + if (!desktopRuntime) { + return []; + } + + const desktopLibraryRoot = path.join( + desktopRuntime, + 'usr', + 'lib', + SNAP_X64_LIBRARY_TRIPLET + ); + return [ + path.join(desktopRuntime, 'lib', SNAP_X64_LIBRARY_TRIPLET), + desktopLibraryRoot, + path.join(desktopLibraryRoot, 'mesa'), + path.join(desktopLibraryRoot, 'mesa-egl'), + path.join(desktopLibraryRoot, 'dri'), + path.join(desktopLibraryRoot, 'pulseaudio'), + ]; +} + +function resolveTrustedSnapDesktopRuntime( + environment: NodeJS.ProcessEnv, + snapRoot: string +): string | null { + const expectedDesktopRuntime = path.join( + snapRoot, + SNAP_DESKTOP_RUNTIME_DIRECTORY + ); + const declaredDesktopRuntime = environment.SNAP_DESKTOP_RUNTIME; + if ( + !declaredDesktopRuntime || + !path.isAbsolute(declaredDesktopRuntime) || + path.resolve(declaredDesktopRuntime) !== expectedDesktopRuntime + ) { + return null; + } + return expectedDesktopRuntime; +} + +function isTrustedFlatpakRuntime( + environment: NodeJS.ProcessEnv, + nativeDir: string +): boolean { + return ( + environment.FLATPAK_ID === TRUSTED_FLATPAK_APP_ID && + path.isAbsolute(nativeDir) && + isPathInside(TRUSTED_FLATPAK_APP_ROOT, path.resolve(nativeDir), false) + ); +} + +function applyTrustedSnapGraphicsEnvironment( + helperEnvironment: NodeJS.ProcessEnv, + sourceEnvironment: NodeJS.ProcessEnv, + snapRoot: string +): void { + const graphicsRuntime = path.join( + snapRoot, + SNAP_GRAPHICS_RUNTIME_DIRECTORY, + 'usr' + ); + const graphicsLibraryRoot = path.join( + graphicsRuntime, + 'lib', + SNAP_X64_LIBRARY_TRIPLET + ); + const graphicsDriRoot = path.join(graphicsLibraryRoot, 'dri'); + + helperEnvironment.GBM_BACKENDS_PATH = [ + path.join(graphicsLibraryRoot, 'gbm'), + path.join(TRUSTED_SNAP_GL_ROOT, 'gbm'), + ].join(':'); + helperEnvironment.LIBGL_DRIVERS_PATH = graphicsDriRoot; + helperEnvironment.LIBVA_DRIVERS_PATH = graphicsDriRoot; + helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = path.join( + graphicsRuntime, + 'share', + 'egl', + 'egl_external_platform.d' + ); + helperEnvironment.__EGL_VENDOR_LIBRARY_DIRS = [ + TRUSTED_SNAP_EGL_VENDOR_ROOT, + path.join(graphicsRuntime, 'share', 'glvnd', 'egl_vendor.d'), + ].join(':'); + helperEnvironment.VK_LAYER_PATH = [ + path.join(graphicsRuntime, 'share', 'vulkan', 'implicit_layer.d'), + path.join(graphicsRuntime, 'share', 'vulkan', 'explicit_layer.d'), + ].join(':'); + + const snapConfigRoot = path.join(snapRoot, 'etc', 'xdg'); + const snapDataRoot = path.join(snapRoot, 'usr', 'share'); + const desktopRuntime = resolveTrustedSnapDesktopRuntime( + sourceEnvironment, + snapRoot + ); + const snapLibraryPaths = + getTrustedSnapHostGlLibraryPaths(sourceEnvironment); + if (snapLibraryPaths.length > 0) { + helperEnvironment.SNAP_LIBRARY_PATH = snapLibraryPaths.join(':'); + } else { + delete helperEnvironment.SNAP_LIBRARY_PATH; + } + helperEnvironment.SNAP_ARCH = 'amd64'; + helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET = SNAP_X64_LIBRARY_TRIPLET; + if (desktopRuntime) { + helperEnvironment.SNAP_DESKTOP_RUNTIME = desktopRuntime; + } else { + delete helperEnvironment.SNAP_DESKTOP_RUNTIME; + } + helperEnvironment.XDG_CONFIG_HOME = snapConfigRoot; + helperEnvironment.XDG_CONFIG_DIRS = [snapConfigRoot, '/etc/xdg'].join(':'); + helperEnvironment.XDG_DATA_HOME = snapDataRoot; + helperEnvironment.XDG_DATA_DIRS = [ + path.join(graphicsRuntime, 'share'), + ...(desktopRuntime ? [path.join(desktopRuntime, 'usr', 'share')] : []), + snapDataRoot, + '/usr/share', + ].join(':'); +} + +/** + * Builds the loader environment shared by the bounded startup probe and each + * real Linux helper session. The validated package profile is authoritative: + * system packages use the system loader, while bundled packages start at + * native/lib and add only fixed trusted Snap or Flatpak graphics roots. + */ +export function createLinuxFrameCopyHelperEnvironment( + environment: NodeJS.ProcessEnv, + nativeDir: string, + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode +): NodeJS.ProcessEnv { + const helperEnvironment = { ...environment }; + for (const variableName of UNSAFE_HELPER_ENVIRONMENT_VARIABLES) { + delete helperEnvironment[variableName]; + } + for (const variableName of Object.keys(helperEnvironment)) { + if (variableName.startsWith('BASH_FUNC_')) { + delete helperEnvironment[variableName]; + } + } + + if (runtimeMode === 'system') { + return helperEnvironment; + } + + const libraryPaths = [path.join(nativeDir, 'lib')]; + const trustedSnapRoot = resolveTrustedSnapRoot(environment, nativeDir); + if (trustedSnapRoot) { + helperEnvironment.PATH = TRUSTED_SNAP_HELPER_PATH; + libraryPaths.push( + ...getTrustedSnapLibraryPaths(environment, trustedSnapRoot) + ); + applyTrustedSnapGraphicsEnvironment( + helperEnvironment, + environment, + trustedSnapRoot + ); + } else if (isTrustedFlatpakRuntime(environment, nativeDir)) { + helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = + TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS; + } + helperEnvironment.LD_LIBRARY_PATH = [...new Set(libraryPaths)].join(':'); + return helperEnvironment; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts new file mode 100644 index 000000000..25dcb8fbd --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts @@ -0,0 +1,338 @@ +import { SUCCESS_OUTPUT } from './runtime.spec-data'; +import { createFixture } from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy helper failures', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it('converts a thrown spawn failure into a stable result', () => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockImplementation(() => { + throw new Error('spawn exploded'); + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-spawn-error', + }); + }); + + it.each([ + { + label: 'timeout', + spawnResult: { + status: null, + signal: 'SIGTERM', + stdout: '', + stderr: '', + error: Object.assign(new Error('timed out'), { + code: 'ETIMEDOUT', + }), + }, + reason: 'helper-probe-timeout', + }, + { + label: 'spawn error', + spawnResult: { + status: null, + signal: null, + stdout: '', + stderr: '', + error: Object.assign(new Error('spawn failed'), { + code: 'EACCES', + }), + }, + reason: 'helper-probe-spawn-error', + }, + { + label: 'nonzero exit', + spawnResult: { + status: 1, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n', + stderr: '', + }, + reason: 'helper-probe-failed', + }, + { + label: 'signal', + spawnResult: { + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + }, + reason: 'helper-probe-signaled', + }, + { + label: 'invalid JSON', + spawnResult: { + status: 0, + signal: null, + stdout: 'not-json\n', + stderr: '', + }, + reason: 'helper-probe-invalid-output', + }, + { + label: 'multiple lines', + spawnResult: { + status: 0, + signal: null, + stdout: `${SUCCESS_OUTPUT}${SUCCESS_OUTPUT}`, + stderr: '', + }, + reason: 'helper-probe-invalid-output', + }, + { + label: 'wrong protocol', + spawnResult: { + status: 0, + signal: null, + stdout: '{"protocol":2,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n', + stderr: '', + }, + reason: 'helper-probe-protocol-mismatch', + }, + { + label: 'unusable success', + spawnResult: { + status: 0, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n', + stderr: '', + }, + reason: 'helper-probe-unusable', + }, + ])('fails closed on helper $label', ({ spawnResult, reason }) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue(spawnResult); + + expect(context.createProbe()(fixture.helperPath)).toEqual( + expect.objectContaining({ usable: false, reason }) + ); + }); + + it.each([ + 'mpv-create-failed', + 'mpv-initialize-failed', + 'gl-context-create-failed', + 'gl-context-bind-failed', + 'mpv-render-context-failed', + 'shared-memory-create-failed', + 'shared-memory-initialize-failed', + ])('preserves the allowlisted helper reason %s', (helperReason) => { + const fixture = createFixture(context.rootDir); + const helperDetail = + helperReason === 'mpv-create-failed' + ? undefined + : 'EGL initialization failed: display unavailable'; + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: `${JSON.stringify({ + protocol: 1, + usable: false, + reason: helperReason, + ...(helperDetail ? { detail: helperDetail } : {}), + })}\n`, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason, + ...(helperDetail ? { helperDetail } : {}), + }); + }); + + it.each([ + ['one printable ASCII character', 'x'], + ['1024 printable ASCII characters', 'x'.repeat(1024)], + ])('preserves %s as helper detail', (_label, helperDetail) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: `${JSON.stringify({ + protocol: 1, + usable: false, + reason: 'gl-context-create-failed', + detail: helperDetail, + })}\n`, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', + helperDetail, + }); + }); + + it.each([ + ['malformed JSON', 'not-json\n'], + [ + 'multiple lines', + '{"protocol":1,"usable":false,"reason":"mpv-create-failed"}\nignored\n', + ], + [ + 'wrong protocol', + '{"protocol":2,"usable":false,"reason":"mpv-create-failed"}\n', + ], + [ + 'wrong usable value', + '{"protocol":1,"usable":true,"reason":"mpv-create-failed"}\n', + ], + [ + 'non-allowlisted reason', + '{"protocol":1,"usable":false,"reason":"loader-injected"}\n', + ], + [ + 'unexpected field', + '{"protocol":1,"usable":false,"reason":"mpv-create-failed","extra":true}\n', + ], + [ + 'invalid detail', + '{"protocol":1,"usable":false,"reason":"mpv-create-failed","detail":1}\n', + ], + ])('does not propagate a helper reason from %s', (_label, stdout) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + }); + }); + + it.each([ + ['empty', ''], + ['control character', 'EGL\tfailure'], + ['trailing line feed', 'EGL failure\n'], + ['DEL character', `EGL${String.fromCharCode(0x7f)}failure`], + ['non-ASCII character', 'EGL échoué'], + ['1025 characters', 'x'.repeat(1025)], + ])( + 'does not propagate any helper fields from %s detail', + (_label, detail) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: `${JSON.stringify({ + protocol: 1, + usable: false, + reason: 'gl-context-create-failed', + detail, + })}\n`, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + }); + } + ); + + it('does not trace helper stderr without the exact player trace flag', () => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n', + stderr: 'libEGL debug output\n', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', + }); + expect(context.writeRuntimeProbeStderr).not.toHaveBeenCalled(); + }); + + it('traces helper stderr as one JSON-escaped line when player tracing is enabled', () => { + const fixture = createFixture(context.rootDir); + const helperStderr = + 'libEGL warning: vendor "mesa"\nfailed path: C:\\driver'; + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n', + stderr: helperStderr, + }); + + expect( + context.createProbe({ + env: { + PATH: '/usr/bin', + IPTVNATOR_TRACE_PLAYER: '1', + }, + })(fixture.helperPath) + ).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', + }); + expect(context.writeRuntimeProbeStderr).toHaveBeenCalledWith( + `${JSON.stringify({ + event: 'embedded-mpv-helper-runtime-probe-stderr', + stderr: helperStderr, + truncated: false, + })}\n` + ); + expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1); + expect( + context.writeRuntimeProbeStderr.mock.calls[0][0].split('\n') + ).toHaveLength(2); + }); + + it('bounds traced helper stderr to 16384 characters and reports truncation', () => { + const fixture = createFixture(context.rootDir); + const helperStderr = `${'x'.repeat(16_384)}discarded`; + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n', + stderr: helperStderr, + }); + + context.createProbe({ + env: { + PATH: '/usr/bin', + IPTVNATOR_TRACE_PLAYER: '1', + }, + })(fixture.helperPath); + + const trace = JSON.parse( + context.writeRuntimeProbeStderr.mock.calls[0][0] + ); + expect(trace).toEqual({ + event: 'embedded-mpv-helper-runtime-probe-stderr', + stderr: 'x'.repeat(16_384), + truncated: true, + }); + expect(trace.stderr).toHaveLength(16_384); + expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts new file mode 100644 index 000000000..ffded9066 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts @@ -0,0 +1,203 @@ +import type { Stats } from 'fs'; +import path from 'path'; +import { createLinuxFrameCopyHelperLaunch } from '../embedded-mpv-frame-copy-runtime'; + +function fakeStat( + kind: 'directory' | 'file' | 'symlink' +): Pick { + return { + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => kind === 'symlink', + }; +} + +describe('createLinuxFrameCopyHelperLaunch', () => { + const helperArgs = ['--runtime-probe']; + + it.each([ + ['system', '/opt/iptvnator/native/iptvnator_mpv_helper'], + [ + 'bundled', + '/tmp/.mount-IPTVnator/resources/app.asar.unpacked/electron-backend/native/iptvnator_mpv_helper', + ], + ] as const)( + 'launches a non-Snap %s helper directly', + (runtimeMode, helperPath) => { + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { + PATH: '/usr/bin', + LD_LIBRARY_PATH: '/tmp/hostile', + }, + helperPath, + helperArgs, + runtimeMode, + }) + ).toEqual({ + usable: true, + command: helperPath, + args: helperArgs, + env: + runtimeMode === 'system' + ? { PATH: '/usr/bin' } + : { + PATH: '/usr/bin', + LD_LIBRARY_PATH: path.join( + path.dirname(helperPath), + 'lib' + ), + }, + }); + } + ); + + it('launches a trusted Snap helper through the connected provider wrapper', () => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper'); + const graphicsRoot = path.join(snapRoot, 'graphics'); + const wrapperPath = path.join( + graphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + const lstatSync = jest.fn((candidatePath: string) => { + if (candidatePath === graphicsRoot) { + return fakeStat('directory') as Stats; + } + if (candidatePath === wrapperPath) { + return fakeStat('file') as Stats; + } + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + }); + const accessSync = jest.fn(); + + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + }, + helperPath, + helperArgs, + runtimeMode: 'bundled', + fileSystem: { lstatSync, accessSync }, + }) + ).toEqual({ + usable: true, + command: wrapperPath, + args: [helperPath, ...helperArgs], + env: expect.objectContaining({ + PATH: '/usr/sbin:/usr/bin:/sbin:/bin', + SNAP: snapRoot, + LD_LIBRARY_PATH: expect.stringContaining( + path.join(nativeDir, 'lib') + ), + }), + }); + expect(lstatSync).toHaveBeenCalledWith(graphicsRoot); + expect(lstatSync).toHaveBeenCalledWith(wrapperPath); + expect(accessSync).toHaveBeenCalledWith( + wrapperPath, + expect.any(Number) + ); + }); + + it.each([ + ['missing mount', 'missing', 'file'], + ['symlink mount', 'symlink', 'file'], + ['missing wrapper', 'directory', 'missing'], + ['symlink wrapper', 'directory', 'symlink'], + ] as const)( + 'fails closed for a trusted Snap with a %s', + (_label, mountKind, wrapperKind) => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper'); + const graphicsRoot = path.join(snapRoot, 'graphics'); + const wrapperPath = path.join( + graphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { SNAP: snapRoot }, + helperPath, + helperArgs, + runtimeMode: 'bundled', + fileSystem: { + lstatSync: (candidatePath) => { + const kind = + candidatePath === graphicsRoot + ? mountKind + : wrapperKind; + if (kind === 'missing') { + throw Object.assign(new Error('missing'), { + code: 'ENOENT', + }); + } + return fakeStat(kind) as Stats; + }, + accessSync: () => undefined, + }, + }) + ).toEqual({ + usable: false, + reason: 'snap-graphics-provider-unavailable', + }); + + expect(wrapperPath).toContain('/graphics/bin/'); + } + ); + + it('fails closed when the provider wrapper is not executable', () => { + const snapRoot = '/var/lib/snapd/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { SNAP: snapRoot }, + helperPath: path.join(nativeDir, 'iptvnator_mpv_helper'), + helperArgs, + runtimeMode: 'bundled', + fileSystem: { + lstatSync: (candidatePath) => + fakeStat( + candidatePath.endsWith('/graphics') + ? 'directory' + : 'file' + ) as Stats, + accessSync: () => { + throw Object.assign(new Error('denied'), { + code: 'EACCES', + }); + }, + }, + }) + ).toEqual({ + usable: false, + reason: 'snap-graphics-provider-unavailable', + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts new file mode 100644 index 000000000..2e8cf8c60 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts @@ -0,0 +1,103 @@ +import { + accessSync as nodeAccessSync, + constants as fileSystemConstants, + lstatSync as nodeLstatSync, +} from 'fs'; +import type * as nodeFileSystem from 'fs'; +import path from 'path'; +import { createLinuxFrameCopyHelperEnvironment } from './helper-environment'; +import { resolveTrustedSnapRoot } from './trusted-snap-root'; +import type { + EmbeddedMpvFrameCopyRuntimeFailureReason, + EmbeddedMpvFrameCopyRuntimeMode, +} from './types'; + +export interface LinuxFrameCopyHelperLaunchFileSystem { + lstatSync(filePath: string): nodeFileSystem.Stats; + accessSync(filePath: string, mode: number): void; +} + +interface CreateLinuxFrameCopyHelperLaunchOptions { + environment: NodeJS.ProcessEnv; + helperPath: string; + helperArgs: string[]; + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode; + fileSystem?: LinuxFrameCopyHelperLaunchFileSystem; +} + +export type LinuxFrameCopyHelperLaunch = + | { + usable: true; + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + } + | { + usable: false; + reason: EmbeddedMpvFrameCopyRuntimeFailureReason; + }; + +export function createLinuxFrameCopyHelperLaunch( + options: CreateLinuxFrameCopyHelperLaunchOptions +): LinuxFrameCopyHelperLaunch { + const nativeDir = path.dirname(options.helperPath); + const env = createLinuxFrameCopyHelperEnvironment( + options.environment, + nativeDir, + options.runtimeMode + ); + const trustedSnapRoot = + options.runtimeMode === 'bundled' + ? resolveTrustedSnapRoot(options.environment, nativeDir) + : null; + if (!trustedSnapRoot) { + return { + usable: true, + command: options.helperPath, + args: options.helperArgs, + env, + }; + } + + const graphicsRoot = path.join(trustedSnapRoot, 'graphics'); + const providerWrapperPath = path.join( + graphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + const fileSystem = options.fileSystem ?? { + lstatSync: nodeLstatSync, + accessSync: nodeAccessSync, + }; + try { + const graphicsRootStat = fileSystem.lstatSync(graphicsRoot); + const providerWrapperStat = fileSystem.lstatSync(providerWrapperPath); + if ( + !graphicsRootStat.isDirectory() || + graphicsRootStat.isSymbolicLink() || + !providerWrapperStat.isFile() || + providerWrapperStat.isSymbolicLink() + ) { + return { + usable: false, + reason: 'snap-graphics-provider-unavailable', + }; + } + fileSystem.accessSync( + providerWrapperPath, + fileSystemConstants.R_OK | fileSystemConstants.X_OK + ); + } catch { + return { + usable: false, + reason: 'snap-graphics-provider-unavailable', + }; + } + + return { + usable: true, + command: providerWrapperPath, + args: [options.helperPath, ...options.helperArgs], + env, + }; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts new file mode 100644 index 000000000..036253afb --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts @@ -0,0 +1,149 @@ +import { unlinkSync, writeFileSync } from 'fs'; +import { + cloneManifest, + createFixture, + mirrorBundledManifestFields, + writeManifest, +} from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy packaged manifest policy', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it('rejects a missing or malformed manifest without throwing', () => { + const missing = createFixture(context.rootDir); + unlinkSync(missing.manifestPath); + expect(context.createProbe()(missing.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-missing', + }); + + const malformed = createFixture(context.rootDir, 'portable'); + writeFileSync(malformed.manifestPath, '{broken\n', { mode: 0o644 }); + expect(context.createProbe()(malformed.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it.each([ + ['origin', 'system-libmpv-frame-copy'], + ['arch', 'arm64'], + ['runtimeMode', 'system'], + ['targets', ['deb']], + ['sourceArchive', null], + ['unexpectedField', true], + ])('rejects a bundled profile mismatch in %s', (field, value) => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + manifest[field] = value; + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it.each([ + ['system', ['deb', 'pacman']], + ['portable', ['appimage']], + ] as const)( + 'rejects an allowed subset of the exact %s profile targets', + (profile, targets) => { + const fixture = createFixture(context.rootDir, profile); + const manifest = cloneManifest(fixture.manifest); + manifest.targets = targets; + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + } + ); + + it('rejects a bundled closure dependency outside the deterministic system allowlist', () => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + const closure = manifest.runtimeDependencyClosure as { + entries: Array<{ needed: string[] }>; + externalDependencies: string[]; + }; + closure.entries[0].needed = ['libambient-only.so.1']; + closure.externalDependencies = ['libambient-only.so.1']; + mirrorBundledManifestFields(manifest); + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it('requires externalDependencies to exactly equal the sorted external closure', () => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + const closure = manifest.runtimeDependencyClosure as { + entries: Array<{ needed: string[] }>; + externalDependencies: string[]; + }; + closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6']; + closure.externalDependencies = []; + mirrorBundledManifestFields(manifest); + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it('accepts only the exact deterministic external-system library declaration', () => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + (manifest.externalSystemLibraries as unknown[]).pop(); + mirrorBundledManifestFields(manifest); + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it('accepts bundled dependencies on declared system interfaces and the glibc toolchain', () => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + const closure = manifest.runtimeDependencyClosure as { + entries: Array<{ needed: string[] }>; + externalDependencies: string[]; + }; + closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6']; + closure.externalDependencies = ['libEGL.so.1', 'libc.so.6']; + mirrorBundledManifestFields(manifest); + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual( + expect.objectContaining({ usable: true, runtimeMode: 'bundled' }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts new file mode 100644 index 000000000..23e736193 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts @@ -0,0 +1,267 @@ +import { isDeepStrictEqual } from 'util'; +import { + BASE_MANIFEST_FIELDS, + BUNDLED_MANIFEST_FIELDS, + DEVELOPMENT_MANIFEST_FIELDS, + EXPECTED_ARTIFACTS, + EXPECTED_DEVELOPMENT_ARTIFACTS, + EXPECTED_DEVELOPMENT_PROCESS_ISOLATION, + EXPECTED_PROCESS_ISOLATION, + PROFILE_CONTRACTS, + validateLinuxSourceArchiveBinding, + SYSTEM_PACKAGE_DEPENDENCIES, + VERSIONED_LIBMPV_PATTERN, +} from './contracts'; +import { validateRuntimeClosure } from './runtime-closure-validator'; +import { validateSourceRuntimePolicy } from './source-runtime-validator'; +import type { RuntimeProfile, ValidManifest, ValidationResult } from './types'; +import { + hasExactFields, + isObject, + validateRuntimeFiles, + validateTargets, + validationFailure, +} from './validation-primitives'; + +export function validatePackagedManifest( + manifest: Record +): ValidationResult { + if ( + manifest.schemaVersion !== 1 || + manifest.platform !== 'linux' || + manifest.arch !== 'x64' || + typeof manifest.profile !== 'string' || + !Object.prototype.hasOwnProperty.call( + PROFILE_CONTRACTS, + manifest.profile + ) + ) { + return validationFailure('runtime-manifest-invalid'); + } + + const profile = manifest.profile as RuntimeProfile; + const contract = PROFILE_CONTRACTS[profile]; + if ( + manifest.origin !== contract.origin || + manifest.runtimeMode !== contract.runtimeMode || + !hasExactFields( + manifest, + contract.runtimeMode === 'bundled' + ? BUNDLED_MANIFEST_FIELDS + : BASE_MANIFEST_FIELDS + ) || + typeof manifest.generatedAt !== 'string' || + manifest.generatedAt.trim() === '' || + Number.isNaN(Date.parse(manifest.generatedAt)) || + !validateTargets(manifest.targets, contract.targets) || + !isDeepStrictEqual(manifest.artifacts, EXPECTED_ARTIFACTS) || + !isDeepStrictEqual( + manifest.processIsolation, + EXPECTED_PROCESS_ISOLATION + ) || + manifest.nativeViewFallback !== 'process-isolated mpv --wid' || + typeof manifest.libmpvSoname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname) + ) { + return validationFailure('runtime-manifest-invalid'); + } + + if (contract.runtimeMode === 'system') { + if ( + !isDeepStrictEqual( + manifest.packageDependencies, + SYSTEM_PACKAGE_DEPENDENCIES + ) || + !isDeepStrictEqual(manifest.runtimeFiles, []) || + manifest.runtimeTotalBytes !== 0 + ) { + return validationFailure('runtime-manifest-invalid'); + } + return { + value: { + profile, + runtimeMode: 'system', + runtimeFiles: [], + }, + }; + } + + const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles); + if ( + !runtimeFiles || + !isDeepStrictEqual(manifest.packageDependencies, {}) || + !runtimeFiles.some(({ name }) => name === 'libmpv.so') || + !runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) || + manifest.runtimeTotalBytes !== + runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ) || + !validateRuntimeClosure( + manifest.runtimeDependencyClosure, + runtimeFiles, + manifest.libmpvSoname, + manifest.externalSystemLibraries + ) || + validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !== + 0 || + !validateSourceRuntimePolicy( + manifest.sourceRuntime, + runtimeFiles, + manifest.runtimeDependencyClosure, + manifest.externalSystemLibraries + ) + ) { + return validationFailure('runtime-manifest-invalid'); + } + + return { + value: { + profile, + runtimeMode: 'bundled', + runtimeFiles, + }, + }; +} + +function validateSystemDevelopmentSource( + value: unknown, + buildInputMode: 'system-dev' | 'system-build-inputs' +): boolean { + if (buildInputMode === 'system-dev') { + return isDeepStrictEqual(value, { + linuxBackend: 'process-isolated mpv --wid', + warning: 'Development-only unmanaged system libmpv toolchain.', + }); + } + if ( + !isObject(value) || + !hasExactFields(value, [ + 'buildInputs', + 'linuxBackend', + 'sourceDistribution', + ]) || + value.linuxBackend !== 'process-isolated mpv --wid' || + typeof value.sourceDistribution !== 'string' || + value.sourceDistribution.trim() === '' || + !isObject(value.buildInputs) || + !hasExactFields(value.buildInputs, ['libmpvDevPackage', 'mpvPackage']) + ) { + return false; + } + return ['libmpvDevPackage', 'mpvPackage'].every((packageField) => { + const packageName = value.buildInputs[packageField]; + return typeof packageName === 'string' && packageName.trim().length > 0; + }); +} + +export function validateDevelopmentManifest( + manifest: Record +): ValidationResult { + const buildInputMode = manifest.buildInputMode; + if ( + !hasExactFields(manifest, DEVELOPMENT_MANIFEST_FIELDS) || + manifest.schemaVersion !== 1 || + manifest.origin !== 'linux-frame-copy-build' || + manifest.platform !== 'linux' || + manifest.arch !== 'x64' || + typeof manifest.generatedAt !== 'string' || + manifest.generatedAt.trim() === '' || + Number.isNaN(Date.parse(manifest.generatedAt)) || + !['system-dev', 'system-build-inputs', 'bundled-runtime'].includes( + String(buildInputMode) + ) || + !isDeepStrictEqual(manifest.allowedPackageRuntimeModes, [ + 'system', + 'bundled', + ]) || + !isDeepStrictEqual( + manifest.artifacts, + EXPECTED_DEVELOPMENT_ARTIFACTS + ) || + !isDeepStrictEqual( + manifest.processIsolation, + EXPECTED_DEVELOPMENT_PROCESS_ISOLATION + ) || + manifest.nativeViewFallback !== 'process-isolated mpv --wid' + ) { + return validationFailure('runtime-manifest-invalid'); + } + + if ( + buildInputMode === 'system-dev' || + buildInputMode === 'system-build-inputs' + ) { + if ( + manifest.sourceRuntimeValidated !== false || + !isDeepStrictEqual(manifest.packageRuntimeAvailability, { + system: false, + bundled: false, + }) || + manifest.libmpvSoname !== null || + !isDeepStrictEqual(manifest.runtimeFiles, []) || + manifest.runtimeTotalBytes !== 0 || + manifest.sourceArchive !== null || + !validateSystemDevelopmentSource( + manifest.sourceRuntime, + buildInputMode + ) + ) { + return validationFailure('runtime-manifest-invalid'); + } + return { + value: { + profile: 'development', + runtimeMode: 'system', + runtimeFiles: [], + }, + }; + } + + const sourceRuntime = manifest.sourceRuntime; + const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles); + if ( + buildInputMode !== 'bundled-runtime' || + manifest.sourceRuntimeValidated !== true || + !isDeepStrictEqual(manifest.packageRuntimeAvailability, { + system: true, + bundled: true, + }) || + typeof manifest.libmpvSoname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname) || + !runtimeFiles || + !runtimeFiles.some(({ name }) => name === 'libmpv.so') || + !runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) || + manifest.runtimeTotalBytes !== + runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ) || + !isObject(sourceRuntime) || + (manifest.sourceArchive !== null && + validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !== + 0) || + !validateRuntimeClosure( + sourceRuntime.runtimeDependencyClosure, + runtimeFiles, + manifest.libmpvSoname, + sourceRuntime.externalSystemLibraries + ) || + !validateSourceRuntimePolicy( + sourceRuntime, + runtimeFiles, + sourceRuntime.runtimeDependencyClosure, + sourceRuntime.externalSystemLibraries + ) + ) { + return validationFailure('runtime-manifest-invalid'); + } + + return { + value: { + profile: 'development', + runtimeMode: 'bundled', + runtimeFiles, + }, + }; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-integrity.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-integrity.spec.ts new file mode 100644 index 000000000..4dd40ba8d --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-integrity.spec.ts @@ -0,0 +1,246 @@ +import { + chmodSync, + constants as fsConstants, + mkdirSync, + readFileSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import path from 'path'; +import type { RuntimeFile, RuntimeFixture } from './runtime.spec-data'; +import { + cloneManifest, + createFixture, + writeManifest, +} from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy package integrity', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it('rejects system manifests with a private library directory', () => { + const fixture = createFixture(context.rootDir); + mkdirSync(path.join(fixture.nativeDir, 'lib')); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-library-directory-invalid', + }); + }); + + it.each([ + { + label: 'missing addon', + mutate(fixture: RuntimeFixture) { + unlinkSync(path.join(fixture.nativeDir, 'embedded_mpv.node')); + }, + reason: 'runtime-artifact-missing', + }, + { + label: 'non-executable helper', + mutate(fixture: RuntimeFixture) { + chmodSync(fixture.helperPath, 0o644); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'setuid helper', + mutate(fixture: RuntimeFixture) { + chmodSync(fixture.helperPath, 0o4755); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'setgid helper', + mutate(fixture: RuntimeFixture) { + chmodSync(fixture.helperPath, 0o2755); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'sticky helper', + mutate(fixture: RuntimeFixture) { + chmodSync(fixture.helperPath, 0o1755); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'wrong reader mode', + mutate(fixture: RuntimeFixture) { + chmodSync( + path.join( + fixture.nativeDir, + 'embedded_mpv_frame_reader.node' + ), + 0o600 + ); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'symlinked helper', + mutate(fixture: RuntimeFixture) { + const target = `${fixture.helperPath}.real`; + writeFileSync(target, '#!/bin/sh\n', { mode: 0o755 }); + unlinkSync(fixture.helperPath); + symlinkSync(target, fixture.helperPath); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'reader directory', + mutate(fixture: RuntimeFixture) { + const readerPath = path.join( + fixture.nativeDir, + 'embedded_mpv_frame_reader.node' + ); + unlinkSync(readerPath); + mkdirSync(readerPath); + }, + reason: 'runtime-artifact-invalid', + }, + ])('rejects a $label', ({ mutate, reason }) => { + const fixture = createFixture(context.rootDir); + mutate(fixture); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason, + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: 'missing declared library', + mutate(fixture: RuntimeFixture) { + unlinkSync(path.join(fixture.nativeDir, 'lib', 'libmpv.so.2')); + }, + reason: 'runtime-library-missing', + }, + { + label: 'undeclared library', + mutate(fixture: RuntimeFixture) { + writeFileSync( + path.join(fixture.nativeDir, 'lib', 'libextra.so'), + 'extra' + ); + }, + reason: 'runtime-library-undeclared', + }, + { + label: 'library size mismatch', + mutate(fixture: RuntimeFixture) { + writeFileSync( + path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'), + 'different-length' + ); + }, + reason: 'runtime-library-size-mismatch', + }, + { + label: 'library hash mismatch', + mutate(fixture: RuntimeFixture) { + const runtimePath = path.join( + fixture.nativeDir, + 'lib', + 'libmpv.so.2' + ); + const original = readFileSync(runtimePath); + writeFileSync( + runtimePath, + Buffer.from(original.map((value) => value ^ 0xff)) + ); + }, + reason: 'runtime-library-hash-mismatch', + }, + { + label: 'symlinked library', + mutate(fixture: RuntimeFixture) { + const runtimePath = path.join( + fixture.nativeDir, + 'lib', + 'libmpv.so.2' + ); + const targetPath = path.join( + fixture.nativeDir, + 'libmpv-real.so.2' + ); + writeFileSync( + targetPath, + fixture.runtimeContents['libmpv.so.2'] + ); + unlinkSync(runtimePath); + symlinkSync(targetPath, runtimePath); + }, + reason: 'runtime-library-invalid', + }, + { + label: 'library directory', + mutate(fixture: RuntimeFixture) { + const runtimePath = path.join( + fixture.nativeDir, + 'lib', + 'libmpv.so.2' + ); + unlinkSync(runtimePath); + mkdirSync(runtimePath); + }, + reason: 'runtime-library-invalid', + }, + ])('rejects a $label', ({ mutate, reason }) => { + const fixture = createFixture(context.rootDir, 'portable'); + mutate(fixture); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason, + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it.each(['../libmpv.so.2', '/tmp/libmpv.so.2', 'sub/libmpv.so.2'])( + 'rejects unsafe runtime path %s', + (unsafeName) => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + const runtimeFiles = manifest.runtimeFiles as RuntimeFile[]; + runtimeFiles[0].name = unsafeName; + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + } + ); + + it('uses read and execute access checks for declared artifacts', () => { + const fixture = createFixture(context.rootDir); + const probeRuntime = context.createProbe(); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + const accessMock = context.fileSystem.accessSync as jest.Mock; + expect(accessMock).toHaveBeenCalledWith( + fixture.helperPath, + fsConstants.R_OK | fsConstants.X_OK + ); + expect(accessMock).toHaveBeenCalledWith( + path.join(fixture.nativeDir, 'embedded_mpv_frame_reader.node'), + fsConstants.R_OK + ); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-validator.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-validator.ts new file mode 100644 index 000000000..145eba544 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-validator.ts @@ -0,0 +1,238 @@ +import { createHash } from 'crypto'; +import * as nodeFileSystem from 'fs'; +import path from 'path'; +import { + FRAME_COPY_ADDON_NAME, + FRAME_COPY_HELPER_NAME, + FRAME_COPY_READER_NAME, +} from './contracts'; +import { + validateDevelopmentManifest, + validatePackagedManifest, +} from './manifest-validator'; +import type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeFileSystem, + RuntimeFile, + ValidatedPackage, + ValidationResult, +} from './types'; +import { + isMissingFileError, + isValidationFailure, + readManifest, + validationFailure, +} from './validation-primitives'; + +function validateRegularArtifact( + filePath: string, + accessMode: number, + expectedMode: number | null, + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem +): ValidationResult { + let stat: nodeFileSystem.Stats; + try { + stat = fileSystem.lstatSync(filePath); + } catch (error) { + return validationFailure( + isMissingFileError(error) + ? 'runtime-artifact-missing' + : 'runtime-artifact-invalid' + ); + } + + if ( + stat.isSymbolicLink() || + !stat.isFile() || + (expectedMode !== null && (stat.mode & 0o7777) !== expectedMode) + ) { + return validationFailure('runtime-artifact-invalid'); + } + try { + fileSystem.accessSync(filePath, accessMode); + } catch { + return validationFailure('runtime-artifact-invalid'); + } + return { value: stat }; +} + +function pathExistsByLstat( + filePath: string, + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem +): boolean { + try { + fileSystem.lstatSync(filePath); + return true; + } catch (error) { + if (isMissingFileError(error)) { + return false; + } + throw error; + } +} + +function validateBundledRuntimeFiles( + nativeDir: string, + runtimeFiles: RuntimeFile[], + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem +): ValidationResult { + const libDir = path.join(nativeDir, 'lib'); + let libStat: nodeFileSystem.Stats; + try { + libStat = fileSystem.lstatSync(libDir); + } catch { + return validationFailure('runtime-library-directory-invalid'); + } + if (libStat.isSymbolicLink() || !libStat.isDirectory()) { + return validationFailure('runtime-library-directory-invalid'); + } + + let packagedNames: string[]; + try { + packagedNames = fileSystem.readdirSync(libDir) as string[]; + } catch { + return validationFailure('runtime-library-directory-invalid'); + } + const declaredNames = new Set(runtimeFiles.map(({ name }) => name)); + if (packagedNames.some((name) => !declaredNames.has(name))) { + return validationFailure('runtime-library-undeclared'); + } + + for (const runtimeFile of runtimeFiles) { + const runtimePath = path.join(libDir, runtimeFile.name); + let stat: nodeFileSystem.Stats; + try { + stat = fileSystem.lstatSync(runtimePath); + } catch (error) { + return validationFailure( + isMissingFileError(error) + ? 'runtime-library-missing' + : 'runtime-library-invalid' + ); + } + if (stat.isSymbolicLink() || !stat.isFile()) { + return validationFailure('runtime-library-invalid'); + } + try { + fileSystem.accessSync(runtimePath, nodeFileSystem.constants.R_OK); + } catch { + return validationFailure('runtime-library-invalid'); + } + if (stat.size !== runtimeFile.size) { + return validationFailure('runtime-library-size-mismatch'); + } + + let contents: Buffer; + try { + contents = fileSystem.readFileSync(runtimePath); + } catch { + return validationFailure('runtime-library-invalid'); + } + if (contents.length !== runtimeFile.size) { + return validationFailure('runtime-library-size-mismatch'); + } + const actualSha256 = createHash('sha256') + .update(contents) + .digest('hex'); + if (actualSha256 !== runtimeFile.sha256) { + return validationFailure('runtime-library-hash-mismatch'); + } + } + return { value: true }; +} + +export function validatePackage( + helperPath: string, + manifestPath: string, + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem, + manifestContract: EmbeddedMpvFrameCopyManifestContract +): ValidationResult { + const nativeDir = path.dirname(helperPath); + if ( + path.basename(helperPath) !== FRAME_COPY_HELPER_NAME || + path.dirname(manifestPath) !== nativeDir + ) { + return validationFailure('runtime-artifact-invalid'); + } + + const manifestArtifact = validateRegularArtifact( + manifestPath, + nodeFileSystem.constants.R_OK, + 0o644, + fileSystem + ); + if (isValidationFailure(manifestArtifact)) { + return validationFailure( + manifestArtifact.reason === 'runtime-artifact-missing' + ? 'runtime-manifest-missing' + : 'runtime-manifest-invalid' + ); + } + const manifestResult = readManifest(manifestPath, fileSystem); + if (isValidationFailure(manifestResult)) { + return manifestResult; + } + const validManifest = + manifestContract === 'packaged' + ? validatePackagedManifest(manifestResult.value) + : validateDevelopmentManifest(manifestResult.value); + if (isValidationFailure(validManifest)) { + return validManifest; + } + + for (const [artifactPath, accessMode, expectedMode] of [ + [ + path.join(nativeDir, FRAME_COPY_ADDON_NAME), + nodeFileSystem.constants.R_OK, + null, + ], + [ + path.join(nativeDir, FRAME_COPY_READER_NAME), + nodeFileSystem.constants.R_OK, + 0o644, + ], + [ + helperPath, + nodeFileSystem.constants.R_OK | nodeFileSystem.constants.X_OK, + 0o755, + ], + ] as const) { + const artifact = validateRegularArtifact( + artifactPath, + accessMode, + expectedMode, + fileSystem + ); + if (isValidationFailure(artifact)) { + return artifact; + } + } + + const libDir = path.join(nativeDir, 'lib'); + if (validManifest.value.runtimeMode === 'system') { + try { + if (pathExistsByLstat(libDir, fileSystem)) { + return validationFailure('runtime-library-directory-invalid'); + } + } catch { + return validationFailure('runtime-library-directory-invalid'); + } + } else { + const bundledRuntime = validateBundledRuntimeFiles( + nativeDir, + validManifest.value.runtimeFiles, + fileSystem + ); + if (isValidationFailure(bundledRuntime)) { + return bundledRuntime; + } + } + + return { + value: { + manifest: validManifest.value, + helperPath, + nativeDir, + }, + }; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts new file mode 100644 index 000000000..6b6a0f610 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts @@ -0,0 +1,391 @@ +import { + accessSync, + chmodSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from 'fs'; +import path from 'path'; +import { + cloneManifest, + createFixture, + writeManifest, +} from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy runtime probe orchestration', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it('validates a system package, sanitizes loader overrides, and caches by helper/manifest identity', () => { + const fixture = createFixture(context.rootDir); + const probeRuntime = context.createProbe({ + env: { + PATH: '/usr/bin', + LIBGL_ALWAYS_SOFTWARE: '1', + GALLIUM_DRIVER: 'llvmpipe', + BASH_ENV: '/tmp/hostile-bash-env', + ENV: '/tmp/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/tmp/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/tmp/hostile-cdpath', + 'BASH_FUNC_dirname%%': + '() { printf /tmp/hostile-provider-root; exit 0; }', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/ambient/libs', + LD_ORIGIN_PATH: '/tmp/hostile-origin', + LD_PRELOAD: '/tmp/inject.so', + __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/tmp/hostile-egl-platform.json', + GBM_BACKEND: '../../../../../tmp/hostile-gbm', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', + MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', + LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', + VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', + VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', + VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', + VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', + VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', + VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', + VK_ADD_IMPLICIT_LAYER_PATH: + '/tmp/hostile-vulkan-add-implicit-layers', + VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', + }, + }); + + expect(probeRuntime(fixture.helperPath)).toEqual({ + usable: true, + profile: 'system', + runtimeMode: 'system', + libmpv: '2.3', + renderApi: 'egl', + }); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + fixture.helperPath, + ['--runtime-probe'], + { + encoding: 'utf8', + timeout: 3000, + killSignal: 'SIGKILL', + windowsHide: true, + maxBuffer: 16 * 1024 * 1024, + env: { + PATH: '/usr/bin', + LIBGL_ALWAYS_SOFTWARE: '1', + GALLIUM_DRIVER: 'llvmpipe', + }, + } + ); + expect(context.fileSystem.readFileSync).toHaveBeenCalled(); + }); + + it('invalidates a cached result when helper or manifest bytes change under identical stats', () => { + const fixture = createFixture(context.rootDir); + const fixedStats = new Map([ + [fixture.helperPath, lstatSync(fixture.helperPath)], + [fixture.manifestPath, lstatSync(fixture.manifestPath)], + ]); + context.fileSystem = { + ...context.fileSystem, + lstatSync: jest.fn( + (filePath: string) => + fixedStats.get(filePath) ?? lstatSync(filePath) + ), + }; + const probeRuntime = context.createProbe(); + + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + const changedHelper = readFileSync(fixture.helperPath); + changedHelper[0] ^= 0xff; + writeFileSync(fixture.helperPath, changedHelper); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + fixture.manifest.generatedAt = '2026-07-18T00:00:00.000Z'; + writeManifest(fixture.manifestPath, fixture.manifest); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(3); + }); + + it.each(['portable', 'flatpak'] as const)( + 'validates the exact %s bundled closure and uses only its private library directory', + (profile) => { + const fixture = createFixture(context.rootDir, profile); + const probeRuntime = context.createProbe(); + + expect(probeRuntime(fixture.helperPath)).toEqual( + expect.objectContaining({ + usable: true, + profile, + runtimeMode: 'bundled', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + fixture.helperPath, + ['--runtime-probe'], + expect.objectContaining({ + env: { + PATH: '/usr/bin', + LD_LIBRARY_PATH: path.join(fixture.nativeDir, 'lib'), + }, + }) + ); + } + ); + + it('runs a packaged Snap probe through the connected graphics provider wrapper', () => { + const actualSnapRoot = path.join(context.rootDir, 'snap-root'); + const actualFixtureRoot = path.join(actualSnapRoot, 'fixture'); + const fixture = createFixture(actualFixtureRoot, 'portable'); + const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics'); + const actualProviderWrapper = path.join( + actualGraphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + mkdirSync(path.dirname(actualProviderWrapper), { recursive: true }); + writeFileSync(actualProviderWrapper, '#!/bin/sh\nexec "$@"\n', { + mode: 0o755, + }); + + const virtualSnapRoot = '/snap/iptvnator/42'; + const linuxTriplet = 'x86_64-linux-gnu'; + const snapLibraries = (...relativePaths: string[]): string[] => + relativePaths.map((relativePath) => + path.join(virtualSnapRoot, relativePath) + ); + const virtualNativeDir = path.join( + virtualSnapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const virtualHelperPath = path.join( + virtualNativeDir, + 'iptvnator_mpv_helper' + ); + const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics'); + const virtualProviderWrapper = path.join( + virtualGraphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + const translatePath = (candidatePath: string): string => { + if ( + candidatePath === virtualNativeDir || + candidatePath.startsWith(`${virtualNativeDir}${path.sep}`) + ) { + return path.join( + fixture.nativeDir, + path.relative(virtualNativeDir, candidatePath) + ); + } + if ( + candidatePath === virtualGraphicsRoot || + candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`) + ) { + return path.join( + actualGraphicsRoot, + path.relative(virtualGraphicsRoot, candidatePath) + ); + } + return candidatePath; + }; + const virtualFileSystem = { + accessSync: jest.fn((candidatePath: string, mode: number) => + accessSync(translatePath(candidatePath), mode) + ), + lstatSync: jest.fn((candidatePath: string) => + lstatSync(translatePath(candidatePath)) + ), + readFileSync: jest.fn((candidatePath: string) => + readFileSync(translatePath(candidatePath)) + ), + readdirSync: jest.fn((candidatePath: string) => + readdirSync(translatePath(candidatePath)) + ), + }; + const probeRuntime = context.createProbe({ + env: { + PATH: '/snap/bin:/usr/bin', + SNAP: virtualSnapRoot, + SNAP_DESKTOP_RUNTIME: path.join( + virtualSnapRoot, + 'gnome-platform' + ), + SNAP_LIBRARY_PATH: + '/var/lib/snapd/lib/gl:/var/lib/snapd/lib/gl/nvidia', + }, + fileSystem: virtualFileSystem, + }); + + expect(probeRuntime(virtualHelperPath)).toEqual( + expect.objectContaining({ + usable: true, + profile: 'portable', + runtimeMode: 'bundled', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + virtualProviderWrapper, + [virtualHelperPath, '--runtime-probe'], + expect.objectContaining({ + env: expect.objectContaining({ + SNAP: virtualSnapRoot, + LD_LIBRARY_PATH: [ + path.join(virtualNativeDir, 'lib'), + '/var/lib/snapd/lib/gl', + '/var/lib/snapd/lib/gl/nvidia', + ...snapLibraries( + `graphics/usr/lib/${linuxTriplet}`, + `graphics/usr/lib/${linuxTriplet}/vdpau` + ), + '/usr/lib/x86_64-linux-gnu', + ...snapLibraries( + `gnome-platform/lib/${linuxTriplet}`, + `gnome-platform/usr/lib/${linuxTriplet}`, + `gnome-platform/usr/lib/${linuxTriplet}/mesa`, + `gnome-platform/usr/lib/${linuxTriplet}/mesa-egl`, + `gnome-platform/usr/lib/${linuxTriplet}/dri`, + `gnome-platform/usr/lib/${linuxTriplet}/pulseaudio`, + 'lib', + 'usr/lib', + `lib/${linuxTriplet}`, + `usr/lib/${linuxTriplet}` + ), + ].join(':'), + }), + }) + ); + }); + + it('reports a stable unavailable reason when the Snap graphics provider is disconnected', () => { + const actualSnapRoot = path.join(context.rootDir, 'snap-root'); + const fixture = createFixture( + path.join(actualSnapRoot, 'fixture'), + 'portable' + ); + const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics'); + mkdirSync(actualGraphicsRoot, { recursive: true }); + + const virtualSnapRoot = '/snap/iptvnator/42'; + const virtualNativeDir = path.join( + virtualSnapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const translatePath = (candidatePath: string): string => { + if ( + candidatePath === virtualNativeDir || + candidatePath.startsWith(`${virtualNativeDir}${path.sep}`) + ) { + return path.join( + fixture.nativeDir, + path.relative(virtualNativeDir, candidatePath) + ); + } + const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics'); + if ( + candidatePath === virtualGraphicsRoot || + candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`) + ) { + return path.join( + actualGraphicsRoot, + path.relative(virtualGraphicsRoot, candidatePath) + ); + } + return candidatePath; + }; + const probeRuntime = context.createProbe({ + env: { SNAP: virtualSnapRoot }, + fileSystem: { + accessSync: (candidatePath, mode) => + accessSync(translatePath(candidatePath), mode), + lstatSync: (candidatePath) => + lstatSync(translatePath(candidatePath)), + readFileSync: (candidatePath) => + readFileSync(translatePath(candidatePath)), + readdirSync: (candidatePath) => + readdirSync(translatePath(candidatePath)), + }, + }); + + expect( + probeRuntime(path.join(virtualNativeDir, 'iptvnator_mpv_helper')) + ).toEqual({ + usable: false, + reason: 'snap-graphics-provider-unavailable', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it('reprobes when the helper identity changes', () => { + const fixture = createFixture(context.rootDir); + const probeRuntime = context.createProbe(); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + writeFileSync(fixture.helperPath, '#!/bin/sh\n# changed\n'); + chmodSync(fixture.helperPath, 0o755); + + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2); + }); + + it('reprobes when the manifest identity changes', () => { + const fixture = createFixture(context.rootDir); + const probeRuntime = context.createProbe(); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + const manifest = cloneManifest(fixture.manifest); + manifest.generatedAt = '2026-07-17T00:01:00.000Z'; + writeManifest(fixture.manifestPath, manifest); + + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['linux', 'arm64', 'unsupported-architecture'], + ['linux', 'arm', 'unsupported-architecture'], + ['darwin', 'x64', 'unsupported-platform'], + ] as const)( + 'does not probe unsupported %s/%s runtimes', + (platform, arch, reason) => { + const fixture = createFixture(context.rootDir); + + expect( + context.createProbe({ platform, arch })(fixture.helperPath) + ).toEqual({ + usable: false, + reason, + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + } + ); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts new file mode 100644 index 000000000..2b9ad7e2d --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts @@ -0,0 +1,334 @@ +import { spawnSync as nodeSpawnSync } from 'child_process'; +import * as nodeFileSystem from 'fs'; +import path from 'path'; +import { + RUNTIME_MANIFEST_NAME, + RUNTIME_PROBE_MAX_BUFFER_BYTES, + RUNTIME_PROBE_PROTOCOL, + RUNTIME_PROBE_TIMEOUT_MS, +} from './contracts'; +import { createLinuxFrameCopyHelperLaunch } from './helper-launch'; +import { validatePackage } from './package-validator'; +import { EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS } from './types'; +import type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeDependencies, + EmbeddedMpvFrameCopyRuntimeFileSystem, + EmbeddedMpvFrameCopyRuntimeResult, + EmbeddedMpvHelperRuntimeProbeFailureReason, + ValidManifest, + ValidatedPackage, +} from './types'; +import { + failure, + fileIdentity, + hasExactFields, + isMissingFileError, + isObject, + isValidationFailure, +} from './validation-primitives'; + +const HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST = new Set( + Object.values(EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS) +); +const HELPER_RUNTIME_PROBE_STDERR_LIMIT = 16_384; +const HELPER_RUNTIME_PROBE_STDERR_EVENT = + 'embedded-mpv-helper-runtime-probe-stderr'; + +interface ParsedFailedProbe { + helperReason: EmbeddedMpvHelperRuntimeProbeFailureReason; + helperDetail?: string; +} + +function isSafeHelperDetail(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length >= 1 && + value.length <= 1024 && + !/[^\x20-\x7e]/.test(value) + ); +} + +function parseFailedProbe(stdout: unknown): ParsedFailedProbe | null { + if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(stdout.slice(0, -1)); + } catch { + return null; + } + if (!isObject(parsed)) { + return null; + } + + const hasDetail = Object.prototype.hasOwnProperty.call(parsed, 'detail'); + const fields = hasDetail + ? ['detail', 'protocol', 'reason', 'usable'] + : ['protocol', 'reason', 'usable']; + if ( + !hasExactFields(parsed, fields) || + parsed.protocol !== RUNTIME_PROBE_PROTOCOL || + parsed.usable !== false || + typeof parsed.reason !== 'string' || + !HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST.has(parsed.reason) || + (hasDetail && !isSafeHelperDetail(parsed.detail)) + ) { + return null; + } + return { + helperReason: + parsed.reason as EmbeddedMpvHelperRuntimeProbeFailureReason, + ...(hasDetail ? { helperDetail: parsed.detail as string } : {}), + }; +} + +function parseSuccessfulProbe( + stdout: unknown, + manifest: ValidManifest +): EmbeddedMpvFrameCopyRuntimeResult { + if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) { + return failure('helper-probe-invalid-output'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(stdout.slice(0, -1)); + } catch { + return failure('helper-probe-invalid-output'); + } + if (!isObject(parsed)) { + return failure('helper-probe-invalid-output'); + } + if (parsed.protocol !== RUNTIME_PROBE_PROTOCOL) { + return failure('helper-probe-protocol-mismatch'); + } + if (parsed.usable !== true) { + return failure( + parsed.usable === false + ? 'helper-probe-unusable' + : 'helper-probe-invalid-output' + ); + } + if ( + !hasExactFields(parsed, [ + 'libmpv', + 'protocol', + 'renderApi', + 'usable', + ]) || + typeof parsed.libmpv !== 'string' || + parsed.libmpv.trim() === '' || + parsed.renderApi !== 'egl' + ) { + return failure('helper-probe-invalid-output'); + } + return { + usable: true, + profile: manifest.profile, + runtimeMode: manifest.runtimeMode, + libmpv: parsed.libmpv, + renderApi: parsed.renderApi, + }; +} + +function traceHelperProbeStderr( + stderr: unknown, + dependencies: EmbeddedMpvFrameCopyRuntimeDependencies +): void { + if ( + dependencies.env.IPTVNATOR_TRACE_PLAYER !== '1' || + typeof stderr !== 'string' || + stderr.length === 0 + ) { + return; + } + + const boundedStderr = stderr.slice(0, HELPER_RUNTIME_PROBE_STDERR_LIMIT); + const output = `${JSON.stringify({ + event: HELPER_RUNTIME_PROBE_STDERR_EVENT, + stderr: boundedStderr, + truncated: stderr.length > boundedStderr.length, + })}\n`; + try { + dependencies.writeStderr(output); + } catch { + // Opt-in diagnostics must never change the fail-closed probe result. + } +} + +function runHelperProbe( + runtimePackage: ValidatedPackage, + dependencies: EmbeddedMpvFrameCopyRuntimeDependencies +): EmbeddedMpvFrameCopyRuntimeResult { + const launch = createLinuxFrameCopyHelperLaunch({ + environment: dependencies.env, + helperPath: runtimePackage.helperPath, + helperArgs: ['--runtime-probe'], + runtimeMode: runtimePackage.manifest.runtimeMode, + fileSystem: dependencies.fileSystem, + }); + if (launch.usable === false) { + return failure(launch.reason); + } + + let result: ReturnType; + try { + result = dependencies.spawnSync(launch.command, launch.args, { + encoding: 'utf8', + timeout: RUNTIME_PROBE_TIMEOUT_MS, + killSignal: 'SIGKILL', + windowsHide: true, + maxBuffer: RUNTIME_PROBE_MAX_BUFFER_BYTES, + env: launch.env, + }); + } catch { + return failure('helper-probe-spawn-error'); + } + traceHelperProbeStderr(result.stderr, dependencies); + + if ( + result.error && + 'code' in result.error && + result.error.code === 'ETIMEDOUT' + ) { + return failure('helper-probe-timeout'); + } + if (result.error) { + return failure('helper-probe-spawn-error'); + } + if (result.signal) { + return failure('helper-probe-signaled'); + } + if (result.status !== 0) { + const helperFailure = parseFailedProbe(result.stdout); + return helperFailure + ? { + usable: false, + reason: 'helper-probe-failed', + ...helperFailure, + } + : failure('helper-probe-failed'); + } + return parseSuccessfulProbe(result.stdout, runtimePackage.manifest); +} + +/** + * Creates an isolated probe/cache. Production uses the singleton below; + * tests inject filesystem and spawn collaborators through this factory. + */ +export function createEmbeddedMpvFrameCopyRuntimeProbe( + overrides: Partial = {} +): ( + helperPath: string, + manifestContract?: EmbeddedMpvFrameCopyManifestContract +) => EmbeddedMpvFrameCopyRuntimeResult { + const defaultFileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem = { + accessSync: (filePath, mode) => + nodeFileSystem.accessSync(filePath, mode), + lstatSync: (filePath) => nodeFileSystem.lstatSync(filePath), + readFileSync: (filePath) => nodeFileSystem.readFileSync(filePath), + readdirSync: (filePath) => nodeFileSystem.readdirSync(filePath), + }; + const dependencies: EmbeddedMpvFrameCopyRuntimeDependencies = { + platform: process.platform, + arch: process.arch, + env: process.env, + fileSystem: defaultFileSystem, + spawnSync: nodeSpawnSync, + writeStderr: (output) => { + nodeFileSystem.writeSync(process.stderr.fd, output); + }, + ...overrides, + }; + const resultCache = new Map(); + + return ( + helperPath: string, + manifestContract: EmbeddedMpvFrameCopyManifestContract = 'packaged' + ): EmbeddedMpvFrameCopyRuntimeResult => { + if (dependencies.platform !== 'linux') { + return failure('unsupported-platform'); + } + if (dependencies.arch !== 'x64') { + return failure('unsupported-architecture'); + } + + const manifestPath = path.join( + path.dirname(helperPath), + RUNTIME_MANIFEST_NAME + ); + let helperStat: nodeFileSystem.Stats; + let manifestStat: nodeFileSystem.Stats; + try { + helperStat = dependencies.fileSystem.lstatSync(helperPath); + } catch { + return failure('runtime-artifact-missing'); + } + try { + manifestStat = dependencies.fileSystem.lstatSync(manifestPath); + } catch (error) { + return failure( + isMissingFileError(error) + ? 'runtime-manifest-missing' + : 'runtime-manifest-invalid' + ); + } + + let helperContents: Buffer; + let manifestContents: Buffer; + try { + helperContents = dependencies.fileSystem.readFileSync(helperPath); + } catch { + return failure('runtime-artifact-invalid'); + } + try { + manifestContents = + dependencies.fileSystem.readFileSync(manifestPath); + } catch { + return failure('runtime-manifest-invalid'); + } + + const cacheKey = `${manifestContract}\0${fileIdentity( + helperPath, + helperStat, + helperContents + )}\0${fileIdentity(manifestPath, manifestStat, manifestContents)}`; + const cached = resultCache.get(cacheKey); + if (cached) { + return cached; + } + + let result: EmbeddedMpvFrameCopyRuntimeResult; + try { + const runtimePackage = validatePackage( + helperPath, + manifestPath, + dependencies.fileSystem, + manifestContract + ); + result = isValidationFailure(runtimePackage) + ? failure(runtimePackage.reason) + : runHelperProbe(runtimePackage.value, dependencies); + } catch { + result = failure('runtime-probe-internal-error'); + } + resultCache.set(cacheKey, result); + return result; + }; +} + +const processRuntimeProbe = createEmbeddedMpvFrameCopyRuntimeProbe(); + +/** + * Fail-closed, process-lifetime Linux runtime decision shared by startup and + * the service gate. The helper and manifest identities scope cached results. + */ +export function probeEmbeddedMpvFrameCopyRuntime( + helperPath: string, + manifestContract: EmbeddedMpvFrameCopyManifestContract +): EmbeddedMpvFrameCopyRuntimeResult { + return processRuntimeProbe(helperPath, manifestContract); +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-closure-validator.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-closure-validator.ts new file mode 100644 index 000000000..8cf7789d6 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-closure-validator.ts @@ -0,0 +1,95 @@ +import { isDeepStrictEqual } from 'util'; +import { + ALLOWED_EXTERNAL_LIBRARY_NAMES, + EXPECTED_EXTERNAL_SYSTEM_LIBRARIES, + SAFE_RUNTIME_NAME_PATTERN, + SHARED_LIBRARY_PATTERN, +} from './contracts'; +import type { RuntimeFile } from './types'; +import { + hasExactFields, + isObject, + isSafeRuntimeName, +} from './validation-primitives'; + +export function validateRuntimeClosure( + value: unknown, + runtimeFiles: RuntimeFile[], + libmpvSoname: string, + externalSystemLibraries: unknown +): boolean { + if ( + !isDeepStrictEqual( + externalSystemLibraries, + EXPECTED_EXTERNAL_SYSTEM_LIBRARIES + ) || + !isObject(value) || + !hasExactFields(value, ['entries', 'externalDependencies']) || + !Array.isArray(value.entries) || + !Array.isArray(value.externalDependencies) || + value.externalDependencies.some( + (dependency) => + typeof dependency !== 'string' || + !SAFE_RUNTIME_NAME_PATTERN.test(dependency) || + !SHARED_LIBRARY_PATTERN.test(dependency) + ) + ) { + return false; + } + + const runtimeNames = runtimeFiles.map(({ name }) => name); + const runtimeNameSet = new Set(runtimeNames); + const closureNames: string[] = []; + const computedExternalDependencies = new Set(); + for (const entry of value.entries) { + if ( + !isObject(entry) || + !hasExactFields(entry, [ + 'name', + 'needed', + 'rpath', + 'runpath', + 'soname', + ]) || + !isSafeRuntimeName(entry.name) || + (entry.soname !== null && !isSafeRuntimeName(entry.soname)) || + !Array.isArray(entry.needed) || + entry.needed.some( + (dependency) => + typeof dependency !== 'string' || + !SAFE_RUNTIME_NAME_PATTERN.test(dependency) || + !SHARED_LIBRARY_PATTERN.test(dependency) + ) || + !isDeepStrictEqual(entry.rpath, []) || + !isDeepStrictEqual(entry.runpath, ['$ORIGIN']) || + new Set(entry.needed).size !== entry.needed.length || + !isDeepStrictEqual([...entry.needed].sort(), entry.needed) + ) { + return false; + } + closureNames.push(entry.name); + for (const dependency of entry.needed) { + if (runtimeNameSet.has(dependency)) { + continue; + } + if (!ALLOWED_EXTERNAL_LIBRARY_NAMES.has(dependency)) { + return false; + } + computedExternalDependencies.add(dependency); + } + } + + const linkerAlias = value.entries.find( + (entry) => isObject(entry) && entry.name === 'libmpv.so' + ); + return ( + isDeepStrictEqual(closureNames, runtimeNames) && + new Set(closureNames).size === closureNames.length && + isDeepStrictEqual( + value.externalDependencies, + [...computedExternalDependencies].sort() + ) && + isObject(linkerAlias) && + linkerAlias.soname === libmpvSoname + ); +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts new file mode 100644 index 000000000..84f4c6523 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts @@ -0,0 +1,306 @@ +import { createHash } from 'crypto'; +import { chmodSync, mkdirSync, writeFileSync } from 'fs'; +import path from 'path'; +import type { createEmbeddedMpvFrameCopyRuntimeProbe } from '../embedded-mpv-frame-copy-runtime'; +import { + EXTERNAL_SYSTEM_LIBRARIES, + PINNED_SOURCE_PACKAGE_IDENTITIES, + type RuntimeFile, + type RuntimeFixture, +} from './runtime.spec-data'; + +function sha256(contents: Buffer): string { + return createHash('sha256').update(contents).digest('hex'); +} + +function createRuntimeFiles( + runtimeContents: Record +): RuntimeFile[] { + return Object.entries(runtimeContents) + .map(([name, contents]) => ({ + name, + size: contents.length, + sha256: sha256(contents), + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function createSourceRuntime( + runtimeFiles: RuntimeFile[], + runtimeDependencyClosure: Record +): Record { + const packages = cloneManifest(PINNED_SOURCE_PACKAGE_IDENTITIES); + return { + schemaVersion: 1, + origin: 'vendored-lgpl-source-build', + platform: 'linux', + arch: 'x64', + packages, + ffmpeg: { + ...(packages.ffmpeg as Record), + configureFlags: ['--disable-gpl', '--disable-nonfree'], + }, + mpv: { + ...(packages.mpv as Record), + mesonFlags: ['-Dgpl=false', '-Dlibmpv=true'], + }, + sourceDistribution: + 'Publish the exact hwdata archive and pnp.ids with the libdisplay-info source.', + runtimeFiles, + runtimeTotalBytes: runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ), + runtimeAbi: { + baseline: { + distribution: 'Ubuntu 22.04', + glibcMaximum: '2.35', + glibcxxMaximum: '3.4.30', + }, + files: runtimeFiles.map(({ name }) => ({ + name, + requiredGlibc: '2.34', + requiredGlibcxx: null, + })), + }, + runtimeDependencyClosure, + externalSystemLibraries: cloneManifest(EXTERNAL_SYSTEM_LIBRARIES), + }; +} + +export function createFixture( + rootDir: string, + profile: 'system' | 'portable' | 'flatpak' = 'system' +): RuntimeFixture { + const nativeDir = path.join(rootDir, profile, 'native'); + const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper'); + const manifestPath = path.join(nativeDir, 'embedded-mpv-runtime.json'); + mkdirSync(nativeDir, { recursive: true }); + writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', { + mode: 0o644, + }); + writeFileSync( + path.join(nativeDir, 'embedded_mpv_frame_reader.node'), + 'reader', + { mode: 0o644 } + ); + writeFileSync(helperPath, '#!/bin/sh\n', { mode: 0o755 }); + + const bundled = profile !== 'system'; + const runtimeContents = bundled + ? { + 'libmpv.so': Buffer.from('libmpv-linker-alias'), + 'libmpv.so.2': Buffer.from('libmpv-soname'), + } + : {}; + const runtimeFiles = createRuntimeFiles(runtimeContents); + const runtimeDependencyClosure = { + entries: runtimeFiles.map(({ name }) => ({ + name, + soname: name === 'libmpv.so' ? 'libmpv.so.2' : name, + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + })), + externalDependencies: [], + }; + const externalSystemLibraries = cloneManifest(EXTERNAL_SYSTEM_LIBRARIES); + const sourceRuntime = createSourceRuntime( + runtimeFiles, + runtimeDependencyClosure + ); + const sourceArchive = { + schemaVersion: 1, + name: 'linux-frame-copy-runtime-sources.tar.xz', + sha256: '7'.repeat(64), + repositoryRevision: '8'.repeat(40), + }; + const manifest: Record = { + schemaVersion: 1, + origin: bundled + ? 'bundled-lgpl-frame-copy' + : 'system-libmpv-frame-copy', + generatedAt: '2026-07-17T00:00:00.000Z', + platform: 'linux', + arch: 'x64', + profile, + runtimeMode: bundled ? 'bundled' : 'system', + targets: + profile === 'system' + ? ['deb', 'pacman', 'rpm'] + : profile === 'portable' + ? ['appimage', 'snap'] + : ['flatpak'], + artifacts: { + addon: { + name: 'embedded_mpv.node', + regularFile: true, + readable: true, + }, + frameReader: { + name: 'embedded_mpv_frame_reader.node', + regularFile: true, + readable: true, + }, + helper: { + name: 'iptvnator_mpv_helper', + regularFile: true, + readable: true, + executable: true, + }, + }, + processIsolation: { + addonLoadsLibmpv: false, + readerLoadsLibmpv: false, + electronLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + libmpvSoname: 'libmpv.so.2', + packageDependencies: bundled + ? {} + : { + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], + rpm: [ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-glx', + 'mesa-libgbm', + ], + pacman: ['mpv', 'libglvnd', 'mesa'], + }, + runtimeFiles, + runtimeTotalBytes: runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ), + ...(bundled + ? { + runtimeDependencyClosure, + externalSystemLibraries, + sourceArchive, + sourceRuntime, + } + : {}), + }; + writeManifest(manifestPath, manifest); + if (bundled) { + const libDir = path.join(nativeDir, 'lib'); + mkdirSync(libDir); + for (const [name, contents] of Object.entries(runtimeContents)) { + writeFileSync(path.join(libDir, name), contents, { + mode: 0o644, + }); + } + } + return { + nativeDir, + helperPath, + manifestPath, + manifest, + runtimeContents, + }; +} + +export function createDevelopmentFixture( + rootDir: string, + buildInputMode: 'system-dev' | 'system-build-inputs' | 'bundled-runtime' +): RuntimeFixture { + const bundled = buildInputMode === 'bundled-runtime'; + const fixture = createFixture(rootDir, bundled ? 'portable' : 'system'); + const packagedSourceRuntime = fixture.manifest.sourceRuntime; + const sourceRuntime = + buildInputMode === 'system-dev' + ? { + linuxBackend: 'process-isolated mpv --wid', + warning: + 'Development-only unmanaged system libmpv toolchain.', + } + : buildInputMode === 'system-build-inputs' + ? { + linuxBackend: 'process-isolated mpv --wid', + buildInputs: { + libmpvDevPackage: 'libmpv-dev', + mpvPackage: 'mpv', + }, + sourceDistribution: + 'Linux development inputs are supplied by the host package manager.', + } + : packagedSourceRuntime; + const manifest: Record = { + schemaVersion: 1, + origin: 'linux-frame-copy-build', + generatedAt: '2026-07-17T00:00:00.000Z', + platform: 'linux', + arch: 'x64', + buildInputMode, + sourceRuntimeValidated: bundled, + allowedPackageRuntimeModes: ['system', 'bundled'], + packageRuntimeAvailability: { + system: bundled, + bundled, + }, + artifacts: { + addon: 'embedded_mpv.node', + frameReader: 'embedded_mpv_frame_reader.node', + helper: 'iptvnator_mpv_helper', + }, + processIsolation: { + addonLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + libmpvSoname: bundled ? 'libmpv.so.2' : null, + runtimeFiles: fixture.manifest.runtimeFiles, + runtimeTotalBytes: fixture.manifest.runtimeTotalBytes, + sourceArchive: bundled + ? cloneManifest(fixture.manifest.sourceArchive) + : null, + sourceRuntime, + }; + fixture.manifest = manifest; + writeManifest(fixture.manifestPath, manifest); + return fixture; +} + +export function probeDevelopmentRuntime( + probeRuntime: ReturnType, + helperPath: string +) { + return ( + probeRuntime as unknown as ( + path: string, + contract: 'development' + ) => ReturnType + )(helperPath, 'development'); +} + +export function mirrorBundledManifestFields( + manifest: Record +): void { + const sourceRuntime = manifest.sourceRuntime as Record; + sourceRuntime.runtimeFiles = cloneManifest(manifest.runtimeFiles); + sourceRuntime.runtimeTotalBytes = manifest.runtimeTotalBytes; + sourceRuntime.runtimeDependencyClosure = cloneManifest( + manifest.runtimeDependencyClosure + ); + sourceRuntime.externalSystemLibraries = cloneManifest( + manifest.externalSystemLibraries + ); +} + +export function writeManifest( + manifestPath: string, + manifest: Record +): void { + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { + mode: 0o644, + }); + chmodSync(manifestPath, 0o644); +} + +export function cloneManifest(manifest: T): T { + return JSON.parse(JSON.stringify(manifest)) as T; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts new file mode 100644 index 000000000..adbdbef73 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts @@ -0,0 +1,74 @@ +import { spawnSync } from 'child_process'; +import { + accessSync, + lstatSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, +} from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; +import { + createEmbeddedMpvFrameCopyRuntimeProbe, + type EmbeddedMpvFrameCopyRuntimeDependencies, +} from '../embedded-mpv-frame-copy-runtime'; +import { SUCCESS_OUTPUT } from './runtime.spec-data'; + +export interface RuntimeTestContext { + rootDir: string; + spawnRuntimeProbe: jest.Mock; + writeRuntimeProbeStderr: jest.Mock; + fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem']; + createProbe( + overrides?: Partial + ): ReturnType; + dispose(): void; +} + +export function createRuntimeTestContext(): RuntimeTestContext { + const rootDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-fc-runtime-')); + const spawnRuntimeProbe = jest.fn(() => ({ + status: 0, + signal: null, + stdout: SUCCESS_OUTPUT, + stderr: '', + })); + const writeRuntimeProbeStderr = jest.fn(); + const fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'] = { + accessSync: jest.fn((filePath: string, mode: number) => + accessSync(filePath, mode) + ), + lstatSync: jest.fn((filePath: string) => lstatSync(filePath)), + readFileSync: jest.fn((filePath: string) => readFileSync(filePath)), + readdirSync: jest.fn((filePath: string) => readdirSync(filePath)), + }; + const context: RuntimeTestContext = { + rootDir, + spawnRuntimeProbe, + writeRuntimeProbeStderr, + fileSystem, + createProbe( + overrides: Partial = {} + ) { + return createEmbeddedMpvFrameCopyRuntimeProbe({ + platform: 'linux', + arch: 'x64', + env: { + PATH: '/usr/bin', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/ambient/libs', + LD_PRELOAD: '/tmp/inject.so', + }, + fileSystem: context.fileSystem, + spawnSync: context.spawnRuntimeProbe as typeof spawnSync, + writeStderr: context.writeRuntimeProbeStderr, + ...overrides, + }); + }, + dispose() { + rmSync(context.rootDir, { recursive: true, force: true }); + }, + }; + return context; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts new file mode 100644 index 000000000..81623319a --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts @@ -0,0 +1,179 @@ +export const SUCCESS_OUTPUT = + '{"protocol":1,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n'; + +export const EXTERNAL_SYSTEM_LIBRARIES = [ + { + name: 'libEGL.so.1', + interface: 'EGL', + reason: 'System graphics-driver interface used by the frame-copy helper.', + }, + { + name: 'libGL.so.1', + interface: 'OpenGL', + reason: 'System OpenGL compatibility interface supplied by the graphics stack.', + }, + { + name: 'libGLX.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.', + }, + { + name: 'libOpenGL.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL interface supplied by the graphics stack.', + }, + { + name: 'libasound.so.2', + interface: 'ALSA', + reason: 'Linux system audio interface intentionally used by libmpv.', + }, + { + name: 'libdrm.so.2', + interface: 'DRM', + reason: 'Kernel graphics interface used by system GBM and VA-API drivers.', + }, + { + name: 'libgbm.so.1', + interface: 'GBM', + reason: 'System graphics-buffer interface used by headless EGL rendering.', + }, + { + name: 'libpulse.so.0', + interface: 'PulseAudio', + reason: 'Linux desktop audio interface intentionally used by libmpv.', + }, + { + name: 'libva-drm.so.2', + interface: 'VA-API DRM', + reason: 'System VA-API DRM interface used for hardware decoding.', + }, + { + name: 'libva.so.2', + interface: 'VA-API', + reason: 'System video-acceleration interface used for hardware decoding.', + }, +]; + +export const PINNED_SOURCE_PACKAGE_IDENTITIES = { + freetype: { + version: '2.13.3', + sourceUrl: + 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz', + sourceSha256: + '0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289', + license: 'FreeType License (FTL)', + }, + fribidi: { + version: '1.0.16', + sourceUrl: + 'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz', + sourceSha256: + '1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c', + license: 'LGPL-2.1-or-later', + }, + harfbuzz: { + version: '8.5.0', + sourceUrl: + 'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz', + sourceSha256: + '77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27', + license: 'MIT', + }, + expat: { + version: '2.8.2', + sourceUrl: + 'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz', + sourceSha256: + '3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4', + license: 'MIT', + }, + fontconfig: { + version: '2.16.0', + sourceUrl: + 'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz', + sourceSha256: + '6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220', + license: 'MIT', + }, + libass: { + version: '0.17.3', + sourceUrl: + 'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz', + sourceSha256: + 'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959', + license: 'ISC', + }, + openssl: { + version: '3.5.7', + sourceUrl: + 'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz', + sourceSha256: + 'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8', + license: 'Apache-2.0', + }, + ffmpeg: { + version: '8.1', + sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz', + sourceSha256: + 'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a', + license: 'LGPL-2.1-or-later', + }, + libplacebo: { + version: '7.360.1', + sourceUrl: 'https://github.com/haasn/libplacebo.git', + sourceTag: 'v7.360.1', + sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4', + sourceSubmodules: [ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear', + ], + license: 'LGPL-2.1-or-later', + }, + hwdata: { + version: '0.409', + sourceUrl: + 'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz', + sourceSha256: + '23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd', + buildInput: { + consumer: 'libdisplay-info', + relativePath: 'pnp.ids', + purpose: 'PNP vendor lookup table compiled into libdisplay-info.', + }, + license: 'GPL-2.0-or-later OR XFree86-1.0', + }, + 'libdisplay-info': { + version: '0.1.1', + sourceUrl: + 'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz', + sourceSha256: + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + license: 'MIT', + }, + mpv: { + version: '0.41.0', + sourceUrl: + 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz', + sourceSha256: + 'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209', + license: 'LGPL-2.1-or-later with -Dgpl=false', + }, +}; + +export interface RuntimeFile { + name: string; + size: number; + sha256: string; +} + +export interface RuntimeFixture { + nativeDir: string; + helperPath: string; + manifestPath: string; + manifest: Record; + runtimeContents: Record; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts new file mode 100644 index 000000000..6663bd4cd --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts @@ -0,0 +1,150 @@ +import { + cloneManifest, + createFixture, + writeManifest, +} from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy source runtime policy', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it.each([ + { + label: 'pinned source identity', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.mpv.sourceSha256 = '0'.repeat(64); + }, + }, + { + label: 'pinned source URL', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.freetype.sourceUrl = + 'https://example.invalid/freetype.tar.xz'; + }, + }, + { + label: 'pinned git tag', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.libplacebo.sourceTag = 'main'; + }, + }, + { + label: 'pinned hwdata build input', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.hwdata.buildInput = { + consumer: 'libdisplay-info', + relativePath: '../pnp.ids', + purpose: + 'PNP vendor lookup table compiled into libdisplay-info.', + }; + }, + }, + { + label: 'git submodule record', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.libplacebo.sourceSubmodules = [ + `${'a'.repeat(40)} ../outside`, + ]; + }, + }, + { + label: 'duplicate git submodule records', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + const record = `${'a'.repeat(40)} 3rdparty/example`; + packages.libplacebo.sourceSubmodules = [record, record]; + }, + }, + { + label: 'unpinned git submodule commit', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.libplacebo.sourceSubmodules = [ + `${'f'.repeat(40)} 3rdparty/Vulkan-Headers`, + `${'e'.repeat(40)} 3rdparty/fast_float`, + ]; + }, + }, + { + label: 'portable ABI baseline', + mutate(sourceRuntime: Record) { + const runtimeAbi = sourceRuntime.runtimeAbi as { + baseline: Record; + }; + runtimeAbi.baseline.glibcMaximum = '9.99'; + }, + }, + { + label: 'source-distribution obligation', + mutate(sourceRuntime: Record) { + sourceRuntime.sourceDistribution = 'Sources available.'; + }, + }, + { + label: 'FFmpeg LGPL flags', + mutate(sourceRuntime: Record) { + const ffmpeg = sourceRuntime.ffmpeg as { + configureFlags: string[]; + }; + ffmpeg.configureFlags = ['--disable-nonfree', '--enable-gpl']; + }, + }, + { + label: 'mpv LGPL flags', + mutate(sourceRuntime: Record) { + const mpv = sourceRuntime.mpv as { + mesonFlags: string[]; + }; + mpv.mesonFlags = ['-Dgpl=true', '-Dlibmpv=true']; + }, + }, + ])('rejects an invalid $label', ({ mutate }) => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + mutate(manifest.sourceRuntime as Record); + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts new file mode 100644 index 000000000..ef5ad6c5f --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts @@ -0,0 +1,247 @@ +import path from 'path'; +import { isDeepStrictEqual } from 'util'; +import { + GIT_COMMIT_PATTERN, + PINNED_SOURCE_PACKAGE_IDENTITIES, + PORTABLE_ABI_BASELINE, + SUBMODULE_RECORD_PATTERN, + VERSION_PATTERN, +} from './contracts'; +import type { RuntimeFile } from './types'; +import { + hasExactFields, + isObject, + isSafeRuntimeName, +} from './validation-primitives'; + +function compareDottedVersions(left: string, right: string): number { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) { + return difference; + } + } + return 0; +} + +function validatesPinnedSourceIdentity( + candidate: unknown, + expected: (typeof PINNED_SOURCE_PACKAGE_IDENTITIES)[keyof typeof PINNED_SOURCE_PACKAGE_IDENTITIES] +): boolean { + if ( + !isObject(candidate) || + candidate.version !== expected.version || + candidate.sourceUrl !== expected.sourceUrl || + candidate.license !== expected.license + ) { + return false; + } + if ( + ('sourceTag' in expected + ? candidate.sourceTag !== expected.sourceTag + : candidate.sourceTag !== undefined) || + ('buildInput' in expected + ? !isDeepStrictEqual(candidate.buildInput, expected.buildInput) + : candidate.buildInput !== undefined) + ) { + return false; + } + if ('sourceSha256' in expected) { + return ( + candidate.sourceSha256 === expected.sourceSha256 && + candidate.sourceGitCommit === undefined && + candidate.sourceSubmodules === undefined + ); + } + return ( + 'sourceSubmodules' in expected && + candidate.sourceGitCommit === expected.sourceGitCommit && + GIT_COMMIT_PATTERN.test(candidate.sourceGitCommit) && + candidate.sourceSha256 === undefined && + validatesGitSubmoduleRecords(candidate.sourceSubmodules) && + isDeepStrictEqual(candidate.sourceSubmodules, expected.sourceSubmodules) + ); +} + +function validatesGitSubmoduleRecords(value: unknown): boolean { + if ( + !Array.isArray(value) || + value.length === 0 || + new Set(value).size !== value.length + ) { + return false; + } + return value.every((record) => { + if (typeof record !== 'string') { + return false; + } + const match = record.match(SUBMODULE_RECORD_PATTERN); + if (!match) { + return false; + } + const submodulePath = match[1]; + return ( + !path.posix.isAbsolute(submodulePath) && + !submodulePath + .split('/') + .some((segment) => segment === '.' || segment === '..') + ); + }); +} + +function validateStringFlags(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.every( + (flag) => + typeof flag === 'string' && + flag.length > 0 && + flag.trim() === flag + ) && + new Set(value).size === value.length + ); +} + +function validateRuntimeAbi( + value: unknown, + runtimeFiles: RuntimeFile[] +): boolean { + if ( + !isObject(value) || + !hasExactFields(value, ['baseline', 'files']) || + !isDeepStrictEqual(value.baseline, PORTABLE_ABI_BASELINE) || + !Array.isArray(value.files) + ) { + return false; + } + + const abiFileNames: string[] = []; + for (const record of value.files) { + if ( + !isObject(record) || + !hasExactFields(record, [ + 'name', + 'requiredGlibc', + 'requiredGlibcxx', + ]) || + !isSafeRuntimeName(record.name) + ) { + return false; + } + for (const [field, maximum] of [ + ['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum], + ['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum], + ] as const) { + const version = record[field]; + if ( + version !== null && + (typeof version !== 'string' || + !VERSION_PATTERN.test(version) || + compareDottedVersions(version, maximum) > 0) + ) { + return false; + } + } + abiFileNames.push(record.name); + } + + return isDeepStrictEqual( + abiFileNames, + runtimeFiles.map(({ name }) => name) + ); +} + +/** + * The source builder and package verifier own exhaustive build-host, recipe, + * tool-version, URL and external-configuration validation. Startup repeats + * only the immutable policy boundary needed before sandbox relaxation: + * pinned source identities/licenses, LGPL flags, portable ABI, display-data + * distribution, and the exact runtime closure mirrored by the package. + */ +export function validateSourceRuntimePolicy( + value: unknown, + runtimeFiles: RuntimeFile[], + runtimeDependencyClosure: unknown, + externalSystemLibraries: unknown +): boolean { + if ( + !isObject(value) || + value.schemaVersion !== 1 || + value.origin !== 'vendored-lgpl-source-build' || + value.platform !== 'linux' || + value.arch !== 'x64' || + !isObject(value.packages) || + !isObject(value.ffmpeg) || + !isObject(value.mpv) || + !isDeepStrictEqual( + Object.keys(value.packages).sort(), + Object.keys(PINNED_SOURCE_PACKAGE_IDENTITIES).sort() + ) + ) { + return false; + } + + for (const [packageName, expectedIdentity] of Object.entries( + PINNED_SOURCE_PACKAGE_IDENTITIES + )) { + if ( + !validatesPinnedSourceIdentity( + value.packages[packageName], + expectedIdentity + ) + ) { + return false; + } + } + + if ( + !validatesPinnedSourceIdentity( + value.ffmpeg, + PINNED_SOURCE_PACKAGE_IDENTITIES.ffmpeg + ) || + !validateStringFlags(value.ffmpeg.configureFlags) || + !value.ffmpeg.configureFlags.includes('--disable-gpl') || + !value.ffmpeg.configureFlags.includes('--disable-nonfree') || + value.ffmpeg.configureFlags.includes('--enable-gpl') || + value.ffmpeg.configureFlags.includes('--enable-nonfree') || + !validatesPinnedSourceIdentity( + value.mpv, + PINNED_SOURCE_PACKAGE_IDENTITIES.mpv + ) || + !validateStringFlags(value.mpv.mesonFlags) || + !value.mpv.mesonFlags.includes('-Dgpl=false') || + !value.mpv.mesonFlags.includes('-Dlibmpv=true') || + value.mpv.mesonFlags.includes('-Dgpl=true') + ) { + return false; + } + + if ( + typeof value.sourceDistribution !== 'string' || + !/\bhwdata\b/i.test(value.sourceDistribution) || + !/\bpnp\.ids\b/i.test(value.sourceDistribution) || + !/\blibdisplay-info\b/i.test(value.sourceDistribution) || + value.runtimeTotalBytes !== + runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ) || + !isDeepStrictEqual(value.runtimeFiles, runtimeFiles) || + !isDeepStrictEqual( + value.runtimeDependencyClosure, + runtimeDependencyClosure + ) || + !isDeepStrictEqual( + value.externalSystemLibraries, + externalSystemLibraries + ) || + !validateRuntimeAbi(value.runtimeAbi, runtimeFiles) + ) { + return false; + } + + return true; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts new file mode 100644 index 000000000..c83432f38 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts @@ -0,0 +1,51 @@ +import path from 'path'; + +const TRUSTED_SNAP_MOUNT_ROOTS = ['/snap', '/var/lib/snapd/snap'] as const; + +function isPathInside( + parentPath: string, + candidatePath: string, + allowEqual: boolean +): boolean { + const relativePath = path.relative(parentPath, candidatePath); + if (relativePath === '') { + return allowEqual; + } + return ( + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ); +} + +export function resolveTrustedSnapRoot( + environment: NodeJS.ProcessEnv, + nativeDir: string +): string | null { + const declaredSnapRoot = environment.SNAP; + if ( + !declaredSnapRoot || + !path.isAbsolute(declaredSnapRoot) || + !path.isAbsolute(nativeDir) + ) { + return null; + } + + const normalizedSnapRoot = path.resolve(declaredSnapRoot); + const resemblesReadOnlySnapMount = TRUSTED_SNAP_MOUNT_ROOTS.some( + (mountRoot) => { + const relativePath = path.relative(mountRoot, normalizedSnapRoot); + return ( + isPathInside(mountRoot, normalizedSnapRoot, false) && + relativePath.split(path.sep).filter(Boolean).length >= 2 + ); + } + ); + if ( + !resemblesReadOnlySnapMount || + !isPathInside(normalizedSnapRoot, path.resolve(nativeDir), false) + ) { + return null; + } + return normalizedSnapRoot; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts new file mode 100644 index 000000000..96240b24c --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts @@ -0,0 +1,103 @@ +import type { spawnSync as nodeSpawnSync } from 'child_process'; +import type * as nodeFileSystem from 'fs'; + +export const EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS = { + MPV_CREATE_FAILED: 'mpv-create-failed', + MPV_INITIALIZE_FAILED: 'mpv-initialize-failed', + GL_CONTEXT_CREATE_FAILED: 'gl-context-create-failed', + GL_CONTEXT_BIND_FAILED: 'gl-context-bind-failed', + MPV_RENDER_CONTEXT_FAILED: 'mpv-render-context-failed', + SHARED_MEMORY_CREATE_FAILED: 'shared-memory-create-failed', + SHARED_MEMORY_INITIALIZE_FAILED: 'shared-memory-initialize-failed', +} as const; + +export type EmbeddedMpvHelperRuntimeProbeFailureReason = + (typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS)[keyof typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS]; + +export type EmbeddedMpvFrameCopyRuntimeFailureReason = + | 'unsupported-platform' + | 'unsupported-architecture' + | 'runtime-manifest-missing' + | 'runtime-manifest-invalid' + | 'runtime-artifact-missing' + | 'runtime-artifact-invalid' + | 'runtime-library-directory-invalid' + | 'runtime-library-missing' + | 'runtime-library-undeclared' + | 'runtime-library-invalid' + | 'runtime-library-size-mismatch' + | 'runtime-library-hash-mismatch' + | 'snap-graphics-provider-unavailable' + | 'helper-probe-timeout' + | 'helper-probe-spawn-error' + | 'helper-probe-signaled' + | 'helper-probe-failed' + | 'helper-probe-invalid-output' + | 'helper-probe-protocol-mismatch' + | 'helper-probe-unusable' + | 'runtime-probe-internal-error'; + +export type EmbeddedMpvFrameCopyManifestContract = 'packaged' | 'development'; +export type EmbeddedMpvFrameCopyRuntimeMode = 'system' | 'bundled'; + +export type RuntimeProfile = 'system' | 'portable' | 'flatpak'; +export type RuntimeProbeProfile = RuntimeProfile | 'development'; + +export type EmbeddedMpvFrameCopyRuntimeResult = + | { + usable: true; + profile: RuntimeProbeProfile; + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode; + libmpv: string; + renderApi: 'egl'; + } + | { + usable: false; + reason: EmbeddedMpvFrameCopyRuntimeFailureReason; + helperReason?: EmbeddedMpvHelperRuntimeProbeFailureReason; + helperDetail?: string; + }; + +export interface EmbeddedMpvFrameCopyRuntimeFileSystem { + accessSync(filePath: string, mode: number): void; + lstatSync(filePath: string): nodeFileSystem.Stats; + readFileSync(filePath: string): Buffer; + readdirSync(filePath: string): string[]; +} + +export interface EmbeddedMpvFrameCopyRuntimeDependencies { + platform: NodeJS.Platform; + arch: string; + env: NodeJS.ProcessEnv; + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem; + spawnSync: typeof nodeSpawnSync; + writeStderr(output: string): void; +} + +export interface RuntimeFile { + name: string; + size: number; + sha256: string; +} + +export interface ValidManifest { + profile: RuntimeProbeProfile; + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode; + runtimeFiles: RuntimeFile[]; +} + +export interface ValidatedPackage { + manifest: ValidManifest; + helperPath: string; + nativeDir: string; +} + +export interface ValidationSuccess { + value: T; +} + +export interface ValidationFailure { + reason: EmbeddedMpvFrameCopyRuntimeFailureReason; +} + +export type ValidationResult = ValidationSuccess | ValidationFailure; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/validation-primitives.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/validation-primitives.ts new file mode 100644 index 000000000..68c252415 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/validation-primitives.ts @@ -0,0 +1,162 @@ +import { createHash } from 'crypto'; +import type * as nodeFileSystem from 'fs'; +import path from 'path'; +import { isDeepStrictEqual } from 'util'; +import { + SAFE_RUNTIME_NAME_PATTERN, + SHA256_PATTERN, + SHARED_LIBRARY_PATTERN, +} from './contracts'; +import type { + EmbeddedMpvFrameCopyRuntimeFailureReason, + EmbeddedMpvFrameCopyRuntimeFileSystem, + EmbeddedMpvFrameCopyRuntimeResult, + RuntimeFile, + ValidationFailure, + ValidationResult, +} from './types'; + +export function failure( + reason: EmbeddedMpvFrameCopyRuntimeFailureReason +): EmbeddedMpvFrameCopyRuntimeResult { + return { usable: false, reason }; +} + +export function validationFailure( + reason: EmbeddedMpvFrameCopyRuntimeFailureReason +): ValidationFailure { + return { reason }; +} + +export function isValidationFailure( + result: ValidationResult +): result is ValidationFailure { + return 'reason' in result; +} + +export function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function hasExactFields( + value: Record, + fields: readonly string[] +): boolean { + return isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort()); +} + +export function isSafeRuntimeName(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value !== '.' && + value !== '..' && + path.basename(value) === value && + !value.includes('/') && + !value.includes('\\') && + SAFE_RUNTIME_NAME_PATTERN.test(value) && + SHARED_LIBRARY_PATTERN.test(value) + ); +} + +export function isMissingFileError(error: unknown): boolean { + return ( + isObject(error) && + typeof error.code === 'string' && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ); +} + +export function fileIdentity( + filePath: string, + stat: nodeFileSystem.Stats, + contents: Buffer +): string { + return [ + path.resolve(filePath), + stat.dev, + stat.ino, + stat.mode, + stat.size, + stat.mtimeMs, + stat.ctimeMs, + createHash('sha256').update(contents).digest('hex'), + ].join(':'); +} + +export function readManifest( + manifestPath: string, + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem +): ValidationResult> { + let contents: Buffer; + try { + contents = fileSystem.readFileSync(manifestPath); + } catch { + return validationFailure('runtime-manifest-invalid'); + } + + try { + const parsed: unknown = JSON.parse(contents.toString('utf8')); + return isObject(parsed) + ? { value: parsed } + : validationFailure('runtime-manifest-invalid'); + } catch { + return validationFailure('runtime-manifest-invalid'); + } +} + +export function validateTargets( + targets: unknown, + allowedTargets: ReadonlySet +): boolean { + const expectedTargets = [...allowedTargets].sort(); + if ( + !Array.isArray(targets) || + targets.length !== expectedTargets.length || + targets.some( + (target) => + typeof target !== 'string' || + target.trim() !== target || + target.toLowerCase() !== target || + !allowedTargets.has(target) + ) + ) { + return false; + } + return isDeepStrictEqual(targets, expectedTargets); +} + +export function validateRuntimeFiles(value: unknown): RuntimeFile[] | null { + if (!Array.isArray(value) || value.length === 0) { + return null; + } + + const runtimeFiles: RuntimeFile[] = []; + const names = new Set(); + for (const candidate of value) { + if ( + !isObject(candidate) || + !hasExactFields(candidate, ['name', 'sha256', 'size']) || + !isSafeRuntimeName(candidate.name) || + !Number.isSafeInteger(candidate.size) || + (candidate.size as number) <= 0 || + typeof candidate.sha256 !== 'string' || + !SHA256_PATTERN.test(candidate.sha256) || + names.has(candidate.name) + ) { + return null; + } + names.add(candidate.name); + runtimeFiles.push({ + name: candidate.name, + size: candidate.size as number, + sha256: candidate.sha256, + }); + } + return isDeepStrictEqual( + runtimeFiles.map(({ name }) => name).sort(), + runtimeFiles.map(({ name }) => name) + ) + ? runtimeFiles + : null; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts index 26fa33b55..10698bd55 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'events'; +import type { Stats } from 'fs'; import path from 'path'; const spawnMock = jest.fn(); @@ -7,6 +8,55 @@ jest.mock('child_process', () => ({ })); import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; + +const HOSTILE_LOADER_ENVIRONMENT = { + BASH_ENV: '/tmp/hostile-bash-env', + ENV: '/tmp/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/tmp/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/tmp/hostile-cdpath', + 'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_ORIGIN_PATH: '/tmp/hostile-origin', + LD_PRELOAD: '/tmp/inject.so', + __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json', + GBM_BACKEND: '../../../../../tmp/hostile-gbm', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', + MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', + LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', + VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', + VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', + VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', + VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', + VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', + VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', + VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers', + VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', +} as const; + +const GRAPHICS_SELECTOR_ENVIRONMENT = { + LIBGL_ALWAYS_SOFTWARE: '1', + GALLIUM_DRIVER: 'llvmpipe', +} as const; + +function fakeStat( + kind: 'directory' | 'file' +): Pick { + return { + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => false, + }; +} class FakeHelperProcess extends EventEmitter { exitCode: number | null = null; @@ -36,14 +86,34 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { let frameSourceChanges: Array<{ sessionId: string; shmName: string }>; let adapter: EmbeddedMpvFrameCopyAdapter; - const createAdapter = (helperPath: string | null = '/native/helper') => { + const createAdapter = ( + helperPath: string | null = '/native/helper', + { + runtimeMode = 'system', + environment, + helperLaunchFileSystem, + }: { + runtimeMode?: EmbeddedMpvFrameCopyRuntimeMode | null; + environment?: NodeJS.ProcessEnv; + helperLaunchFileSystem?: { + lstatSync(filePath: string): Stats; + accessSync(filePath: string, mode: number): void; + }; + } = {} + ) => { frameSourceChanges = []; return new EmbeddedMpvFrameCopyAdapter({ resolveHelperPath: () => helperPath, + resolveRuntimeMode: () => runtimeMode, + environment, + helperLaunchFileSystem, getScaleFactor: () => 2, onFrameSourceChanged: (sessionId, source) => - frameSourceChanges.push({ sessionId, shmName: source.shmName }), - }); + frameSourceChanges.push({ + sessionId, + shmName: source.shmName, + }), + } as ConstructorParameters[0]); }; beforeEach(() => { @@ -83,6 +153,269 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { ]); }); + describe('Linux loader environment', () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + Object.defineProperty(process, 'arch', { value: originalArch }); + }); + + it('uses a sanitized system environment for the real helper session', () => { + adapter = createAdapter('/opt/iptvnator/native/helper', { + runtimeMode: 'system', + environment: { + PATH: '/usr/bin', + HOME: '/home/user', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + }); + + createSession(); + + expect(spawnMock.mock.calls[0][2]).toEqual({ + stdio: ['pipe', 'pipe', 'pipe'], + env: { + PATH: '/usr/bin', + HOME: '/home/user', + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + }); + }); + + it('keeps trusted Snap GL roots ahead of generic Snap libraries for playback', () => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + adapter = createAdapter(path.join(nativeDir, 'helper'), { + runtimeMode: 'bundled', + helperLaunchFileSystem: { + lstatSync: (candidatePath) => + fakeStat( + candidatePath.endsWith('/graphics') + ? 'directory' + : 'file' + ) as Stats, + accessSync: () => undefined, + }, + environment: { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home', + XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs', + XDG_DATA_HOME: '/tmp/hostile-xdg-data-home', + XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + }); + + createSession(); + + expect(spawnMock.mock.calls[0][0]).toBe( + path.join( + snapRoot, + 'graphics', + 'bin', + 'graphics-core22-provider-wrapper' + ) + ); + expect(spawnMock.mock.calls[0][1][0]).toBe( + path.join(nativeDir, 'helper') + ); + expect(spawnMock.mock.calls[0][2]).toEqual({ + stdio: ['pipe', 'pipe', 'pipe'], + env: { + PATH: '/usr/sbin:/usr/bin:/sbin:/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl', + SNAP_ARCH: 'amd64', + SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + ...GRAPHICS_SELECTOR_ENVIRONMENT, + GBM_BACKENDS_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'gbm' + ), + '/var/lib/snapd/lib/gl/gbm', + ].join(':'), + LIBGL_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + LIBVA_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'egl', + 'egl_external_platform.d' + ), + __EGL_VENDOR_LIBRARY_DIRS: [ + '/var/lib/snapd/lib/glvnd/egl_vendor.d', + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'glvnd', + 'egl_vendor.d' + ), + ].join(':'), + VK_LAYER_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'implicit_layer.d' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'explicit_layer.d' + ), + ].join(':'), + XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'), + XDG_CONFIG_DIRS: [ + path.join(snapRoot, 'etc', 'xdg'), + '/etc/xdg', + ].join(':'), + XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'), + XDG_DATA_DIRS: [ + path.join(snapRoot, 'graphics', 'usr', 'share'), + path.join(snapRoot, 'gnome-platform', 'usr', 'share'), + path.join(snapRoot, 'usr', 'share'), + '/usr/share', + ].join(':'), + LD_LIBRARY_PATH: [ + path.join(nativeDir, 'lib'), + '/var/lib/snapd/lib/gl', + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'vdpau' + ), + '/usr/lib/x86_64-linux-gnu', + path.join( + snapRoot, + 'gnome-platform', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa-egl' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'pulseaudio' + ), + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + ].join(':'), + }, + }); + }); + + it('refuses a Linux session without a validated runtime mode', () => { + adapter = createAdapter('/native/helper', { runtimeMode: null }); + + expect(() => createSession()).toThrow( + 'validated Linux frame-copy runtime' + ); + expect(spawnMock).not.toHaveBeenCalled(); + }); + }); + it('caches helper snapshot events for getSessionSnapshot', () => { const sessionId = createSession(); child.emitStdout({ @@ -149,7 +482,12 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { 'size\twidth=1600\theight=900\n' ); const writesBefore = child.stdin.written.length; - adapter.setBounds(sessionId, { x: -10000, y: -10000, width: 1, height: 1 }); + adapter.setBounds(sessionId, { + x: -10000, + y: -10000, + width: 1, + height: 1, + }); expect(child.stdin.written.length).toBe(writesBefore); }); @@ -192,7 +530,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { ['darwin', 'arm64', true], ['darwin', 'x64', false], ['linux', 'x64', true], - ['linux', 'arm64', true], + ['linux', 'arm64', false], ['win32', 'x64', true], ['freebsd', 'x64', false], ])( diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts index 5edc3c2fa..7c14720b0 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts @@ -7,6 +7,11 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util'; +import { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime'; +import type { + EmbeddedMpvFrameCopyRuntimeMode, + LinuxFrameCopyHelperLaunchFileSystem, +} from './embedded-mpv-frame-copy-runtime'; import type { NativeEmbeddedMpvAddon, NativeEmbeddedMpvSessionSnapshot, @@ -28,6 +33,9 @@ import type { export interface EmbeddedMpvFrameCopyAdapterOptions { resolveHelperPath: () => string | null; + resolveRuntimeMode: () => EmbeddedMpvFrameCopyRuntimeMode | null; + environment?: NodeJS.ProcessEnv; + helperLaunchFileSystem?: LinuxFrameCopyHelperLaunchFileSystem; getScaleFactor: () => number; onFrameSourceChanged: ( sessionId: string, @@ -76,14 +84,18 @@ function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot { export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { private readonly sessions = new Map(); - constructor( - private readonly options: EmbeddedMpvFrameCopyAdapterOptions - ) {} + constructor(private readonly options: EmbeddedMpvFrameCopyAdapterOptions) {} isSupported(): boolean { + if ( + !isFrameCopyPlatformSupported() || + this.options.resolveHelperPath() === null + ) { + return false; + } return ( - isFrameCopyPlatformSupported() && - this.options.resolveHelperPath() !== null + process.platform !== 'linux' || + this.options.resolveRuntimeMode() !== null ); } @@ -104,30 +116,56 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { const scale = this.options.getScaleFactor(); const width = Math.max(16, Math.round(bounds.width * scale)); const height = Math.max(16, Math.round(bounds.height * scale)); + const helperArgs = [ + '--shm-base', + `/${sessionId}`, + '--width', + String(width), + '--height', + String(height), + '--volume', + String(Math.min(Math.max(initialVolume ?? 1, 0), 1)), + // Lip-sync compensation for the video path's added latency + // (~10 ms measured on M1 Pro); tunable until calibration + // lands, see the architecture doc. + ...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY + ? [ + '--audio-delay', + process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY, + ] + : []), + ]; + let helperCommand = helperPath; + let resolvedHelperArgs = helperArgs; + let helperEnvironment: NodeJS.ProcessEnv | undefined; + if (process.platform === 'linux') { + const runtimeMode = this.options.resolveRuntimeMode(); + if (!runtimeMode) { + throw new Error( + 'A validated Linux frame-copy runtime is not available.' + ); + } + const launch = createLinuxFrameCopyHelperLaunch({ + environment: this.options.environment ?? process.env, + helperPath, + helperArgs, + runtimeMode, + fileSystem: this.options.helperLaunchFileSystem, + }); + if (!launch.usable) { + throw new Error( + 'The connected Snap graphics provider is not available.' + ); + } + helperCommand = launch.command; + resolvedHelperArgs = launch.args; + helperEnvironment = launch.env; + } - const child = spawn( - helperPath, - [ - '--shm-base', - `/${sessionId}`, - '--width', - String(width), - '--height', - String(height), - '--volume', - String(Math.min(Math.max(initialVolume ?? 1, 0), 1)), - // Lip-sync compensation for the video path's added latency - // (~10 ms measured on M1 Pro); tunable until calibration - // lands, see the architecture doc. - ...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY - ? [ - '--audio-delay', - process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY, - ] - : []), - ], - { stdio: ['pipe', 'pipe', 'pipe'] } - ); + const child = spawn(helperCommand, resolvedHelperArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + ...(helperEnvironment ? { env: helperEnvironment } : {}), + }); console.log( `[embedded-mpv-fc][${sessionId}] spawn ${width}x${height} (pid pending)` @@ -177,7 +215,9 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { } loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void { - const fields: string[] = [`url=${encodeProtocolValue(playback.streamUrl)}`]; + const fields: string[] = [ + `url=${encodeProtocolValue(playback.streamUrl)}`, + ]; if (playback.title) { fields.push( `opt.force-media-title=${encodeProtocolValue(playback.title)}` diff --git a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts new file mode 100644 index 000000000..5ce94928d --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts @@ -0,0 +1,539 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import { createHash } from 'crypto'; +import { createRequire } from 'module'; +import { tmpdir } from 'os'; +import path from 'path'; + +const linkageModulePath = path.resolve( + __dirname, + '../../../embedded-mpv-linux-linkage.cjs' +); +const requireBuildHelper = createRequire(__filename); + +interface RuntimeFileRecord { + name: string; + size: number; + sha256: string; +} + +interface SonameFixture { + exactPath: string; + outputLibDir: string; + runtimeDependencyClosure: { + entries: Array<{ + name: string; + needed: string[]; + rpath: string[]; + runpath: string[]; + soname: string | null; + }>; + }; + runtimeFiles: RuntimeFileRecord[]; +} + +function loadLinkageModule(): { + parseReadelfDynamic: (output: string) => { + needed: string[]; + rpath: string[]; + runpath: string[]; + soname: string[]; + }; + resolveVerifiedLinuxLibMpvSoname: (options: { + outputLibDir: string; + readDynamicSection: (filePath: string) => string; + runtimeDependencyClosure: SonameFixture['runtimeDependencyClosure']; + runtimeFiles: RuntimeFileRecord[]; + }) => string; + resolveLinuxFrameCopyLinkageInputs: (options: { + buildInputMode: string; + outputLibDir: string; + packagedLibmpvSoname: string | null; + readDynamicSection: (filePath: string) => string; + runtimeLibDir: string; + }) => { + expectedLibmpvSoname: string | null; + linkerLibraryDir: string; + }; + runWithCleanup: (operation: () => T, cleanup: () => void) => T; + validateLinuxFrameCopyLinkage: (options: { + expectedLibmpvSoname: string; + outputDir: string; + readDynamicSection: (filePath: string) => string; + }) => void; +} { + expect(existsSync(linkageModulePath)).toBe(true); + return requireBuildHelper(linkageModulePath); +} + +function sha256(contents: Buffer): string { + return createHash('sha256').update(contents).digest('hex'); +} + +function runtimeFile(name: string, contents: Buffer): RuntimeFileRecord { + return { + name, + size: contents.byteLength, + sha256: sha256(contents), + }; +} + +function readelfDynamic( + entries: Array<['NEEDED' | 'RPATH' | 'RUNPATH' | 'SONAME', string]> +): string { + return entries + .map( + ([tag, value], index) => + ` 0x${index + .toString(16) + .padStart(16, '0')} (${tag}) Library value: [${value}]` + ) + .join('\n'); +} + +describe('Linux Embedded MPV linkage verification', () => { + const temporaryDirectories: string[] = []; + + afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } + }); + + function temporaryDirectory(): string { + const directory = mkdtempSync( + path.join(tmpdir(), 'iptvnator-mpv-linkage-') + ); + temporaryDirectories.push(directory); + return directory; + } + + function createSonameFixture(soname = 'libmpv.so.2'): SonameFixture { + const outputLibDir = path.join(temporaryDirectory(), 'lib'); + mkdirSync(outputLibDir, { recursive: true }); + const contents = Buffer.from('verified libmpv ELF contents'); + const aliasPath = path.join(outputLibDir, 'libmpv.so'); + const exactPath = path.join(outputLibDir, soname); + writeFileSync(aliasPath, contents); + writeFileSync(exactPath, contents); + + return { + exactPath, + outputLibDir, + runtimeFiles: [ + runtimeFile('libmpv.so', contents), + runtimeFile(soname, contents), + ], + runtimeDependencyClosure: { + entries: [ + { + name: 'libmpv.so', + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname, + }, + { + name: soname, + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname, + }, + ], + }, + }; + } + + it('parses every dynamic tag without hiding duplicate SONAME entries', () => { + const { parseReadelfDynamic } = loadLinkageModule(); + + expect( + parseReadelfDynamic( + readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RPATH', '/forbidden'], + ['RUNPATH', '$ORIGIN/lib'], + ['SONAME', 'libmpv.so.2'], + ['SONAME', 'libmpv.so.3'], + ]) + ) + ).toEqual({ + needed: ['libmpv.so.2'], + rpath: ['/forbidden'], + runpath: ['$ORIGIN/lib'], + soname: ['libmpv.so.2', 'libmpv.so.3'], + }); + }); + + it('resolves the exact libmpv SONAME from closure metadata and verified copied files', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const fixture = createSonameFixture(); + + expect( + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toBe('libmpv.so.2'); + }); + + it('rejects missing and ambiguous closure SONAME metadata', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const missingFixture = createSonameFixture(); + for (const entry of missingFixture.runtimeDependencyClosure.entries) { + entry.soname = null; + } + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...missingFixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/exactly one versioned libmpv SONAME/i); + + const ambiguousFixture = createSonameFixture(); + ambiguousFixture.runtimeDependencyClosure.entries.push({ + name: 'libmpv.so.3', + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname: 'libmpv.so.3', + }); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...ambiguousFixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/exactly one versioned libmpv SONAME/i); + }); + + it('rejects missing, ambiguous, and mismatched DT_SONAME values', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const fixture = createSonameFixture(); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => readelfDynamic([]), + }) + ).toThrow(/exactly one DT_SONAME/i); + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([ + ['SONAME', 'libmpv.so.2'], + ['SONAME', 'libmpv.so.3'], + ]), + }) + ).toThrow(/exactly one DT_SONAME/i); + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.3']]), + }) + ).toThrow(/does not match validated closure SONAME/i); + }); + + (process.platform === 'win32' ? it.skip : it)( + 'rejects a symlinked copied linker input', + () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const fixture = createSonameFixture(); + const aliasPath = path.join(fixture.outputLibDir, 'libmpv.so'); + unlinkSync(aliasPath); + symlinkSync(path.basename(fixture.exactPath), aliasPath); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/must be a regular non-symbolic-link file/i); + } + ); + + it('rejects a missing exact runtime record and a mismatched exact file hash', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const missingRecordFixture = createSonameFixture(); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...missingRecordFixture, + runtimeFiles: missingRecordFixture.runtimeFiles.filter( + ({ name }) => name !== 'libmpv.so.2' + ), + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/exact runtimeFiles record/i); + + const mismatchedFileFixture = createSonameFixture(); + writeFileSync( + mismatchedFileFixture.exactPath, + Buffer.from('tampered exact SONAME file') + ); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...mismatchedFileFixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/size|SHA-256/i); + }); + + it('uses and identifies the unmanaged system libmpv only for system development', () => { + const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule(); + const readDynamicSection = jest.fn((filePath: string) => { + expect(filePath).toBe('/opt/libmpv/lib/libmpv.so'); + return readelfDynamic([['SONAME', 'libmpv.so.2']]); + }); + + expect( + resolveLinuxFrameCopyLinkageInputs({ + buildInputMode: 'system-dev', + outputLibDir: '/native/build/Release/lib', + packagedLibmpvSoname: null, + readDynamicSection, + runtimeLibDir: '/opt/libmpv/lib', + }) + ).toEqual({ + expectedLibmpvSoname: 'libmpv.so.2', + linkerLibraryDir: '/opt/libmpv/lib', + }); + expect(readDynamicSection).toHaveBeenCalledTimes(1); + }); + + it('keeps bundled and untrusted build modes on the copied runtime directory', () => { + const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule(); + const readDynamicSection = jest.fn(() => { + throw new Error('must not inspect the ambient system runtime'); + }); + + for (const buildInputMode of [ + 'bundled-runtime', + 'system-build-inputs', + 'unexpected-mode', + ]) { + expect( + resolveLinuxFrameCopyLinkageInputs({ + buildInputMode, + outputLibDir: '/native/build/Release/lib', + packagedLibmpvSoname: + buildInputMode === 'bundled-runtime' + ? 'libmpv.so.2' + : null, + readDynamicSection, + runtimeLibDir: '/usr/lib/x86_64-linux-gnu', + }) + ).toEqual({ + expectedLibmpvSoname: + buildInputMode === 'bundled-runtime' ? 'libmpv.so.2' : null, + linkerLibraryDir: '/native/build/Release/lib', + }); + } + expect(readDynamicSection).not.toHaveBeenCalled(); + }); + + it('rejects ambiguous or unversioned system-development libmpv identities', () => { + const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule(); + const options = { + buildInputMode: 'system-dev', + outputLibDir: '/native/build/Release/lib', + packagedLibmpvSoname: null, + runtimeLibDir: '/usr/lib/x86_64-linux-gnu', + }; + + expect(() => + resolveLinuxFrameCopyLinkageInputs({ + ...options, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so']]), + }) + ).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); + expect(() => + resolveLinuxFrameCopyLinkageInputs({ + ...options, + readDynamicSection: () => readelfDynamic([]), + }) + ).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); + expect(() => + resolveLinuxFrameCopyLinkageInputs({ + ...options, + readDynamicSection: () => + readelfDynamic([ + ['SONAME', 'libmpv.so.1'], + ['SONAME', 'libmpv.so.2'], + ]), + }) + ).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); + }); + + function createArtifactFixture(): { + outputDir: string; + readDynamicSection: (filePath: string) => string; + outputs: Record; + } { + const outputDir = temporaryDirectory(); + const outputs: Record = { + 'embedded_mpv.node': readelfDynamic([['NEEDED', 'libX11.so.6']]), + 'embedded_mpv_frame_reader.node': readelfDynamic([]), + iptvnator_mpv_helper: readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['NEEDED', 'libEGL.so.1'], + ['RUNPATH', '$ORIGIN/lib'], + ]), + }; + for (const artifact of Object.keys(outputs)) { + writeFileSync(path.join(outputDir, artifact), 'ELF'); + } + return { + outputDir, + outputs, + readDynamicSection: (filePath: string) => + outputs[path.basename(filePath)], + }; + } + + it('accepts only process-isolated Linux frame-copy linkage', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).not.toThrow(); + }); + + it('rejects a helper linked to the wrong libmpv SONAME', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + + fixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.3'], + ['RUNPATH', '$ORIGIN/lib'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).toThrow(/helper.*DT_NEEDED must contain exactly libmpv\.so\.2/i); + }); + + it('rejects helper RPATH and any RUNPATH other than $ORIGIN/lib', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const rpathFixture = createArtifactFixture(); + rpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RPATH', '/host/lib'], + ['RUNPATH', '$ORIGIN/lib'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: rpathFixture.outputDir, + readDynamicSection: rpathFixture.readDynamicSection, + }) + ).toThrow(/helper must not contain RPATH/i); + + const runpathFixture = createArtifactFixture(); + runpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RUNPATH', '$ORIGIN'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: runpathFixture.outputDir, + readDynamicSection: runpathFixture.readDynamicSection, + }) + ).toThrow(/helper RUNPATH must be exactly \$ORIGIN\/lib/i); + }); + + it.each([ + ['embedded_mpv.node', 'addon'], + ['embedded_mpv_frame_reader.node', 'frame reader'], + ])('rejects Electron-side libmpv linkage from %s', (fileName, label) => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + fixture.outputs[fileName] = readelfDynamic([['NEEDED', 'libmpv.so.2']]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).toThrow(new RegExp(`${label} must not have a direct libmpv`, 'i')); + }); + + it('rejects missing artifacts and readelf failures', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const missingArtifactFixture = createArtifactFixture(); + unlinkSync( + path.join( + missingArtifactFixture.outputDir, + 'embedded_mpv_frame_reader.node' + ) + ); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: missingArtifactFixture.outputDir, + readDynamicSection: missingArtifactFixture.readDynamicSection, + }) + ).toThrow(/missing.*frame reader/i); + + const readelfFailureFixture = createArtifactFixture(); + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: readelfFailureFixture.outputDir, + readDynamicSection: () => { + throw new Error('readelf is unavailable'); + }, + }) + ).toThrow(/readelf is unavailable/); + }); + + it('runs cleanup before rethrowing the original transaction failure', () => { + const { runWithCleanup } = loadLinkageModule(); + const calls: string[] = []; + const failure = new Error('post-link validation failed'); + + expect(() => + runWithCleanup( + () => { + calls.push('operation'); + throw failure; + }, + () => calls.push('cleanup') + ) + ).toThrow(failure); + expect(calls).toEqual(['operation', 'cleanup']); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index 09039e268..3588ed41d 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -32,6 +32,16 @@ describe('Embedded MPV native source recording invariants', () => { ), 'utf8' ); + const electronBuilderConfig = JSON.parse( + readFileSync( + path.resolve(__dirname, '../../../../../electron-builder.json'), + 'utf8' + ) + ) as { + snap?: { + plugs?: unknown; + }; + }; const stageRuntimeSource = readFileSync( path.resolve( __dirname, @@ -43,6 +53,10 @@ describe('Embedded MPV native source recording invariants', () => { path.resolve(__dirname, '../../../native/helper/frame_helper_render.h'), 'utf8' ); + const frameHelperSource = readFileSync( + path.resolve(__dirname, '../../../native/helper/mpv_frame_helper.cpp'), + 'utf8' + ); const frameHelperGlSource = readFileSync( path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'), 'utf8' @@ -570,11 +584,120 @@ describe('Embedded MPV native source recording invariants', () => { expect(buildScriptSource).toContain('cleanNativeBuildIntermediates();'); }); - it('does not stage Linux libmpv runtime libraries', () => { - expect(stageRuntimeSource).toContain( - "if (platform !== 'linux') {\n" + - ' copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter);\n' + - ' }' + it('validates and copies only the staged Linux shared-library closure', () => { + expect(buildScriptSource).toContain( + "require('../../tools/embedded-mpv/linux-runtime-manifest.cjs')" + ); + expect(buildScriptSource).toContain( + 'validateLinuxRuntimeManifest(sourceRuntimeManifest)' + ); + expect(buildScriptSource).toContain( + 'validateLinuxSystemBuildInputManifest(sourceRuntimeManifest)' + ); + expect(buildScriptSource).toContain( + 'copyLinuxRuntimeClosureToNativeBuild(runtime)' + ); + expect(buildScriptSource).toContain( + 'runtime.sourceRuntimeManifest.runtimeFiles' + ); + expect(buildScriptSource).toContain( + 'SHA-256 mismatch for staged Linux runtime file' + ); + expect(buildScriptSource).toContain( + 'resolveLinuxFrameCopyLinkageInputs({' + ); + expect(buildScriptSource).toContain( + 'LINUX_VERIFIED_RUNTIME_LIBRARY_DIR:\n' + + ' linuxLinkageInputs.linkerLibraryDir' + ); + expect(buildScriptSource).toContain( + 'expectedLibmpvSoname: linuxLinkageInputs.expectedLibmpvSoname' + ); + expect(buildScriptSource).not.toContain( + 'process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir' + ); + expect(buildScriptSource).not.toContain( + 'LINUX_NATIVE_LIBRARY_DIR: runtime.libDir' + ); + }); + + it('writes a profile-neutral Linux frame-copy build manifest', () => { + expect(buildScriptSource).toContain( + "const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']);" + ); + expect(buildScriptSource).toContain("origin: 'linux-frame-copy-build'"); + expect(buildScriptSource).toContain( + 'allowedPackageRuntimeModes: [...LINUX_PACKAGE_RUNTIME_MODES]' + ); + expect(buildScriptSource).toContain( + 'buildInputMode: runtime.buildInputMode' + ); + expect(buildScriptSource).toContain( + 'sourceRuntime: runtime.sourceRuntimeManifest' + ); + expect(buildScriptSource).toContain('runtimeFiles: copiedRuntimeFiles'); + expect(buildScriptSource).not.toContain( + 'writeLinuxProcessRuntimeManifest' + ); + }); + + it('requires a validated bundled source runtime for required Linux builds', () => { + expect(buildScriptSource).toContain( + "sourceRuntimeValidated: buildInputMode === 'bundled-runtime'" + ); + expect(buildScriptSource).toContain( + 'assertRequiredLinuxFrameCopyRuntime(runtime);' + ); + expect(buildScriptSource).toContain( + "runtime.buildInputMode !== 'bundled-runtime' ||" + ); + expect(buildScriptSource).toContain( + 'runtime.sourceRuntimeValidated !== true' + ); + expect(buildScriptSource).toContain("runtimeFile.name === 'libmpv.so'"); + expect(buildScriptSource).toContain( + 'Required Linux builds must use the validated bundled source runtime containing staged libmpv.' + ); + }); + + it('derives packaged Linux libmpv identity from validated closure SONAME metadata', () => { + expect(buildScriptSource).toContain('resolveVerifiedLinuxLibMpvSoname'); + expect(buildScriptSource).toContain( + 'runtime.sourceRuntimeManifest.runtimeDependencyClosure' + ); + expect(buildScriptSource).toContain('libmpvSoname,'); + expect(buildScriptSource).not.toContain( + 'VERSIONED_LINUX_LIBMPV_PATTERN' + ); + expect(buildScriptSource).not.toContain("libmpvSoname: 'libmpv.so.2'"); + }); + + it('marks both package modes available only for a validated bundled build', () => { + expect(buildScriptSource).toContain( + 'runtime.sourceRuntimeValidated === true &&\n' + + " runtime.buildInputMode === 'bundled-runtime' &&\n" + + ' copiedRuntimeFiles.length > 0 &&\n' + + ' libmpvSoname !== null' + ); + expect(buildScriptSource).toContain('system: packageRuntimeAvailable'); + expect(buildScriptSource).toContain('bundled: packageRuntimeAvailable'); + }); + + it('validates Linux post-link isolation before marking the build available', () => { + const postLinkValidation = buildScriptSource.indexOf( + 'validateLinuxFrameCopyLinkage({' + ); + const availabilityMarkerRemoval = buildScriptSource.lastIndexOf( + 'fs.rmSync(unavailableMarkerFile' + ); + + expect(buildScriptSource).toContain( + "spawnSync('readelf', ['-d', filePath]" + ); + expect(postLinkValidation).toBeGreaterThanOrEqual(0); + expect(availabilityMarkerRemoval).toBeGreaterThan(postLinkValidation); + expect(buildScriptSource).toContain( + 'runWithCleanup(buildNativeArtifacts, cleanOutput)' ); }); @@ -597,17 +720,33 @@ describe('Embedded MPV native source recording invariants', () => { ); }); - it('requires Linux embedded MPV build inputs and validates process isolation in CI', () => { + it('requires the pinned Linux source runtime artifact and validates process isolation in CI', () => { expect(buildAndMakeWorkflowSource).toContain( - 'libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev' + 'Build and stage pinned LGPL Linux runtime' ); expect(buildAndMakeWorkflowSource).toContain( + 'node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}"' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}"' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'name: linux-embedded-mpv-runtime' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'path: vendor/embedded-mpv/linux-x64' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'Download pinned Linux Embedded MPV runtime' + ); + expect(buildAndMakeWorkflowSource).not.toContain('libopengl-dev'); + expect(buildAndMakeWorkflowSource).not.toContain( 'Stage Linux embedded MPV build inputs' ); - expect(buildAndMakeWorkflowSource).toContain( - "linuxBackend: 'process-isolated mpv --wid'" - ); expect(buildAndMakeWorkflowSource).toContain("matrix.os == 'linux'"); + expect(buildAndMakeWorkflowSource).toContain( + "manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true" + ); expect(buildAndMakeWorkflowSource).toContain( 'Linux embedded MPV addon must not link directly to libmpv' ); @@ -615,14 +754,11 @@ describe('Embedded MPV native source recording invariants', () => { 'test -f dist/apps/electron-backend/native/iptvnator_mpv_helper' ); expect(buildAndMakeWorkflowSource).toContain( - 'Linux frame-copy helper must link libmpv' + 'Linux frame-copy helper must need libmpv.so.2' ); - expect(buildScriptSource).toContain("origin: 'external-mpv-process'"); - expect(buildScriptSource).toContain('writeLinuxProcessRuntimeManifest'); - expect(buildScriptSource).toContain('runtimeFiles: []'); }); - it('supports Linux system-development inputs without leaving stale frame-copy artifacts', () => { + it('keeps optional Linux system development separate from required staged inputs', () => { expect(buildScriptSource).toContain( "const systemIncludeDir =\n process.env.LIBMPV_INCLUDE_DIR || '/usr/include';" ); @@ -635,6 +771,9 @@ describe('Embedded MPV native source recording invariants', () => { expect(buildScriptSource).toContain( "if (!embeddedMpvRequired && runtime.origin === 'system-dev')" ); + expect(buildScriptSource).toContain( + 'Required Linux builds must use the validated bundled source runtime containing staged libmpv.' + ); expect(buildScriptSource).toContain( 'removeStaleFrameCopyArtifacts(outputDir);' ); @@ -654,6 +793,101 @@ describe('Embedded MPV native source recording invariants', () => { ); }); + it('keeps Electron Builder defaults and exact Snap runtime plugs', () => { + expect(electronBuilderConfig.snap?.plugs).toEqual([ + 'default', + { + 'graphics-core22': { + interface: 'content', + target: '$SNAP/graphics', + 'default-provider': 'mesa-core22', + }, + }, + { + 'shared-memory': { + interface: 'shared-memory', + private: true, + }, + }, + ]); + }); + + it('runs the helper runtime probe through shared memory without media or command loops', () => { + const runtimeProbe = sourceFunctionBody( + frameHelperSource, + 'int runRuntimeProbe(', + 'runRuntimeProbe' + ); + const runtimeProbeShmName = sourceFunctionBody( + frameHelperSource, + 'std::string runtimeProbeShmName(', + 'runtimeProbeShmName' + ); + const main = sourceFunctionBody(frameHelperSource, 'int main(', 'main'); + const runtimeProbeFailure = sourceFunctionBody( + frameHelperSource, + 'int runtimeProbeFailure(', + 'runtimeProbeFailure' + ); + + expect(main).toContain('if (args.runtimeProbe) {'); + expect(main).toContain('return runRuntimeProbe();'); + expect(runtimeProbe).toContain('mpv_create()'); + expect(runtimeProbe).toContain('"idle", "yes"'); + expect(runtimeProbe).toContain('"vo", "libmpv"'); + expect(runtimeProbe).toContain('mpv_initialize(mpv)'); + expect(runtimeProbe).toContain('GlContext gl;'); + expect(runtimeProbe).toContain('gl.create(error)'); + expect(runtimeProbe).toContain('gl.makeCurrent(error)'); + expect(runtimeProbe).toContain('mpv_render_context_create('); + expect(runtimeProbe).toContain('mpv_render_context_free('); + expect(runtimeProbe).toContain('gl.destroy()'); + expect(runtimeProbe).toContain('mpv_terminate_destroy(mpv)'); + expect(runtimeProbe).toContain( + 'frame_helper::ShmRing runtimeProbeRing;' + ); + expect(runtimeProbe).toContain( + 'const std::string shmName = runtimeProbeShmName();' + ); + expect(runtimeProbe).toContain( + 'runtimeProbeRing.create(shmName, 16, 16, 1)' + ); + expect(runtimeProbe).toContain( + 'runtimeProbeRing.header->magic == FRAME_SHM_MAGIC' + ); + expect(runtimeProbe).toContain('runtimeProbeRing.destroy();'); + expect(runtimeProbe).toContain('"shared-memory-create-failed"'); + expect(runtimeProbe).toContain('"shared-memory-initialize-failed"'); + expect(runtimeProbeShmName).toContain('"/impv-fc-runtime-probe-"'); + expect(runtimeProbeShmName).toContain('getpid()'); + expect(runtimeProbeShmName).toContain('GetCurrentProcessId()'); + expect(runtimeProbeShmName).toContain('std::to_string(processId)'); + expect(runtimeProbe).toContain('.num("protocol", 1)'); + expect(runtimeProbe).toContain('.boolean("usable", true)'); + expect(runtimeProbe).toContain('.str("libmpv",'); + expect(runtimeProbe).toContain('.str("renderApi", gl.renderApiName())'); + expect(runtimeProbe).not.toContain('pipeline'); + expect(runtimeProbe).not.toContain('runStdinLoop'); + expect(runtimeProbe).not.toContain('runMpvEventLoop'); + expect(runtimeProbe).not.toContain('loadfile'); + expect(runtimeProbe.match(/emitLine\(/g)).toHaveLength(1); + expect(runtimeProbeFailure).toContain('.num("protocol", 1)'); + expect(runtimeProbeFailure).toContain('.boolean("usable", false)'); + expect(runtimeProbeFailure).toContain('.str("reason", reason)'); + expect(runtimeProbeFailure.match(/emitLine\(/g)).toHaveLength(1); + expect(runtimeProbeFailure).toContain('return 1;'); + }); + + it('identifies the Linux helper runtime probe render API as EGL', () => { + const renderApiName = sourceFunctionBody( + linuxFrameHelperGlSource, + 'const char* renderApiName() const', + 'GlContext::renderApiName' + ); + + expect(renderApiName).toContain('return "egl";'); + }); + it('validates complete EGL candidates and keeps software rendering as the final fallback', () => { const tryCandidate = sourceFunctionBody( linuxFrameHelperGlSource, @@ -817,14 +1051,33 @@ describe('Embedded MPV native build configuration', () => { )?.[1]; expect(linuxAddonConfig?.libraries).not.toContain('-lmpv'); + expect(JSON.stringify(linuxAddonConfig)).not.toContain('-lmpv'); expect(linuxHelperConfig?.libraries).toEqual( - expect.arrayContaining([ - '-lmpv', - '-lEGL', - '-lOpenGL', - '-lgbm', - '-ldl', - ]) + expect.arrayContaining(['-lEGL', '-lGL', '-lgbm', '-ldl']) + ); + expect(linuxHelperConfig?.libraries).not.toContain('-lOpenGL'); + expect(linuxHelperConfig?.libraries).not.toContain('-lmpv'); + expect( + linuxHelperConfig?.libraries.find((library: string) => + library.includes("path.join(dir, 'libmpv.so')") + ) + ).toContain('LINUX_VERIFIED_RUNTIME_LIBRARY_DIR'); + expect(JSON.stringify(linuxHelperConfig)).not.toContain( + "LINUX_VERIFIED_RUNTIME_LIBRARY_DIR || '/usr/lib'" + ); + expect(JSON.stringify(linuxHelperConfig)).toContain( + 'Missing LINUX_VERIFIED_RUNTIME_LIBRARY_DIR' + ); + + const runtimeLinkerFlags = linuxHelperConfig?.ldflags.filter( + (flag: string) => flag.startsWith('-Wl,') + ); + expect(runtimeLinkerFlags).toEqual([ + '-Wl,--enable-new-dtags', + "-Wl,-rpath,'$$ORIGIN/lib'", + ]); + expect(JSON.stringify(runtimeLinkerFlags)).not.toContain( + 'LINUX_NATIVE_LIBRARY_DIR' ); }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 3df3c7160..1f85402b2 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -3,24 +3,30 @@ import type { EmbeddedMpvSessionStatus, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from 'fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import path from 'path'; import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service'; const mockSpawnSync = jest.fn(); +const mockIsFrameCopyRuntimeUsable = jest.fn(); +const mockGetFrameCopyRuntimeAvailability = jest.fn(); jest.mock('child_process', () => ({ spawnSync: mockSpawnSync, })); +jest.mock('./embedded-mpv-frame-copy-platform.util', () => { + const actual = jest.requireActual( + './embedded-mpv-frame-copy-platform.util' + ); + return { + ...actual, + getFrameCopyRuntimeAvailability: mockGetFrameCopyRuntimeAvailability, + isFrameCopyRuntimeUsable: mockIsFrameCopyRuntimeUsable, + }; +}); + const powerSaveBlockerMock = { start: jest.fn(), stop: jest.fn(), @@ -138,6 +144,13 @@ describe('EmbeddedMpvNativeService power blocker', () => { mockSpawnSync.mockReturnValue({ status: 0, }); + mockIsFrameCopyRuntimeUsable.mockReset(); + mockIsFrameCopyRuntimeUsable.mockReturnValue(false); + mockGetFrameCopyRuntimeAvailability.mockReset(); + mockGetFrameCopyRuntimeAvailability.mockReturnValue({ + usable: false, + reason: 'helper-probe-failed', + }); mainWindowGetNativeWindowHandleMock.mockReset(); mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8)); mainWindowSendMock.mockReset(); @@ -230,14 +243,9 @@ describe('EmbeddedMpvNativeService power blocker', () => { // no helper on disk => the engine env flag is ignored, native keeps // working, and support does not advertise frame-copy. process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; - jest.spyOn( - service as unknown as { - resolveFrameCopyHelperPath: () => string | null; - }, - 'resolveFrameCopyHelperPath' - ).mockReturnValue(null); try { expect(service.getActiveEngine()).toBe('native'); + expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledWith(); const support = service.getSupport(); expect(support.engine).not.toBe('frame-copy'); startSession('s-fallback', snapshot('loading')); @@ -262,37 +270,23 @@ describe('EmbeddedMpvNativeService power blocker', () => { }); (process.platform === 'win32' ? it.skip : it)( - 'falls back to the native engine when the frame-copy helper is not executable', + 'falls back to the native engine when the frame-copy runtime probe fails', () => { - const tempDir = createTempDir(); - const releaseDir = path.join( - tempDir, - 'apps', - 'electron-backend', - 'native', - 'build', - 'Release' - ); - const helperPath = path.join( - releaseDir, - 'iptvnator_mpv_helper' - ); - mkdirSync(releaseDir, { recursive: true }); - writeFileSync(helperPath, '#!/bin/sh\n'); - chmodSync(helperPath, 0o644); - writeFileSync( - path.join(releaseDir, 'embedded_mpv_frame_reader.node'), - 'reader' - ); - const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tempDir); process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; try { expect(service.getActiveEngine()).toBe('native'); expect(service.isFrameCopyAvailable()).toBe(false); + expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledWith(); + expect(service.getSupport()).toEqual( + expect.objectContaining({ + engine: 'native', + frameCopyAvailable: false, + frameCopyUnavailableReason: 'helper-probe-failed', + }) + ); } finally { delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY; - cwdSpy.mockRestore(); } } ); @@ -300,13 +294,11 @@ describe('EmbeddedMpvNativeService power blocker', () => { describe('frame-copy platform gate', () => { const originalArch = process.arch; - function mockHelperPresent(): void { - jest.spyOn( - service as unknown as { - resolveFrameCopyHelperPath: () => string | null; - }, - 'resolveFrameCopyHelperPath' - ).mockReturnValue('/native/iptvnator_mpv_helper'); + function mockRuntimeUsable(): void { + mockIsFrameCopyRuntimeUsable.mockReturnValue(true); + mockGetFrameCopyRuntimeAvailability.mockReturnValue({ + usable: true, + }); } afterEach(() => { @@ -322,7 +314,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { process.env.DISPLAY = ':0'; process.env.WAYLAND_DISPLAY = 'wayland-0'; process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; - mockHelperPresent(); + mockRuntimeUsable(); expect(service.getActiveEngine()).toBe('frame-copy'); expect(service.isFrameCopyAvailable()).toBe(true); @@ -342,7 +334,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { Object.defineProperty(process, 'platform', { value: 'linux' }); process.env.DISPLAY = ':0'; process.env.WAYLAND_DISPLAY = 'wayland-0'; - mockHelperPresent(); + mockRuntimeUsable(); const support = service.getSupport(); expect(support.supported).toBe(false); @@ -355,7 +347,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { process.env.DISPLAY = ':0'; delete process.env.WAYLAND_DISPLAY; mockSpawnSync.mockReturnValue({ status: 1 }); - mockHelperPresent(); + mockRuntimeUsable(); const support = service.getSupport(); expect(support.supported).toBe(false); @@ -370,7 +362,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { delete process.env.WAYLAND_DISPLAY; process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; mockSpawnSync.mockReturnValue({ status: 1 }); - mockHelperPresent(); + mockRuntimeUsable(); expect(service.getSupport()).toEqual( expect.objectContaining({ @@ -383,16 +375,35 @@ describe('EmbeddedMpvNativeService power blocker', () => { it('activates the frame-copy engine on macOS arm64', () => { Object.defineProperty(process, 'arch', { value: 'arm64' }); process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; - mockHelperPresent(); + mockRuntimeUsable(); expect(service.getActiveEngine()).toBe('frame-copy'); expect(service.isFrameCopyAvailable()).toBe(true); }); + it('supplies the adapter with the runtime mode from the validated Linux capability', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockGetFrameCopyRuntimeAvailability.mockReturnValue({ + usable: true, + profile: 'portable', + runtimeMode: 'bundled', + libmpv: '2.3', + renderApi: 'egl', + }); + + expect( + ( + service as unknown as { + resolveFrameCopyRuntimeMode(): string | null; + } + ).resolveFrameCopyRuntimeMode() + ).toBe('bundled'); + }); + it('keeps the frame-copy engine Apple-Silicon-only on macOS', () => { Object.defineProperty(process, 'arch', { value: 'x64' }); process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; - mockHelperPresent(); + mockIsFrameCopyRuntimeUsable.mockReturnValue(false); expect(service.getActiveEngine()).toBe('native'); expect(service.isFrameCopyAvailable()).toBe(false); @@ -401,7 +412,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { it('skips the native window handle when creating a frame-copy session', () => { Object.defineProperty(process, 'platform', { value: 'linux' }); process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; - mockHelperPresent(); + mockRuntimeUsable(); const frameCopyAddon = createMockAddon(); frameCopyAddon.createSession.mockReturnValueOnce('s-fc'); frameCopyAddon.getSessionSnapshot.mockReturnValueOnce( @@ -425,9 +436,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { // dispatches to the adapter that owns the session, not the // native addon the outer afterEach shutdown would pick. service.disposeSession('s-fc'); - expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith( - 's-fc' - ); + expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith('s-fc'); }); }); @@ -455,9 +464,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { expect.arrayContaining(['render-process-gone', 'did-navigate']) ); - const consoleWarnSpy = jest - .spyOn(console, 'warn') - .mockImplementation(); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); handlers.get('did-navigate')?.(); expect(addon.disposeSession).toHaveBeenCalledWith('s1'); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 4f967a36a..d30ad596f 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -29,10 +29,12 @@ import { } from '@iptvnator/shared/interfaces'; import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; import { + getFrameCopyRuntimeAvailability, getEmbeddedMpvAddonCandidatePaths, isFrameCopyRuntimeUsable, resolveFrameCopyHelperPath, } from './embedded-mpv-frame-copy-platform.util'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; import { EMBEDDED_MPV_EXPERIMENT_ENV, isEmbeddedMpvFeatureEnabled, @@ -112,8 +114,9 @@ export class EmbeddedMpvNativeService { /** * Frame-copy engine: helper process + shm ring + renderer canvas. * Experimental, macOS Apple Silicon (owner decision 2026-07-10), Linux - * and Windows, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 - * on top of the regular embedded MPV experiment flag. + * x64 and Windows, opted into with + * IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on top of the regular + * embedded MPV experiment flag. */ private isFrameCopyEngineRequested(): boolean { return ['1', 'true', 'yes', 'on'].includes( @@ -127,10 +130,7 @@ export class EmbeddedMpvNativeService { // Requires the helper binary too: a stale opt-in (cleaned native // build, bad install) must fall back to the native engine instead // of leaving embedded MPV unsupported with no way to recover. - return ( - this.isFrameCopyEngineRequested() && - isFrameCopyRuntimeUsable(() => this.resolveFrameCopyHelperPath()) - ); + return this.isFrameCopyEngineRequested() && isFrameCopyRuntimeUsable(); } getActiveEngine(): EmbeddedMpvEngine { @@ -138,9 +138,31 @@ export class EmbeddedMpvNativeService { } isFrameCopyAvailable(): boolean { - return isFrameCopyRuntimeUsable(() => - this.resolveFrameCopyHelperPath() - ); + return isFrameCopyRuntimeUsable(); + } + + private getFrameCopySupportDetails(): Pick< + EmbeddedMpvSupport, + 'frameCopyAvailable' | 'frameCopyUnavailableReason' + > { + const availability = getFrameCopyRuntimeAvailability(); + if (!('reason' in availability)) { + return { frameCopyAvailable: true }; + } + return { + frameCopyAvailable: false, + frameCopyUnavailableReason: availability.reason, + }; + } + + private resolveFrameCopyRuntimeMode(): EmbeddedMpvFrameCopyRuntimeMode | null { + if (process.platform !== 'linux') { + return null; + } + const availability = getFrameCopyRuntimeAvailability(); + return availability.usable && 'runtimeMode' in availability + ? availability.runtimeMode + : null; } getFrameSource(sessionId: string): EmbeddedMpvFrameSource | null { @@ -150,7 +172,8 @@ export class EmbeddedMpvNativeService { private getFrameCopyAdapter(): EmbeddedMpvFrameCopyAdapter { if (!this.frameCopyAdapter) { this.frameCopyAdapter = new EmbeddedMpvFrameCopyAdapter({ - resolveHelperPath: () => this.resolveFrameCopyHelperPath(), + resolveHelperPath: resolveFrameCopyHelperPath, + resolveRuntimeMode: () => this.resolveFrameCopyRuntimeMode(), getScaleFactor: () => this.getMainWindowScaleFactor(), onFrameSourceChanged: (sessionId, source) => { if (!App.mainWindow || App.mainWindow.isDestroyed()) { @@ -166,12 +189,6 @@ export class EmbeddedMpvNativeService { return this.frameCopyAdapter; } - private resolveFrameCopyHelperPath(): string | null { - // Shared with the startup sandbox gate; kept as an instance method so - // service tests can stub helper discovery per scenario. - return resolveFrameCopyHelperPath(); - } - private getMainWindowScaleFactor(): number { try { if (!App.mainWindow || App.mainWindow.isDestroyed()) { @@ -229,7 +246,7 @@ export class EmbeddedMpvNativeService { supported: false, platform: process.platform, reason: 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.', - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), }; } @@ -249,7 +266,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: 'frame-copy', - frameCopyAvailable: true, + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } @@ -261,7 +278,7 @@ export class EmbeddedMpvNativeService { supported: false, platform: process.platform, reason: missingLinuxMpvExecutableReason, - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), }; } @@ -279,7 +296,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: this.getActiveEngine(), - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } catch (error) { @@ -345,7 +362,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: this.getActiveEngine(), - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } catch (error) { @@ -377,7 +394,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: this.getActiveEngine(), - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } catch (error) { @@ -709,8 +726,9 @@ export class EmbeddedMpvNativeService { }); }; - App.mainWindow.webContents.on('render-process-gone', (_event, details) => - disposeAll(`process gone (${details.reason})`) + App.mainWindow.webContents.on( + 'render-process-gone', + (_event, details) => disposeAll(`process gone (${details.reason})`) ); // Full navigations/reloads only — in-app Angular routing emits // did-navigate-in-page and must not kill the active session. diff --git a/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts new file mode 100644 index 000000000..c90ca51d1 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts @@ -0,0 +1,112 @@ +const mockElectronApp = { + isPackaged: true, +}; + +jest.mock('electron', () => ({ app: mockElectronApp })); + +import { + EMBEDDED_MPV_RUNTIME_PROBE_SWITCH, + runEmbeddedMpvRuntimeDiagnosticOrContinue, +} from './embedded-mpv-runtime-diagnostic'; +import type { FrameCopyRuntimeAvailability } from './embedded-mpv-frame-copy-platform.util'; + +interface DiagnosticHarness { + continueStartup: jest.Mock; + exit: jest.Mock; + getRuntimeAvailability: jest.Mock; + writeStdout: jest.Mock; +} + +function createHarness( + availability: FrameCopyRuntimeAvailability = { + usable: false, + reason: 'runtime-artifact-missing', + } +): DiagnosticHarness { + return { + continueStartup: jest.fn(), + exit: jest.fn(), + getRuntimeAvailability: jest.fn(() => availability), + writeStdout: jest.fn(), + }; +} + +function runDiagnostic( + argv: readonly string[], + harness: DiagnosticHarness +): void { + runEmbeddedMpvRuntimeDiagnosticOrContinue(argv, harness.continueStartup, { + exit: harness.exit, + getRuntimeAvailability: harness.getRuntimeAvailability, + writeStdout: harness.writeStdout, + }); +} + +describe('embedded MPV runtime diagnostic', () => { + it.each([ + [['electron', 'main.js']], + [['electron', 'main.js', `${EMBEDDED_MPV_RUNTIME_PROBE_SWITCH}=1`]], + [['electron', 'main.js', 'embedded-mpv-runtime-probe']], + ])('continues normal startup for argv %j', (argv) => { + const harness = createHarness(); + + runDiagnostic(argv, harness); + + expect(harness.continueStartup).toHaveBeenCalledTimes(1); + expect(harness.getRuntimeAvailability).not.toHaveBeenCalled(); + expect(harness.writeStdout).not.toHaveBeenCalled(); + expect(harness.exit).not.toHaveBeenCalled(); + }); + + it('prints the usable availability as one JSON line, exits zero, and skips startup', () => { + const availability: FrameCopyRuntimeAvailability = { + usable: true, + profile: 'portable', + runtimeMode: 'bundled', + libmpv: '2.3', + renderApi: 'egl', + }; + const harness = createHarness(availability); + + runDiagnostic( + ['electron', 'main.js', EMBEDDED_MPV_RUNTIME_PROBE_SWITCH], + harness + ); + + expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1); + expect(harness.writeStdout).toHaveBeenCalledWith( + `${JSON.stringify(availability)}\n` + ); + expect(harness.writeStdout).toHaveBeenCalledTimes(1); + expect(harness.exit).toHaveBeenCalledWith(0); + expect(harness.exit).toHaveBeenCalledTimes(1); + expect(harness.writeStdout.mock.invocationCallOrder[0]).toBeLessThan( + harness.exit.mock.invocationCallOrder[0] + ); + expect(harness.continueStartup).not.toHaveBeenCalled(); + }); + + it('prints the unavailable helper reason as one JSON line, exits nonzero, and skips startup', () => { + const availability: FrameCopyRuntimeAvailability = { + usable: false, + reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', + helperDetail: 'EGL initialization failed: display unavailable', + }; + const harness = createHarness(availability); + + runDiagnostic( + [EMBEDDED_MPV_RUNTIME_PROBE_SWITCH, 'electron', 'main.js'], + harness + ); + + expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1); + expect(harness.writeStdout).toHaveBeenCalledWith( + '{"usable":false,"reason":"helper-probe-failed","helperReason":"gl-context-create-failed","helperDetail":"EGL initialization failed: display unavailable"}\n' + ); + expect(harness.writeStdout).toHaveBeenCalledTimes(1); + expect(harness.exit).toHaveBeenCalledWith(1); + expect(harness.exit).toHaveBeenCalledTimes(1); + expect(harness.continueStartup).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts new file mode 100644 index 000000000..4c6f23941 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts @@ -0,0 +1,36 @@ +import { writeSync } from 'fs'; +import { + getFrameCopyRuntimeAvailability, + type FrameCopyRuntimeAvailability, +} from './embedded-mpv-frame-copy-platform.util'; + +export const EMBEDDED_MPV_RUNTIME_PROBE_SWITCH = '--embedded-mpv-runtime-probe'; + +interface EmbeddedMpvRuntimeDiagnosticDependencies { + exit(code: number): void; + getRuntimeAvailability(): FrameCopyRuntimeAvailability; + writeStdout(output: string): void; +} + +const defaultDependencies: EmbeddedMpvRuntimeDiagnosticDependencies = { + exit: (code) => process.exit(code), + getRuntimeAvailability: getFrameCopyRuntimeAvailability, + writeStdout: (output) => { + writeSync(process.stdout.fd, output); + }, +}; + +export function runEmbeddedMpvRuntimeDiagnosticOrContinue( + argv: readonly string[], + continueStartup: () => void, + dependencies: EmbeddedMpvRuntimeDiagnosticDependencies = defaultDependencies +): void { + if (!argv.includes(EMBEDDED_MPV_RUNTIME_PROBE_SWITCH)) { + continueStartup(); + return; + } + + const availability = dependencies.getRuntimeAvailability(); + dependencies.writeStdout(`${JSON.stringify(availability)}\n`); + dependencies.exit(availability.usable ? 0 : 1); +} diff --git a/apps/electron-backend/src/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index a6bb9d68a..7bd61ae7c 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -9,10 +9,10 @@ export const VLC_PLAYER_ARGUMENTS = 'VLC_PLAYER_ARGUMENTS'; export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE'; export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE'; /** - * Embedded MPV frame-copy engine opt-in (macOS arm64, Linux). Lives in the main - * process config file because it must be readable synchronously before the - * BrowserWindow is created — the engine relaxes the window sandbox for its - * preload frame pump, which cannot change after window creation. + * Embedded MPV frame-copy engine opt-in (macOS arm64, Linux x64). Lives in the + * main process config file because it must be readable synchronously before + * the BrowserWindow is created — the engine relaxes the window sandbox for + * its preload frame pump, which cannot change after window creation. */ export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY'; diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 0e99bd6ee..ea9b08923 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -36,10 +36,8 @@ import { shouldPromotePersistedFrameCopyOptIn, } from './app/services/embedded-mpv-frame-copy-platform.util'; import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util'; -import { - EMBEDDED_MPV_FRAME_COPY, - store, -} from './app/services/store.service'; +import { runEmbeddedMpvRuntimeDiagnosticOrContinue } from './app/services/embedded-mpv-runtime-diagnostic'; +import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service'; app.setName('iptvnator'); @@ -74,7 +72,7 @@ if ( shouldPromotePersistedFrameCopyOptIn( store.get(EMBEDDED_MPV_FRAME_COPY, false), process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY, - isFrameCopyRuntimeUsable() + isFrameCopyRuntimeUsable ) ) { process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; @@ -192,23 +190,25 @@ export default class Main { } } -// handle setup events as quickly as possible -Main.initialize(); +runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { + // handle setup events as quickly as possible + Main.initialize(); -// bootstrap app -Main.bootstrapApp(); + // bootstrap app + Main.bootstrapApp(); -// Bootstrap app events after Electron app is ready -app.whenReady().then(async () => { - if (isStartupTraceEnabled()) { - trace('startup', 'app.whenReady'); - } - await Main.bootstrapAppEvents(); -}); - -app.on('before-quit', () => { - shutdownEmbeddedMpv(); - shutdownMpvSession(); - shutdownVlcSession(); - void databaseWorkerClient.shutdown(); + // Bootstrap app events after Electron app is ready + app.whenReady().then(async () => { + if (isStartupTraceEnabled()) { + trace('startup', 'app.whenReady'); + } + await Main.bootstrapAppEvents(); + }); + + app.on('before-quit', () => { + shutdownEmbeddedMpv(); + shutdownMpvSession(); + shutdownVlcSession(); + void databaseWorkerClient.shutdown(); + }); }); diff --git a/apps/electron-backend/tsconfig.spec.json b/apps/electron-backend/tsconfig.spec.json index 976a7011d..ae084b520 100644 --- a/apps/electron-backend/tsconfig.spec.json +++ b/apps/electron-backend/tsconfig.spec.json @@ -1,16 +1,17 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/out-tsc", - "esModuleInterop": true, - "module": "commonjs", - "moduleResolution": "node10", - "types": ["jest", "node"] - }, - "include": [ - "jest.config.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.d.ts" - ] + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "esModuleInterop": true, + "allowJs": true, + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] } diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index f664f4945..b0833d40b 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -18,7 +18,7 @@ Source files for the embedded MPV integration: - `libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts` defines the shared session and audio-track contract. - `libs/ui/playback/src/lib/embedded-mpv-player/` owns the Angular UI and controls. -Frame-copy engine sources (experimental, macOS Apple Silicon, Linux and +Frame-copy engine sources (experimental, macOS Apple Silicon, Linux x64, and Windows — see the "Frame-Copy Engine" section below): - `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper` process (`mpv_frame_helper.cpp`, `frame_helper_render.h`, `frame_helper_gl.h`, `frame_helper_io.h`, `frame_shm.h`). @@ -60,20 +60,76 @@ Linux native Wayland embedding is not implemented. When Electron is started on X ## Linux Support Matrix -Embedded MPV on Linux is supported only for x64 desktop builds where Electron runs under X11 or Xwayland and an `mpv` executable is available on `PATH`. Native Wayland embedding is not supported in this implementation. +Official Linux frame-copy packaging is x64-only. The native-view and frame-copy +engines have different runtime requirements: -The experimental frame-copy engine (below) is the exception to both requirements: it renders offscreen through headless EGL into a renderer canvas — no window embedding — and the helper links libmpv itself, so neither the X11/Xwayland constraint nor the system-`mpv`-on-`PATH` probe applies while it is active. It is currently a dev-build-only engine on Linux (the helper links the build host's system `libmpv` and is stripped from packaged apps until the bundled-runtime staging lands). Packaged Linux launchers pass `--ozone-platform=x11` so Wayland desktops use Xwayland when it is available, and `main.ts` appends the same switch on Linux when it is absent so direct binary/AppImage launches from a terminal behave like launcher starts. Explicit user intent is never overridden: both a user-provided `--ozone-platform` switch and the `ELECTRON_OZONE_PLATFORM_HINT` environment variable suppress the fallback. +| Engine | Display path | MPV runtime | +| ----------- | ----------------------------- | -------------------------------------------------------------------------- | +| native-view | X11 or Xwayland | An `mpv` executable on `PATH`; playback is an isolated `mpv --wid` process | +| frame-copy | Headless EGL; no window embed | A separately linked and capability-probed `iptvnator_mpv_helper` | -When the `mpv` executable probe fails inside a Flatpak or Snap sandbox (`FLATPAK_ID`/`SNAP` env present), the support reason explains that sandboxed packages cannot access a system mpv instead of asking the user to install it. +Native Wayland embedding is not implemented for native-view. Frame-copy itself +does not embed a window and can render through EGL on a native Wayland desktop, +although packaged launchers still default Electron to X11/Xwayland unless the +user explicitly supplies an Ozone choice. A user-provided `--ozone-platform` +or `ELECTRON_OZONE_PLATFORM_HINT` is never overridden. -Current release-announcement wording should stay close to this: +Linux packages are built in separate passes because Electron Builder reuses +one unpacked application layout per pass: -- Supported display path: X11 or Xwayland. -- Not supported: native Wayland embedding. -- Validated locally: Ubuntu 24.04 GNOME Wayland session with Electron forced to X11/Xwayland and system `mpv`. -- Validated in CI: Ubuntu 22.04 standard Linux package build and Ubuntu 24.04 Flatpak package build. -- Expected standard packages: `.deb` on Ubuntu/Debian, `pacman` on Arch/Manjaro, `.rpm` on RPM-based distributions, and AppImage on x64 glibc systems, all with system `mpv` installed. -- Sandbox caveat: Flatpak and Snap packages build and continue to support the normal inline/external-player flows, but embedded MPV is not announced as supported there yet because the Linux backend launches `mpv --wid` and those sandboxed formats do not expose the host `mpv` executable to the app by default. +| Profile | Formats | Frame-copy runtime strategy | +| ---------- | ---------------- | -------------------------------------------------------------------------------------------------------- | +| `system` | DEB, RPM, Pacman | System `libmpv.so.2` plus the helper's direct EGL/GL/GBM interfaces; exact dependencies are listed below | +| `portable` | AppImage, Snap | Bundled pinned LGPL-compatible runtime under `native/lib` | +| `flatpak` | Flatpak | The same bundled pinned LGPL-compatible runtime under `native/lib` | + +System package dependencies are fail-closed and format-specific: + +- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm` +- Pacman: `mpv`, `libglvnd`, `mesa` + +The DEB contract deliberately names `libmpv2`, not a loose `libmpv` +alternative, and explicitly names the GLVND `libGL.so.1` interface because +`libmpv2` does not pull it in for the helper. The helper uses `-lGL`, not +`-lOpenGL`; this matches the graphics interface supplied by distributions and +Snap's `mesa-core22`. Release CI verifies that contract on Ubuntu 24.04 +(Noble). Ubuntu 22.04 (Jammy) provides `libmpv1`, so its DEB cannot enable this +system-runtime frame-copy path; use the x64 AppImage there instead. + +Every x64 layout contains the addon, frame reader, helper, and a normalized +`embedded-mpv-runtime.json`. The Electron executable, Electron libraries, +`embedded_mpv.node`, and the frame reader must not link libmpv; only the helper +may do so. AppImage, Snap, and Flatpak retain dynamically linked, replaceable +runtime libraries and ship the corresponding source/build metadata. Their +native directory also contains hash-validated `embedded-mpv-notices.json`, +`THIRD_PARTY_NOTICES.txt`, and `licenses//**`. DEB, RPM, Pacman, and +marker-only packages intentionally contain neither a private `native/lib` +directory nor the bundled-runtime legal payload. + +The build-time `electron-backend/native` tree is excluded from `app.asar`. +`afterPack` is the only owner of +`resources/app.asar.unpacked/electron-backend/native`, so each profile receives +exactly its normalized payload and ARM packages cannot retain a hidden x64 +helper, runtime, manifest, or notice copy in the archive. Both unpacked-layout +and final Linux artifact verification enumerate `app.asar` and fail if any +entry remains below `/electron-backend/native/`. + +The pristine `afterPack` and unpacked-layout checks recursively inspect +Electron-owned shared libraries. An extracted Snap has already overlaid its +package-manager `lib/**` and `usr/lib/**` runtime trees onto that same payload +root, so the post-target verifier excludes exactly those two target-provided +trees while continuing to scan every other directory recursively. Electron +libraries are still required to be regular files and free of libmpv linkage; +Snap runtime symlinks are outside that ownership boundary. + +ARM Linux packages remain marker-only. They never borrow x64 native artifacts, +even when build environment variables claim a matching staged architecture. +Consequently frame-copy is not advertised there, and the normal inline/external +players remain available. On x64, any missing or unusable frame-copy dependency +falls back to native-view without crashing; if native-view also lacks X11 or a +system `mpv` executable, Embedded MPV is reported unavailable with a stable +diagnostic reason. The flow is: @@ -117,7 +173,16 @@ The renderer never gets direct native-module access. It can only call the preloa - dispose session - subscribe to session updates -Settings uses the preload support API as an availability and capability check. Unsupported paths return before loading the addon when platform, experiment gating, addon presence, bundled runtime presence, or the Linux `mpv` executable check fails. Supported paths load `embedded_mpv.node` so the renderer can receive capability flags from the actual addon binary. Avoid calling this support API from global workspace startup paths; use an explicit user action or idle preparation path when a renderer surface only needs to reveal optional Embedded MPV UI. +Settings uses the preload support API as an availability and capability check. +Unsupported paths return before loading the addon when platform, experiment +gating, native artifacts, the packaged runtime manifest, the Linux helper +probe, or the native-view `mpv` executable check fails. Support diagnostics +include a stable `frameCopyUnavailableReason`; it is tracing/support data, not +user-facing copy. Supported paths load `embedded_mpv.node` so the renderer can +receive capability flags from the actual addon binary. Avoid calling this +support API from global workspace startup paths; use an explicit user action +or idle preparation path when a renderer surface only needs to reveal optional +Embedded MPV UI. When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds. @@ -194,19 +259,126 @@ service and the adapter): Enabling it: the `Settings > Playback > Embedded MPV: frame-copy engine` checkbox (shown only when support reports `frameCopyAvailable`) persists to -the main-process config store (`electron-conf`), which `main.ts` reads -before creating the window and translates into the env flag; an explicitly -set env var (including `0`) wins over the stored preference, but cannot bypass -the platform/runtime safety gate. Frame-copy can relax the window sandbox only +the main-process config store (`electron-conf`), which `main.ts` reads before +creating the window and translates into the env flag; an explicitly set env +var (including `0`) wins over the stored preference, but cannot bypass the +platform/runtime safety gate. Frame-copy can relax the window sandbox only when embedded MPV itself is enabled for the current run (packaged app or the -regular development experiment flag) and discovery finds both an executable -(`X_OK`) helper and a readable regular frame-reader addon in the same native -directory. Packaged discovery is limited to packaged resource locations and -never falls through to writable cwd/dist development paths. A disabled base -experiment keeps the renderer sandbox enabled and embedded MPV unavailable. -When the base feature is enabled, a missing, mode-stripped, or incomplete -frame-copy runtime keeps the sandbox enabled and falls back to the native -engine. +regular development experiment flag) and one process-wide capability decision +has succeeded. On Linux x64 that decision validates the profile manifest, +regular-file/access modes, the complete declared bundled closure and hashes, +then runs `iptvnator_mpv_helper --runtime-probe` with a three-second timeout. +The probe loads dependencies through the normal ELF loader, initializes an +idle libmpv client, creates EGL/OpenGL plus mpv render contexts, then +creates, maps, validates, and destroys a minimal `16x16` shared-memory ring +named `/impv-fc-runtime-probe-`. It never opens media or enters the media +or command loops. Shared-memory creation/mapping and header-initialization +failures emit the stable helper reasons `shared-memory-create-failed` and +`shared-memory-initialize-failed`. The probe must emit exactly one protocol-v1 +JSON line and return zero. When the helper exits nonzero with an otherwise +exact failure line, the application availability diagnostic keeps the +fail-closed top-level reason `helper-probe-failed` and may add only the +allowlisted helper reason as `helperReason`. An optional `helperDetail` is +copied only from the same exact line when it contains 1–1024 printable ASCII +characters; an invalid detail rejects both helper fields. Malformed, +multi-line, wrong-protocol, or unknown failure output never reaches either +field. Every probe uses the same explicit 16 MiB aggregate captured-output +ceiling, independent of tracing, so verbose diagnostics do not fall back to +Node's smaller implicit buffer. When `IPTVNATOR_TRACE_PLAYER=1`, the probe also +emits non-empty captured helper stderr separately as one JSON line. JSON +escaping keeps embedded newlines on that single line, the `stderr` field is +limited to the first 16,384 characters, and the `truncated` boolean is always +present. A missing flag, empty capture, or trace-writer failure produces no +trace and never changes the cached availability result or the application +diagnostic's stdout protocol. + +The startup probe and every playback helper session use the same sanitized +loader environment selected by the validated manifest's cached `runtimeMode`. +Both remove ambient ELF audit/preload/origin/library overrides, direct +EGL/GBM/GL/VA/Vulkan driver and layer paths, shell startup/options, tracing +hooks, and exported Bash functions. The extracted-artifact verifier applies +the same deny-set before its direct helper smoke, while preserving +feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. The system profile +then uses the default system loader without a private path. Bundled profiles +put the validated packaged `native/lib` first. AppImage and Flatpak resolve the +declared external graphics/audio interfaces through their normal host or +sandbox loader. +For the exact `com.fourgray.iptvnator` Flatpak payload under `/app`, the helper +reconstructs Freedesktop Platform 24.08's immutable +`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS` value: +`/etc/egl/egl_external_platform.d:/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d:/usr/share/egl/egl_external_platform.d`. +All ambient EGL/GBM/GL/VA/Vulkan path overrides remain removed. Freedesktop's +GL extension `add-ld-path` is supplied through the sandbox loader cache, so no +ambient `LD_LIBRARY_PATH` is needed. Flatpak CI runs the application-level +`--embedded-mpv-runtime-probe`; direct helper execution is only a package +layout check and cannot substitute for the real gate. +The installed-Snap smoke enables `IPTVNATOR_TRACE_PLAYER=1`, +`EGL_LOG_LEVEL=debug`, and `LIBGL_DEBUG=verbose`, so GLVND/Mesa loader failures +remain observable through the bounded stderr record while the same hostile +ambient-path assertions and fail-closed application gate stay active. +Inside a genuine Snap mount, filtered absolute `SNAP_LIBRARY_PATH` entries below +`/var/lib/snapd/lib/gl` follow `native/lib`. The exact +`$SNAP/graphics/usr/lib/x86_64-linux-gnu` content-provider roots come next, +followed by the core22 base `/usr/lib/x86_64-linux-gnu`, then the GNOME +platform's fixed x64 library, Mesa, DRI, and PulseAudio roots only when +`SNAP_DESKTOP_RUNTIME` resolves exactly to `$SNAP/gnome-platform`; generic +`$SNAP` roots remain last. Core22 must precede that older desktop content +runtime so its compatible `libedit.so.2` wins instead of the GNOME copy that +requires unavailable `libtinfo.so.5`. The helper also rebuilds the GBM, GL/VA +driver, EGL vendor/platform, and Vulkan layer variables from those trusted +roots. Caller-provided triplets, graphics-driver paths, and out-of-root loader +entries are ignored. +Both the bounded probe and playback execute the helper through +`$SNAP/graphics/bin/graphics-core22-provider-wrapper`. Before either launch, +the app requires the mounted graphics root to be a real directory and the +wrapper to be a regular, non-symlinked, readable executable. A missing or +disconnected provider therefore reports the stable +`snap-graphics-provider-unavailable` reason instead of attempting a partial +loader setup. Because that provider wrapper is a non-interactive Bash script, +the child environment also removes shell startup/options, exported +`BASH_FUNC_*` functions, and tracing hooks, and fixes `PATH` to core22 system +directories. This prevents ambient shell configuration from replacing the +probe or its `dirname` lookup before the helper executes. + +The Snap is `base: core22` with strict confinement. It keeps Electron Builder's +default plugs and adds an auto-connected private `shared-memory` plug plus the +`graphics-core22` content plug targeting a real empty mode-0755 +`$SNAP/graphics`, with `mesa-core22` as default provider. `mesa-core22` +supplies the shared +EGL/GL/GLX/GBM/DRM/VA userspace; the existing GNOME content runtime supplies +ALSA/PulseAudio. These providers are external shared snaps, so their binaries, +source, notices, and installed bytes are not part of the IPTVnator Snap or its +compliance archive. CI installs and explicitly connects both providers for a +locally installed `--dangerous` artifact, then runs the application-level +probe under strict confinement. + +The package carries the empty content target itself because core22 does not +create `$SNAP` content targets while packing. Metadata verification rejects a +missing, non-directory, symlinked, non-empty, or incorrectly permissioned +target. It also requires exactly the canonical provider-data layouts: +`/usr/share/libdrm` binds from `$SNAP/graphics/libdrm`, and +`/usr/share/drirc.d` symlinks to `$SNAP/graphics/drirc.d`. No additional or +duplicate layout entry is accepted. + +Private shared memory gives the app a confined, snap-specific POSIX shm +namespace rather than global cross-snap access. The packaging-only +`--embedded-mpv-runtime-probe` application switch invokes the same complete +manifest, mode, hash, linkage, environment, and bounded helper probe used at +startup before any BrowserWindow is created. It writes exactly one availability +JSON line and exits zero only when frame-copy is usable. Consequently the +installed-Snap smoke validates the actual confinement and shared-memory +lifecycle required by playback, not only direct helper execution. The smoke +first disconnects `graphics-core22` and requires the application diagnostic to +emit `usable:false` with reason `snap-graphics-provider-unavailable` and +controlled exit code `1`; it then reconnects the provider and requires the +same diagnostic to succeed. +Packaged addon, frame-reader, and helper discovery is limited to package-owned +`app.asar.unpacked` resource locations and never falls through to writable +cwd/dist development paths. Those fallbacks are development-only. A disabled +base experiment or any failed capability check keeps the renderer sandbox +enabled and falls back to the native engine. The result is cached by +helper/manifest identity for the process lifetime, so the startup and service +gates cannot disagree. Changing the toggle requires an app restart because web preferences are fixed at window creation. @@ -232,14 +404,12 @@ the executable resolves it from its own directory. The after-pack hook restores the POSIX helper's executable mode after the asset copy, and optional/skipped native rebuilds remove stale helper/reader artifacts before reporting frame-copy availability. This cleanup prevents known leftover build -output; it is not a compatibility check for a complete but version-mismatched -runtime pair. Linux packages deliberately do NOT ship the helper yet: it links -the build host's system `libmpv`, which packaged apps cannot assume is -installed, so centralized after-pack preparation strips both possible helper -basenames and package validation rejects either one if it survives. The -support probe therefore reports frame-copy unavailable in Linux packages. -The engine is dev-build-only on Linux until bundled-libmpv runtime staging -lands (PORTING.md milestone 4). +output; the manifest and runtime probe are the compatibility check for a +complete Linux runtime. Linux x64 packages retain the helper and frame reader: +system packages resolve the declared `libmpv.so.2` through their package +manager, while portable and sandboxed profiles resolve the source-built closure +through `$ORIGIN/lib`. Foreign-architecture packages remove all native +artifacts and contain only the unavailable marker. Trade-offs and constraints: @@ -251,12 +421,12 @@ Trade-offs and constraints: MessagePort (costs one extra copy + GC churn since Electron ports clone ArrayBuffers) or a WebCodecs-based path. - Scope: on macOS Apple Silicon only by owner decision (2026-07-10); - Intel Macs keep the native-view engine. Linux (any arch) is ported — + Intel Macs keep the native-view engine. Official Linux frame-copy is x64 — headless EGL, works under native Wayland since nothing embeds into a - window; dev builds need `libmpv-dev`, `libegl-dev`, `libgl-dev`, - `libopengl-dev` and `libgbm-dev` (the helper links system libmpv, which - is legal out-of-process — the in-process libmpv ban still binds the - addon). The helper logs the chosen EGL display tier and the GL renderer + window; local system builds need `libmpv-dev`, `libegl-dev`, `libgl-dev`, + and `libgbm-dev`. The helper links libmpv, which is legal + out-of-process; the in-process-libmpv ban still binds the addon and frame + reader. The helper logs the chosen EGL display tier and the GL renderer string to stderr. If an early tier selects Mesa software rendering (for example, while a proprietary NVIDIA driver is reachable through the default display or GBM), it probes the remaining tiers and uses software only when @@ -491,39 +661,82 @@ The Electron main process holds an `electron.powerSaveBlocker` of type `prevent- Current development behavior: - The addon build supports `darwin`, `win32`, and `linux`; Windows and Linux builds require running on that target OS. -- The build script first looks for staged inputs at `vendor/embedded-mpv/-/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries; `LIBMPV_INCLUDE_DIR` and `LINUX_NATIVE_LIBRARY_DIR` override the default system paths. -- When the staged-input path is used, it must contain `include/mpv/client.h` and `runtime-manifest.json`. macOS and Windows staging also contains the platform runtime files that are bundled into the app. +- The build script first looks for staged inputs at `vendor/embedded-mpv/-/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries. `LIBMPV_INCLUDE_DIR` overrides the header root. `LINUX_NATIVE_LIBRARY_DIR` is a link-time override and must name a directory already visible to the system dynamic loader; it is never inherited as helper `LD_LIBRARY_PATH`. +- When the staged-input path is used, it must contain `include/mpv/client.h`, + `runtime-manifest.json`, and the platform runtime/build files. The Linux + source builder also stages the complete declared `.so` closure. - The compiled `.node` addon is copied into `dist/apps/electron-backend/native/embedded_mpv.node`. -- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/` for macOS and Windows. macOS copies `.dylib` and non-`.dylib` Mach-O dependencies; Windows copies the staged `mpv-2.dll`/`libmpv-2.dll`/`mpv.dll`/`libmpv.dll` runtime name plus import libraries. Linux writes an `external-mpv-process` manifest and intentionally leaves `libmpv.so` out of the package. -- Linux does not bundle or load `libmpv` in the Electron process. The addon can compile against staged or system-development MPV headers. Its native engine still depends on an X11/Xwayland window handle plus an `mpv` executable on `PATH`; the dev-only frame-copy helper is a separate process linked to system `libmpv` and renders through headless EGL, so it bypasses those native-engine prerequisites. +- Bundled runtime files are copied into + `dist/apps/electron-backend/native/lib/`. macOS copies `.dylib` and + non-`.dylib` Mach-O dependencies; Windows copies the staged + `mpv-2.dll`/`libmpv-2.dll`/`mpv.dll`/`libmpv.dll` runtime name plus import + libraries. Linux source-runtime builds copy only the manifest-declared + closure. +- Linux never bundles or loads libmpv in the Electron process. The native-view + addon remains X11/process-only; the inverse rule applies to frame-copy: + `iptvnator_mpv_helper` must link exactly the declared `libmpv.so.2`, while + the addon and frame reader must not. - `afterPack` copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` on macOS, Windows, and Linux so the addon, manifest, and runtime libraries are filesystem-addressable. +- Electron Builder excludes `electron-backend/native{,/**/*}` from `app.asar`; + package verification rejects any archived native entry so `afterPack` + remains the single profile-aware owner. -Current release caveat: +Linux release profiles: -- Release packaging requires a `vendored-lgpl` runtime manifest on macOS and Windows, and an `external-mpv-process` manifest on Linux. -- The Linux addon is built once per CI host architecture (x64). Linux packages for other architectures (arm64, armv7l) must not ship that foreign addon: `afterPack` replaces the native directory with an `embedded-mpv-unavailable.txt` marker explaining that embedded MPV is not bundled for that architecture, and package-layout verification rejects a foreign-architecture `embedded_mpv.node` while requiring the marker. +- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=system` builds DEB, RPM, and Pacman. + `afterPack` removes the private `lib` directory, writes a + `system-libmpv-frame-copy` manifest, and package metadata requires the exact + libmpv plus EGL/GL/GBM package set listed above. The DEB path is verified + on Ubuntu 24.04+; Ubuntu 22.04 users need the x64 AppImage because Jammy only + provides `libmpv1`. +- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=portable` builds AppImage and Snap with + the pinned source-built closure and a `bundled-lgpl-frame-copy` manifest. +- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=flatpak` builds Flatpak with the same + source-built closure and manifest origin. Its app-level probe reconstructs + only the exact Freedesktop 24.08 EGL external-platform search path inside the + trusted `/app` payload. +- The three profiles are separate packaging passes; mixing target sets fails + closed. Linux packages for other architectures (arm64, armv7l) must not ship + x64 native artifacts. `afterPack` replaces the native directory with + `embedded-mpv-unavailable.txt`, and package verification requires that + marker. +- Every packaged manifest names its exact artifacts, profile/targets, libmpv + SONAME, loader closure, byte sizes, SHA-256 hashes, package dependencies, and + native-view fallback. Artifact modes and ELF dependency isolation are + verified after packaging. - macOS release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`. -- Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present, no bundled `libmpv.so` files slipped into the package, and no development-only frame-copy helper survived `afterPack`. +- Windows release packaging verifies that the platform runtime file is present + when Embedded MPV is required. - Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin. -Before public release, packaging must: +Release packaging must: -- stage an LGPL-compatible `libmpv` runtime for each macOS/Windows release platform/architecture, and stage Linux MPV headers/build metadata for Linux +- stage an LGPL-compatible libmpv runtime for each bundled release + platform/architecture, including the pinned Linux x64 source runtime - collect indirect macOS dependencies expressed as absolute paths, `@loader_path`, or `@rpath` - rewrite macOS install names and dependency paths to app-relative paths such as `@loader_path` - code-sign and notarize the full macOS dependency set - ensure Windows runtime staging includes both the DLL and the import library used by `node-gyp` -- ensure Linux native builds do not gain a direct `libmpv` dependency; the runtime playback path is `mpv --wid` in a separate process -- publish the corresponding FFmpeg/libmpv source and build metadata for bundled macOS/Windows runtimes; Linux should document the distribution package versions used as build inputs +- ensure Linux Electron/addon/reader binaries do not gain a direct libmpv + dependency and the helper does +- execute the helper capability probe in each intended x64 package environment +- publish corresponding source archives, git/submodule records, checksums, + exact flags, local patches, and build scripts for every bundled runtime -Users on macOS and Windows do not need the MPV GUI application for this architecture. Linux currently requires an `mpv` executable because the supported backend is process-isolated. If the native addon/runtime prerequisites or Linux `mpv` executable are missing, embedded MPV is hidden/unsupported and the existing inline/external players remain available. +Users on macOS and Windows do not need the MPV GUI application for this +architecture. Linux native-view still requires an `mpv` executable. Linux +frame-copy system packages need their declared libmpv and EGL/GL/GBM +packages, while AppImage, Snap, and Flatpak carry their own runtime closure. +If frame-copy prerequisites are missing, x64 falls back to native-view; if all +Embedded MPV prerequisites are unavailable, the existing inline/external +players remain available. ## Runtime Staging Runtime staging tooling lives in: -- `/Users/4gray/Code/iptvnator/tools/embedded-mpv/` -- `/Users/4gray/Code/iptvnator/vendor/embedded-mpv/` +- `tools/embedded-mpv/` +- `vendor/embedded-mpv/` Release runtime policy: @@ -547,11 +760,83 @@ pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix ``` -During temporary PR and `master` artifact testing, CI can restore an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/-/` runtime and skip the expensive source build or archive staging path where one exists. The cache only contains `include/`, `lib/`, and `runtime-manifest.json`; it never contains the compiled `embedded_mpv.node` addon because that target depends on Electron headers, ABI, architecture, and build environment. Runtime cache entries are saved only from trusted repository refs, and tagged public macOS release builds continue to rebuild from pinned sources until a dedicated signed and attested runtime artifact flow exists. Windows CI uses a checksum-pinned `win32-x64` runtime archive configured through `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL` and `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256` repository variables or secrets on cache miss. Non-tag artifact builds have a pinned `zhongfly/mpv-winbuild` `mpv-dev-lgpl-x86_64` fallback so PR builds can produce a Windows embedded MPV artifact before repository variables are configured; tagged releases still require explicit repository configuration. The Windows archive helper accepts normal `lib/` + `bin/` prefixes and common `mpv-dev-lgpl` flat archives, including `libmpv-2.dll` names, and preserves the DLL basename expected by the import library; when the archive does not include `runtime-manifest.json`, it generates a minimal manifest from the archive URL/path and checksum. Linux stages Ubuntu package build inputs only; adding pinned source builders for Windows and Linux remains a separate release-hardening task. +Linux x64 builds the release runtime from pinned source inputs and stages it +before compiling the helper: -The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes macOS/Windows manifests to `origin: vendored-lgpl`, which release package validation requires on those platforms. +```bash +pnpm embedded-mpv:build-runtime:linux -- /tmp/embedded-mpv-linux-prefix +pnpm embedded-mpv:stage-runtime -- linux x64 /tmp/embedded-mpv-linux-prefix +``` -The Electron backend build consumes the staged runtime/build inputs and copies macOS/Windows runtime files into the native build output. Linux consumes staged MPV headers when available or distribution development headers for local builds, writes an `external-mpv-process` manifest, and does not copy `libmpv.so` into the package. macOS additionally rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later. +The Linux builder is intentionally host-restricted to Linux x64. It checks +minimum build-tool versions, uses an owned staging directory plus atomic +publish, rejects host pkg-config/runtime leakage, rewrites every bundled +library to an `$ORIGIN` RUNPATH, and enforces the portable ABI ceilings +`GLIBC_2.35` and `GLIBCXX_3.4.30`. It also verifies the exact libmpv SONAME, +complete dependency closure, and absence of build-prefix paths. + +CI may restore exact-keyed caches for staged +`vendor/embedded-mpv/-/` runtimes. The Linux cache contains +only generated headers, libraries, the runtime manifest, and immutable source +inputs: exact archives, a clean recursive libplacebo checkout, and collected +license inputs. It never contains `embedded_mpv.node`, generated notices, or +the finished source-compliance archive. Runtime cache entries are saved only +from trusted repository refs. The Linux cache key covers the builder/stager, +notice generator, pinned sources, and toolchain. On every run, including a +cache hit, CI validates the cached hashes and clean checkout, regenerates the +notices for the current runtime manifest, converts libplacebo into a +VCS-metadata-free working-tree snapshot while retaining the validated +commit/submodule record, and creates +`linux-frame-copy-runtime-sources.tar.xz` for the current repository revision +and binary diff with normalized tar metadata. + +The workflow keeps the macOS/Windows package matrix independent from the Linux +runtime prerequisite. Only the three Linux profile jobs depend on the runtime +builder; both matrices reuse one YAML-anchored step list to prevent packaging +logic drift. Draft release assembly still requires both matrices, so a public +release cannot silently omit a promised platform. + +Windows CI uses a checksum-pinned `win32-x64` runtime archive configured +through `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL` and +`IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256`. Non-tag artifact builds have +a pinned `zhongfly/mpv-winbuild` `mpv-dev-lgpl-x86_64` fallback; tagged +releases require explicit repository configuration. Upstream retains only its +latest 30 daily builds, so the fallback and any repository-variable copy must +be refreshed as one URL/checksum pair before expiry. A long-lived mirror must +publish the matching source/build records and license notices with the binary. +The archive helper accepts normal `lib/` + `bin/` prefixes and common flat +archives, preserves the DLL basename encoded by the import library, and +generates minimal build metadata only when the archive lacks it. + +The Linux builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, +libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, HarfBuzz `8.5.0`, +Expat `2.8.2`, Fontconfig `2.16.0`, OpenSSL `3.5.7`, hwdata `0.409`, and +libdisplay-info `0.1.1`. FFmpeg disables autodetected external libraries. +Libplacebo is checked out at an exact git commit with all required submodules. +The hwdata archive and its `pnp.ids` build input are pinned so +libdisplay-info cannot silently consume `/usr/share/hwdata` from the builder. +The generated manifest records source URLs/checksums or git commits, +submodules, licenses, exact flags, build-host/toolchain data, runtime hashes, +and the dynamic closure. FFmpeg/mpv remain LGPL-compatible and dynamically +linked; codecs outside that build configuration are not implied. + +`generate-linux-runtime-notices.cjs` collects the exact upstream license files +for every pinned package and all recursive libplacebo submodules. Generation +is fail-closed for a missing, undeclared, symlinked, size-mismatched, or +hash-mismatched file. Portable and Flatpak package hooks copy only the +validated notice manifest, aggregate notice, and per-package license tree; +package-layout verification revalidates that legal payload against the +embedded source-runtime manifest. + +The Electron backend build consumes the staged runtime/build inputs. On Linux +it links the helper against the verified staged `libmpv.so.2`, never against a +generic host `-lmpv`, then verifies the helper's `DT_NEEDED` and +`$ORIGIN/lib` RUNPATH with `readelf`. The addon and frame reader are checked +for the opposite invariant. The profile-aware packaging hook later retains or +removes the private closure. Local Linux builds may still use distribution +headers/libraries, but a required package build must use the staged manifest. +macOS additionally rewrites Mach-O paths and re-signs modified local binaries; +release signing/notarization still happens later. For local development before the vendored runtime exists, Homebrew can be used explicitly: @@ -593,7 +878,88 @@ For tagged macOS builds, CI must: For Windows builds, CI must restore the `win32-x64` staged runtime cache or stage the checksum-pinned runtime archive before `pnpm run build:backend`. The Windows job must set `IPTVNATOR_EMBEDDED_MPV_PLATFORM=win32`, `IPTVNATOR_EMBEDDED_MPV_ARCH=x64`, and `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for backend build, package make, and package-layout verification. CI narrows `electron-builder.json` to x64 Windows targets while only a `win32-x64` runtime is available. The Windows job is pinned to `windows-2022` until the Electron `node-gyp` toolchain can identify Visual Studio 18 from `windows-latest`. -For Linux builds, CI must set `IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux`, `IPTVNATOR_EMBEDDED_MPV_ARCH=x64`, and `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` after staging the Ubuntu package build inputs. Linux package verification checks the `external-mpv-process` manifest and confirms that no bundled `libmpv.so` files are present. +For Linux builds, CI first builds or restores the pinned x64 source runtime and +stages it under `vendor/embedded-mpv/linux-x64`. It then runs three isolated +packaging passes with `IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux`, +`IPTVNATOR_EMBEDDED_MPV_ARCH=x64`, +`IPTVNATOR_REQUIRE_EMBEDDED_MPV=1`, and one exact +`IPTVNATOR_LINUX_FRAME_COPY_PROFILE`. Each produced artifact is extracted and +verified, and the x64 helper probe runs in the intended runtime environment. +The packaged x64 Playwright smoke first runs its fixture-contract target and +passes Chromium `--ignore-gpu-blocklist` so Mesa llvmpipe can expose WebGL2 in +CI. That launch-only flag does not bypass any manifest, hash, loader, or helper +capability check; `--no-sandbox` is added only when the runner is root. +Bundled package layouts must include the generated notices and exact license +tree. The separately uploaded +`linux-frame-copy-runtime-sources.tar.xz` contains the exact archive set, +the VCS-metadata-free libplacebo working tree plus the exact pinned commit and +six recursive submodule records, notice/license inputs, runtime metadata, +current revision/diff, and build tooling. Each submodule record is canonical +`full-commit safe/path`; clone-depth-dependent `git describe` annotations are +discarded. The source index also records a +globally sorted exact inventory +of every libplacebo directory, regular file, and symlink. File hashes, sizes, +normalized executable bits, link targets, aggregate counts/bytes, and the +canonical inventory digest must match the trusted pinned v7.360.1 checkout; +an arbitrary or incomplete self-declared tree is rejected. Its tar metadata is +normalized, its member/type layout is exact, and +`metadata/archive-sha256.txt` must describe the actual source archive bytes. +Tar listing continues past every end marker, so concatenated xz/tar streams +cannot hide undeclared members. ARM artifacts are independently verified as +marker-only and never run the x64 helper. + +After constructing the final +`linux-frame-copy-runtime-sources.tar.xz`, CI hashes its exact bytes and stages +`source-archive-binding.json` beside the x64 runtime. AppImage, Snap, and +Flatpak manifests copy that binding unchanged as `sourceArchive`, including the +SHA-256 and repository revision. System-package manifests and marker-only +non-x64 packages must not carry it, so a portable package cannot advertise +source correspondence inherited from another profile or architecture. + +The build workflow creates a draft GitHub release but never publishes Snap in +parallel with that draft. The separate Snap workflow runs only for a public +`release.published` event whose tag starts with `v`; before any Store upload it +requires at least one exact `.snap` asset and exactly one non-empty +`linux-frame-copy-runtime-sources.tar.xz` in that public release. It hashes and +safely inspects the bounded downloaded archive, requires regular metadata, +archive, legal, and tooling member/type set, validates link targets and the +archive checksum metadata, and requires the clean checkout and source index to +match the released tag. It verifies the actual pinned source-member hashes, +the six recursive libplacebo submodule records, license-input and notice +hashes, the exact VCS-free libplacebo tree inventory/digest, and byte-identical +tooling from the released tag. Checkout and both artifact-transfer actions use +full pinned commits, and checkout sets `persist-credentials: false`. +The bounded SquashFS preflight and extraction then require the canonical +`/usr/lib/iptvnator` layout and reuse the static package validator for every +selected Snap. The public-release verifier separately reapplies the exact +strict `meta/snap.yaml` graphics/shared-memory/layout contract and enumerates +the extracted `resources/app.asar`; any archived +`electron-backend/native/**` entry fails before Store publication. The bounded +ASAR header reader uses only Node built-ins plus released local tooling, keeping +this check runnable from the clean tag checkout without `node_modules`. Exactly +one x64 Snap must contain a bundled portable manifest +whose exact `sourceArchive` and `sourceRuntime` match the archive; non-x64 +Snaps must remain marker-only. Repository credentials are scoped to the two +GitHub asset steps. The secretless verification job copies downloaded files +through no-follow descriptors into a private snapshot, hashes them before and +after inspection, writes an exact receipt, root-seals the snapshot, and reruns +the complete source/package verifier against those bytes. It then transfers +only the sealed data through the pinned artifact service, publishes the exact +receipt digest separately as a job output, and terminates. + +The dependent publish job runs on a bounded GitHub-hosted `ubuntu-latest` +runner with no checkout or release-tag code. It requires the separately +transmitted receipt digest, validates the exact receipt schema and every asset +size/hash, accepts only the expected regular `.snap`, source archive, and +receipt layout, rejects links and extra entries, and root-seals the transferred +files again before installing the official stable Snapcraft snap. +Only its final fixed shell step receives the Store credential. That step uses a +bounded Bash glob, resolves no PATH command, executes no released code, and +passes the credential only to each exact +`/snap/bin/snapcraft upload --release=edge` process. Any verification or +transfer mismatch aborts before Store credentials are available. +Candidate/stable promotion is manual after installed-Snap frame-copy and +missing-runtime fallback smoke; GitHub Actions never promotes automatically. During temporary artifact tests, CI may also set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for PR and `master` push jobs where a runtime is known to exist. After the artifacts are manually validated, remove temporary conditions so ordinary development builds leave `IPTVNATOR_REQUIRE_EMBEDDED_MPV` unset or `0`. This keeps the native feature in-tree without making every non-release build depend on runtime artifacts. @@ -605,7 +971,8 @@ The feature is still experimental. The largest risks are native-process risks, n - packaging can fail if `libmpv` or one of its platform runtime dependencies is missing, unsigned where signing applies, or linked to the wrong runtime path - macOS graphics behavior can vary across Intel, Apple Silicon, external displays, fullscreen transitions, and hardware decoding paths - Windows `HWND` and Linux X11/Xwayland embedding need packaged-app smoke coverage for focus, resize, and fullscreen behavior -- Linux native Wayland is unsupported until a dedicated Wayland embedding path exists +- Linux native-view remains unsupported on native Wayland; frame-copy has no + window-embedding dependency but still requires a working EGL probe - Homebrew `libmpv` builds can target a newer macOS version than IPTVnator's declared deployment target It is reasonable to ship the code in-tree behind the current experiment flag. It is not yet safe to make it the default player. It can be exposed as desktop experimental if support detection is strict, the UI clearly labels it experimental, and fallback to Video.js or external MPV/VLC stays available. @@ -616,8 +983,18 @@ If an embedded session fails to initialize, the app should keep the user in cont Do not expose embedded MPV broadly until these pass on every supported target: -- macOS/Windows packaged app starts without system `mpv` installed; Linux reports Embedded MPV unsupported with a clear message when system `mpv` is missing -- bundled `libmpv` and dependent runtime files pass macOS/Windows package validation; Linux package validation confirms the external-process manifest and absence of bundled `libmpv.so` +- macOS/Windows packaged apps start without system `mpv`; Linux x64 + frame-copy starts in each declared package profile, and a missing frame-copy + dependency falls back without crashing +- bundled libmpv and dependent runtime files pass package validation; + DEB/RPM/Pacman contain no private closure and declare the exact system + dependency +- Electron, its shipped libraries, `embedded_mpv.node`, and the frame reader + have no direct libmpv `DT_NEEDED`; the helper resolves the exact declared + libmpv runtime +- AppImage, DEB, RPM, Pacman, Snap, and Flatpak payloads pass extraction, + manifest/mode/ELF checks and the applicable helper probe; ARM payloads are + marker-only - macOS bundled `libmpv` and dependent dylibs pass code signing and notarization - VOD resume starts near the saved offset - series EOF emits `ended` and embedded MPV auto-continues only inside the current season diff --git a/docs/superpowers/plans/2026-07-17-linux-embedded-mpv-frame-copy-packaging.md b/docs/superpowers/plans/2026-07-17-linux-embedded-mpv-frame-copy-packaging.md new file mode 100644 index 000000000..a090863f3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-linux-embedded-mpv-frame-copy-packaging.md @@ -0,0 +1,658 @@ +# Linux Embedded MPV Frame-Copy Packaging Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a verified Linux x64 frame-copy runtime in AppImage, DEB, RPM, Pacman, Snap, and Flatpak while preserving out-of-process libmpv isolation and honest native-view fallback. + +**Architecture:** Split official Linux packaging into system-runtime and bundled-runtime passes because Electron Builder reuses one unpacked layout per pass. A versioned manifest plus a real helper `--runtime-probe` gates frame-copy before BrowserWindow creation; only the helper may link libmpv, and foreign-architecture packages retain the unavailable marker. + +**Tech Stack:** Electron 41, Node/TypeScript, C++17/N-API, libmpv render API, EGL/OpenGL/GBM, ELF/RPATH tooling, electron-builder 26, Nx/Jest/node:test, Playwright, GitHub Actions, AppImage/DEB/RPM/Pacman/Snap/Flatpak. + +--- + +## File Map + +### Runtime contracts and staging + +- Create `tools/embedded-mpv/linux-runtime-manifest.cjs` + - Parse, normalize, and validate Linux frame-copy runtime manifests. +- Create `tools/embedded-mpv/build-linux-runtime.mjs` + - Build the pinned LGPL-compatible FFmpeg/libass/libplacebo/libmpv prefix. +- Modify `tools/embedded-mpv/stage-runtime.mjs` + - Stage Linux shared libraries and reject incomplete release manifests. +- Modify `apps/electron-backend/build-embedded-mpv.js` + - Build against staged Linux libmpv, copy the bundled closure, and emit the + profile-neutral build manifest. +- Modify `apps/electron-backend/native/binding.gyp` + - Keep helper RPATH relative and remove build-host RPATH. +- Modify `package.json` + - Expose the Linux runtime build command. + +### Packaging profiles and validation + +- Create `tools/packaging/linux-frame-copy-profile.cjs` + - Own profile names, target sets, manifest origins, and package dependencies. +- Create `tools/packaging/linux-frame-copy-profile.test.mjs` + - Verify profile/target/dependency mapping and invalid combinations. +- Modify `electron-builder.json` + - Declare DEB/RPM/Pacman libmpv dependencies. +- Modify `tools/packaging/embedded-mpv-frame-copy-files.cjs` + - Package Linux helper/reader and select/remove private runtime by profile. +- Modify `tools/packaging/embedded-mpv-packaging.cjs` + - Validate Linux ELF linkage, manifest, files, modes, RPATH, and isolation. +- Modify `tools/packaging/embedded-mpv-arch.test.mjs` + - Cover system, bundled, malformed, and foreign-architecture layouts. +- Modify `tools/packaging/electron-after-pack.cjs` + - Pass the required Linux profile into preparation and validation. +- Modify `tools/packaging/verify-electron-package-layout.mjs` + - Verify the expected profile for every unpacked layout. +- Modify `tools/packaging/project.json` + - Add new tests and source inputs. + +### Runtime capability probe + +- Modify `apps/electron-backend/native/helper/frame_helper_gl.h` + - Provide a context-only probe that does not create a playback session. +- Modify `apps/electron-backend/native/helper/mpv_frame_helper.cpp` + - Implement the versioned `--runtime-probe` JSON protocol. +- Create `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts` + - Validate manifest/files and run/cache the bounded helper probe. +- Create `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts` + - Cover all fail-closed paths and success caching. +- Modify `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts` + - Resolve an artifact set and delegate usability to the runtime probe. +- Modify `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts` + - Keep path/security coverage and add manifest/probe integration cases. +- Modify `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts` + - Surface stable fallback diagnostics without changing native-view safety. + +### Linux CI, package smoke, and documentation + +- Create `tools/packaging/verify-linux-frame-copy-runtime.mjs` + - Inspect real package payload ELF/modes/manifest and invoke the helper probe. +- Create `tools/packaging/verify-linux-frame-copy-runtime.test.mjs` + - Unit-test verifier parsing and failure reporting with fixtures. +- Create `apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts` + - Exercise packaged capability, a deterministic media frame, and fallback. +- Modify `.github/workflows/build-and-make.yaml` + - Build/cache runtime, split profiles, inspect every format, and run sandbox + and container smoke coverage. +- Modify `docs/architecture/embedded-mpv-native.md` +- Modify `tools/embedded-mpv/README.md` +- Modify `vendor/embedded-mpv/README.md` +- Modify `AGENTS.md` +- Modify `CLAUDE.md` + - Document the final contract and verification matrix. + +## Task 1: Define Linux Packaging Profiles + +**Files:** + +- Create: `tools/packaging/linux-frame-copy-profile.cjs` +- Create: `tools/packaging/linux-frame-copy-profile.test.mjs` +- Modify: `electron-builder.json` +- Modify: `tools/packaging/project.json` + +- [ ] **Step 1: Write failing profile tests** + +Cover the exact public API: + +```js +assert.deepEqual(resolveLinuxFrameCopyProfile('system'), { + name: 'system', + runtimeMode: 'system', + targets: ['deb', 'rpm', 'pacman'], + manifestOrigin: 'system-libmpv-frame-copy', +}); +assert.deepEqual(resolveLinuxFrameCopyProfile('portable').targets, [ + 'appimage', + 'snap', +]); +assert.deepEqual(resolveLinuxFrameCopyProfile('flatpak').targets, ['flatpak']); +assert.throws(() => resolveLinuxFrameCopyProfile('standard'), /Unsupported/); +assert.deepEqual(LINUX_SYSTEM_PACKAGE_DEPENDENCIES, { + deb: 'libmpv2', + rpm: 'mpv-libs', + pacman: 'mpv', +}); +assert.deepEqual(validateLinuxProfileTargets('system', ['deb', 'AppImage']), [ + 'Linux frame-copy profile "system" cannot build target "appimage".', +]); +``` + +- [ ] **Step 2: Run RED** + +```bash +node --test tools/packaging/linux-frame-copy-profile.test.mjs +``` + +Expected: FAIL because the profile module does not exist. + +- [ ] **Step 3: Implement the profile module and package dependencies** + +Export immutable `LINUX_FRAME_COPY_PROFILES`, +`LINUX_SYSTEM_PACKAGE_DEPENDENCIES`, `resolveLinuxFrameCopyProfile()`, and +`validateLinuxProfileTargets()`. Add `deb.depends += libmpv2`, +`rpm.depends += mpv-libs`, and `pacman.depends += mpv` without replacing +Electron Builder's existing defaults. + +- [ ] **Step 4: Register and run GREEN** + +Add the new test to `packaging:test`, then run: + +```bash +pnpm nx test packaging --skip-nx-cache +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add electron-builder.json tools/packaging +git commit -m "feat(packaging): define Linux frame-copy profiles" +``` + +## Task 2: Stage A Pinned LGPL Linux Runtime + +**Files:** + +- Create: `tools/embedded-mpv/linux-runtime-manifest.cjs` +- Create: `tools/embedded-mpv/linux-runtime-manifest.test.mjs` +- Create: `tools/embedded-mpv/build-linux-runtime.mjs` +- Modify: `tools/embedded-mpv/stage-runtime.mjs` +- Modify: `package.json` +- Modify: `tools/packaging/project.json` + +- [ ] **Step 1: Write failing manifest/staging tests** + +Use temporary prefixes to prove: + +```js +assert.equal(validateLinuxRuntimeManifest(validManifest).length, 0); +assert.match( + validateLinuxRuntimeManifest({ + ...validManifest, + ffmpeg: { configureFlags: ['--enable-gpl'] }, + })[0], + /--enable-gpl/ +); +assert.match( + validateLinuxRuntimeManifest({ + ...validManifest, + mpv: { mesonFlags: ['-Dgpl=true'] }, + })[0], + /-Dgpl=false/ +); +``` + +Exercise `stage-runtime.mjs linux x64 ` and assert it copies +`libmpv.so.2` plus all manifest-declared `.so` files and records byte sizes. + +- [ ] **Step 2: Run RED** + +```bash +node --test tools/embedded-mpv/linux-runtime-manifest.test.mjs +``` + +Expected: FAIL because the validator/build/staging contract is absent. + +- [ ] **Step 3: Implement the source builder** + +Reuse the pinned package versions already used by the macOS builder. Linux +FFmpeg flags must include: + +```text +--enable-shared --disable-static --disable-programs --disable-doc +--disable-debug --disable-autodetect --disable-gpl --disable-nonfree +--enable-pic --enable-pthreads +``` + +Linux mpv flags must include: + +```text +-Dgpl=false -Dlibmpv=true -Dcplayer=false -Dtests=false +-Dlua=disabled -Djavascript=disabled -Dcplugins=disabled +-Dlibarchive=disabled -Dlibbluray=disabled -Ddvdnav=disabled +-Dcdda=disabled -Ddvbin=disabled -Dvulkan=disabled +-Dplain-gl=enabled -Degl=enabled -Dgbm=enabled +``` + +Record downloaded SHA-256 values, git commits/submodules, exact flags, runtime +file names/sizes, and source-distribution obligations. Pin the hwdata v0.409 +archive and record its `pnp.ids` as a build input to libdisplay-info 0.1.1. +Stage private `hwdata.pc` metadata and run libdisplay-info's Meson setup with a +prefix-only pkg-config environment so the upstream +`/usr/share/hwdata/pnp.ids` fallback is unreachable. Include the exact hwdata +archive and its `GPL-2.0-or-later OR XFree86-1.0` notice in the release source +bundle. + +- [ ] **Step 4: Implement Linux staging** + +Require `include/mpv/client.h`, a versioned `libmpv.so.*`, and a valid manifest. +Copy only manifest-declared shared libraries and preserve SONAME symlinks as +materialized regular files so Electron Builder cannot lose them. + +- [ ] **Step 5: Run GREEN and static policy checks** + +```bash +pnpm nx test packaging --skip-nx-cache +node --check tools/embedded-mpv/build-linux-runtime.mjs +node --check tools/embedded-mpv/stage-runtime.mjs +``` + +Expected: PASS and no GPL/nonfree-enabling flag in generated policy fixtures. + +- [ ] **Step 6: Commit** + +```bash +git add package.json tools/embedded-mpv tools/packaging/project.json +git commit -m "feat(embedded-mpv): stage LGPL Linux runtime" +``` + +## Task 3: Build A Relocatable Isolated Helper + +**Files:** + +- Modify: `apps/electron-backend/native/binding.gyp` +- Modify: `apps/electron-backend/build-embedded-mpv.js` +- Test: `apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts` + +- [ ] **Step 1: Extend source-policy tests** + +Assert the Linux helper has `$ORIGIN/lib` and no absolute build-host RPATH, +the addon does not use `-lmpv`, the staged closure is copied, and the build +manifest identifies both allowed package modes. + +- [ ] **Step 2: Run RED** + +```bash +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns=embedded-mpv-native-source.spec +``` + +Expected: FAIL on the current absolute `LINUX_NATIVE_LIBRARY_DIR` RPATH and +`external-mpv-process`-only manifest. + +- [ ] **Step 3: Update native build integration** + +Link the helper against staged `libmpv.so`, retain only: + +```text +-Wl,--enable-new-dtags +-Wl,-rpath,$ORIGIN/lib +``` + +Copy the staged shared-library closure to build output for downstream bundled +profiles, but keep `embedded_mpv.node` dynamically independent of libmpv. +Write a build manifest that carries the runtime metadata without prematurely +choosing `system` versus `portable`. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns=embedded-mpv-native-source.spec +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/electron-backend/native/binding.gyp \ + apps/electron-backend/build-embedded-mpv.js \ + apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +git commit -m "feat(embedded-mpv): build relocatable Linux helper" +``` + +## Task 4: Package And Validate Each Runtime Mode + +**Files:** + +- Modify: `tools/packaging/embedded-mpv-frame-copy-files.cjs` +- Modify: `tools/packaging/embedded-mpv-packaging.cjs` +- Modify: `tools/packaging/embedded-mpv-arch.test.mjs` +- Modify: `tools/packaging/electron-after-pack.cjs` +- Modify: `tools/packaging/verify-electron-package-layout.mjs` + +- [ ] **Step 1: Write failing package-layout tests** + +Create realistic temp layouts and prove: + +- system mode requires executable helper, reader, system manifest, no + `native/lib/libmpv*`; +- bundled mode requires all manifest files and rejects missing/runtime-prefix + links; +- helper mode `0644` is rejected; +- reader symlinks/directories are rejected; +- Linux addon or Electron `DT_NEEDED libmpv` is rejected; +- helper without `DT_NEEDED libmpv.so.2` is rejected; +- foreign-arch layout contains only the unavailable marker. + +- [ ] **Step 2: Run RED** + +```bash +pnpm nx test packaging --skip-nx-cache +``` + +Expected: FAIL because Linux helpers are deleted and validation forbids them. + +- [ ] **Step 3: Implement profile-aware preparation** + +For x64: + +- restore helper mode `0755`; +- always retain the frame reader; +- `system`: remove private runtime and write normalized system manifest; +- `portable`/`flatpak`: retain only manifest-declared closure and write bundled + manifest; +- reject missing `IPTVNATOR_LINUX_FRAME_COPY_PROFILE` when Embedded MPV is + required. + +For foreign architectures, keep the current unavailable marker behavior and +remove every native artifact. + +- [ ] **Step 4: Implement ELF/package validation** + +Use `readelf -d` for `NEEDED` and RPATH/RUNPATH inspection. Resolve bundled +closure recursively from the private directory and permit only a documented +glibc/driver/system allowlist outside it. Keep validation host-aware: pure +manifest/mode checks run everywhere; ELF inspection is required on Linux CI. + +- [ ] **Step 5: Run GREEN** + +```bash +pnpm nx test packaging --skip-nx-cache +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns=embedded-mpv-native-source.spec +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add tools/packaging +git commit -m "feat(packaging): ship Linux frame-copy artifacts" +``` + +## Task 5: Add The Real Runtime Capability Probe + +**Files:** + +- Modify: `apps/electron-backend/native/helper/frame_helper_gl.h` +- Modify: `apps/electron-backend/native/helper/mpv_frame_helper.cpp` +- Create: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts` +- Create: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts` +- Modify: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts` +- Modify: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts` +- Modify: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts` + +- [ ] **Step 1: Write failing TypeScript probe tests** + +Inject filesystem and `spawnSync` collaborators. Cover: + +```ts +expect(probeRuntime(validArtifacts, successSpawn).usable).toBe(true); +expect(probeRuntime(validArtifacts, timeoutSpawn).reason).toBe( + 'helper-probe-timeout' +); +expect(probeRuntime(validArtifacts, nonzeroSpawn).reason).toBe( + 'helper-probe-failed' +); +expect(probeRuntime(validArtifacts, invalidJsonSpawn).reason).toBe( + 'helper-probe-invalid-output' +); +expect(probeRuntime(missingManifest, successSpawn).reason).toBe( + 'runtime-manifest-missing' +); +expect(successSpawn).toHaveBeenCalledTimes(1); +``` + +The last assertion calls the public probe twice and proves process-lifetime +caching. + +- [ ] **Step 2: Run RED** + +```bash +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns=embedded-mpv-frame-copy-runtime.spec +``` + +Expected: FAIL because the runtime probe module does not exist. + +- [ ] **Step 3: Implement helper `--runtime-probe`** + +Emit exactly one line: + +```json +{ "protocol": 1, "usable": true, "libmpv": "2.x", "renderApi": "egl" } +``` + +Exit nonzero with a JSON `reason` when `mpv_create`, `mpv_initialize`, or the +EGL/OpenGL context probe fails. Do not create shared memory, open a URL, or +enter the normal command loop. + +- [ ] **Step 4: Implement fail-closed main-process probing** + +Validate manifest/artifacts first, then run: + +```ts +spawnSync(helperPath, ['--runtime-probe'], { + encoding: 'utf8', + timeout: 3000, + windowsHide: true, + env: probeEnvironment, +}); +``` + +Cache by helper/manifest identity. Never throw across startup; return a stable +reason and make `isFrameCopyRuntimeUsable()` depend on `.usable`. + +- [ ] **Step 5: Run GREEN and related regression tests** + +```bash +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns='embedded-mpv-frame-copy-(runtime|platform).util.spec|app.spec|embedded-mpv-native.service.spec' +``` + +Expected: PASS; unavailable runtime keeps sandbox enabled and selects native. + +- [ ] **Step 6: Commit** + +```bash +git add apps/electron-backend/native/helper \ + apps/electron-backend/src/app/services +git commit -m "feat(embedded-mpv): probe Linux frame-copy runtime" +``` + +## Task 6: Split Linux CI And Inspect Every Artifact + +**Files:** + +- Create: `tools/packaging/verify-linux-frame-copy-runtime.mjs` +- Create: `tools/packaging/verify-linux-frame-copy-runtime.test.mjs` +- Modify: `.github/workflows/build-and-make.yaml` +- Modify: `tools/packaging/project.json` + +- [ ] **Step 1: Write failing verifier tests** + +Test payload discovery for `.AppImage`, `.deb`, `.rpm`, Pacman archive, +`.snap`, and `.flatpak`, plus clear errors for a missing helper, wrong mode, +wrong profile, direct addon libmpv linkage, and unresolved helper dependency. + +- [ ] **Step 2: Run RED** + +```bash +node --test tools/packaging/verify-linux-frame-copy-runtime.test.mjs +``` + +Expected: FAIL because the verifier does not exist. + +- [ ] **Step 3: Implement artifact verification** + +Provide `--artifact --profile `. Extract/mount into a temp +directory, find the native layout, run manifest/mode/ELF checks, and execute +`iptvnator_mpv_helper --runtime-probe` in the package's intended environment. +Always clean temporary mounts/directories. + +- [ ] **Step 4: Split and harden CI** + +Change Linux matrix entries to: + +```yaml +- os: linux + linux_profile: system +- os: linux + linux_profile: portable +- os: linux + linux_profile: flatpak +``` + +Filter targets exactly per profile and set +`IPTVNATOR_LINUX_FRAME_COPY_PROFILE`. Build/cache the pinned runtime once per +source/tool hash. Add format-specific extraction/installation tools and invoke +the verifier for every produced artifact. + +Run system formats in matching containers with `libmpv2`, `mpv-libs`, or +`mpv`; run AppImage directly with extraction fallback; install and probe Snap +and Flatpak inside their sandboxes. + +- [ ] **Step 5: Run GREEN and workflow source regressions** + +```bash +pnpm nx test packaging --skip-nx-cache +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns=embedded-mpv-native-source.spec +``` + +Expected: PASS and source tests prove all three profiles and six formats. + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows/build-and-make.yaml tools/packaging +git commit -m "ci: verify Linux frame-copy packages" +``` + +## Task 7: Add Packaged Playback And Fallback Smoke Coverage + +**Files:** + +- Create: `apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts` +- Modify: `.github/workflows/build-and-make.yaml` + +- [ ] **Step 1: Write the packaged E2E** + +Use the existing Electron test fixtures and a generated two-second local media +fixture. Assert the support response reports `frameCopyAvailable: true`, +activate the engine, load the fixture, observe a nonzero frame generation and +playing/paused snapshot, then relaunch with the runtime hidden and assert +native engine selection without a main-process crash. + +- [ ] **Step 2: Run the closest local parse/list check** + +```bash +pnpm nx lint electron-backend-e2e +pnpm nx show project electron-backend-e2e +``` + +Expected: PASS on macOS; the actual test is Linux-packaged-only. + +- [ ] **Step 3: Wire the Linux packaged smoke** + +Run the spec against the unpacked x64 bundled layout under software EGL +(`LIBGL_ALWAYS_SOFTWARE=1`) and keep a hardware-enabled smoke as a separate +non-blocking diagnostic when the CI runner exposes DRI. + +- [ ] **Step 4: Commit** + +```bash +git add apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts \ + .github/workflows/build-and-make.yaml +git commit -m "test(embedded-mpv): smoke packaged Linux frame-copy" +``` + +## Task 8: Update Canonical Documentation + +**Files:** + +- Modify: `docs/architecture/embedded-mpv-native.md` +- Modify: `tools/embedded-mpv/README.md` +- Modify: `vendor/embedded-mpv/README.md` +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Replace the dev-only Linux contract** + +Document x64 support across all six formats, the three profiles, dependency +names, runtime manifest/probe, codec baseline, source obligations, fallback, +and ARM unavailable behavior. Keep `AGENTS.md` and `CLAUDE.md` synchronized. + +- [ ] **Step 2: Verify documentation consistency** + +```bash +rg -n "dev-build-only|stripped from packages|must not bundle libmpv" \ + AGENTS.md CLAUDE.md docs/architecture/embedded-mpv-native.md \ + tools/embedded-mpv/README.md vendor/embedded-mpv/README.md +git diff --check +``` + +Expected: no stale Linux frame-copy shipping claim; any remaining +“must not bundle” text applies specifically to Electron/addon or system +profiles. + +- [ ] **Step 3: Commit** + +```bash +git add AGENTS.md CLAUDE.md docs/architecture/embedded-mpv-native.md \ + tools/embedded-mpv/README.md vendor/embedded-mpv/README.md +git commit -m "docs: document Linux frame-copy packages" +``` + +## Task 9: Final Verification Matrix + +**Files:** No production edits unless verification exposes a defect. + +- [ ] **Step 1: Run local project discovery and affected checks** + +```bash +pnpm nx show projects +pnpm nx test packaging --skip-nx-cache +pnpm nx test electron-backend --skip-nx-cache --runInBand +pnpm nx lint packaging --skip-nx-cache +pnpm nx lint electron-backend --skip-nx-cache +pnpm nx lint electron-backend-e2e --skip-nx-cache +pnpm nx build electron-backend --configuration=production --skip-nx-cache +``` + +Expected: PASS or an explicitly recorded platform-only native-build skip on +macOS without a vendored runtime. + +- [ ] **Step 2: Verify isolation source and local artifacts** + +```bash +rg -n -- '-lmpv|libmpv' apps/electron-backend/native/binding.gyp \ + tools/packaging apps/electron-backend/build-embedded-mpv.js +git diff --check origin/master...HEAD +git status --short +``` + +Expected: only the helper target links libmpv; no unstaged/unexplained files. + +- [ ] **Step 3: Record the exact evidence matrix** + +Report separately: + +- verified locally on macOS: Node/Jest/lint/build/static/package-policy tests; +- structurally verified but not executable locally: Linux runtime builder and + artifact extraction code; +- requires Linux CI: ELF resolution, actual six-format packages, package + managers, Snap/Flatpak confinement, EGL/GBM, frame production, and fallback + with libmpv removed. + +- [ ] **Step 4: Stop before publication** + +Do not push, open a PR, publish artifacts, or merge. Leave the fully checked +local branch ready for explicit user confirmation in a new task. diff --git a/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md new file mode 100644 index 000000000..94745ccaf --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md @@ -0,0 +1,263 @@ +# Linux Embedded MPV Frame-Copy Packaging Design + +**Date:** 2026-07-17 + +**Status:** Approved + +## Goal + +Ship a genuinely usable Embedded MPV frame-copy runtime in every official +Linux x64 package format produced by IPTVnator: AppImage, DEB, RPM, Pacman, +Snap, and Flatpak. Keep libmpv outside the Electron process, retain the +existing native-view engine as a safe fallback, and never advertise +frame-copy from artifact presence alone. + +Linux arm64 and armv7l remain out of scope for native Embedded MPV artifacts. +The current CI cross-packages those architectures from an x64 host and cannot +produce or exercise matching native addons. Those packages must continue to +carry an explicit unavailable marker and must not contain x64 native binaries. + +## Evidence From The Existing Implementation + +- `apps/electron-backend/native/binding.gyp` builds three distinct artifacts: + the native-view addon, the N-API shared-memory frame reader, and the + `iptvnator_mpv_helper` process. On Linux only the helper links `-lmpv`; the + addon uses X11/Xext/dlopen and must remain free of libmpv linkage. +- `tools/packaging/embedded-mpv-frame-copy-files.cjs` deliberately deletes the + helper from every Linux package. +- `tools/packaging/embedded-mpv-packaging.cjs` rejects Linux packages that + contain either the helper or libmpv, and accepts only the + `external-mpv-process` manifest origin. +- `resolveFrameCopyHelperPath()` currently treats an executable helper plus a + readable reader addon as a usable runtime. It does not prove that the ELF + loader can resolve libmpv or that libmpv/EGL initialization works. +- `.github/workflows/build-and-make.yaml` builds and verifies the Linux helper + against Ubuntu's system libmpv, then relies on the after-pack hook to remove + it. The same x64 build output is used for foreign-architecture Linux + packages, which receive the unavailable marker. +- Electron Builder creates one unpacked application layout before producing + multiple distributable targets. A system-runtime layout and a bundled + portable-runtime layout therefore cannot safely share one packaging pass. + +## Selected Distribution Strategy + +Linux packaging is split into explicit profiles: + +| Profile | Formats | libmpv strategy | +| ---------- | ---------------- | -------------------------------------------------------------------------- | +| `system` | DEB, RPM, Pacman | Depend on the distribution package and resolve `libmpv.so.2` from the host | +| `portable` | AppImage, Snap | Bundle the pinned LGPL-compatible runtime closure under `native/lib` | +| `flatpak` | Flatpak | Bundle the same pinned LGPL-compatible runtime closure under `native/lib` | + +The official CI matrix must run these profiles independently. The packaging +hook receives the profile through a required environment value and validates +that the selected target set matches the runtime mode. It must fail closed if +an official x64 package is requested with an absent, incomplete, or ambiguous +profile. + +System package dependencies are: + +- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm` +- Pacman: `mpv`, `libglvnd`, `mesa` + +These names match the current Debian, Fedora, and Arch package databases and +cover every direct helper interface: libmpv, EGL, GL, and GBM. The helper +links `libGL.so.1` rather than `libOpenGL.so.0`, matching both the distro +contracts and Snap's graphics provider. System +packages do not copy libmpv into IPTVnator. The helper keeps an `$ORIGIN/lib` +RUNPATH first for a consistent binary, but naturally resolves the system SONAME +when the private directory is absent. + +Portable and sandboxed packages use a source-built runtime rather than copying +the Ubuntu runner's mpv package. The runtime build is checksum/version pinned, +uses FFmpeg without GPL/nonfree switches and mpv with `-Dgpl=false`, records +sources and exact flags in the manifest, and keeps shared libraries replaceable +under the LGPL. The minimal codec baseline is FFmpeg's built-in LGPL decoders, +demuxers, protocols, and software scaling/resampling plus libass text +subtitles. Hardware decoding remains opportunistic through host Mesa/driver +interfaces and must fall back to software decoding. + +The source build also pins the `hwdata` v0.409 archive and its SHA-256 because +libdisplay-info 0.1.1 compiles `pnp.ids` into its generated vendor lookup +table. The builder stages that file with private `hwdata.pc` metadata and +restricts libdisplay-info's native pkg-config search to the staged prefix, so +Meson's `/usr/share/hwdata/pnp.ids` fallback cannot make the runtime depend on +unrecorded host data. The runtime manifest records this build-input +relationship. Release source bundles must include the exact hwdata archive and +its dual-license notice (`GPL-2.0-or-later OR XFree86-1.0`) alongside the +MIT-licensed libdisplay-info source. + +The strict Snap uses `base: core22`, a private `shared-memory` plug, and an +exact `graphics-core22` content plug targeting a real empty mode-0755 +`$SNAP/graphics` with external `mesa-core22` as default provider. The graphics provider supplies +EGL/GL/GLX/GBM/DRM/VA; Electron Builder's GNOME content runtime supplies +ALSA/PulseAudio. Those shared providers are not copied into IPTVnator's Snap or +source/notices archive. Because core22 does not synthesize `$SNAP` content +targets, the package hook creates the empty directory and extracted-artifact +validation checks its type and emptiness. The metadata also declares exactly +the canonical graphics layouts: `/usr/share/libdrm` binds from +`$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to +`$SNAP/graphics/drirc.d`. Locally installed `--dangerous` artifacts explicitly +install and connect both providers in CI, disconnect `graphics-core22` to +require an unavailable application diagnostic with exit code `1`, then +reconnect it and require success. + +## Runtime Layout And Linkage + +The x64 packaged native directory is: + +```text +resources/app.asar.unpacked/electron-backend/native/ + embedded_mpv.node + embedded_mpv_frame_reader.node + iptvnator_mpv_helper + embedded-mpv-runtime.json + lib/ + libmpv.so.2 + libavcodec.so.* + libavformat.so.* + libavutil.so.* + libavfilter.so.* + libswresample.so.* + libswscale.so.* + libass.so.* + ...other non-system runtime dependencies +``` + +For `system`, `lib/` is absent and the manifest declares the required SONAME +and package-family dependency. For `portable` and `flatpak`, `lib/` contains +the complete non-system dependency closure. ELF dependencies inside that +closure and the helper use only SONAMEs plus `$ORIGIN`-relative RPATH/RUNPATH; +they may not retain build-prefix paths. + +`embedded_mpv.node`, the Electron executable (`iptvnator.bin`), and Electron's +shipped libraries must not have a direct `DT_NEEDED` entry for libmpv. +`iptvnator_mpv_helper` must have one. Process isolation is an invariant, not a +profile-specific choice. The source `electron-backend/native{,/**/*}` tree is +excluded from `app.asar`; `afterPack` is the sole owner of the normalized +unpacked native directory, and package checks reject every archived native +entry. The pristine Electron tree is scanned recursively +before target packaging. Because Snap later overlays package-manager +`lib/**`/`usr/lib/**` trees into the payload root, its extracted-target scan +excludes exactly those two target-provided trees while remaining recursive +everywhere else. Electron-library symlinks outside those roots fail closed. + +The manifest records: + +- schema version, platform, architecture, profile, and runtime origin; +- required helper/reader names and executable/readable expectations; +- libmpv SONAME and either system package requirements or bundled files; +- source package versions, URLs/checksums, license identifiers, and exact + FFmpeg/mpv build flags for bundled profiles; +- the pinned hwdata `pnp.ids` build input consumed by libdisplay-info; +- runtime closure and total byte size; +- the native-view backend contract and the fact that only the helper links + libmpv. + +## Honest Capability Detection + +The helper gains a side-effect-free `--runtime-probe` mode. It must: + +1. load through the normal ELF loader and therefore prove that all `DT_NEEDED` + dependencies resolve; +2. create and initialize an idle libmpv handle with `vo=libmpv`; +3. create the platform render pipeline far enough to prove EGL/OpenGL/GBM + availability without opening media; +4. create, map, validate, and destroy the minimal shared-memory ring required + by playback; +5. emit one versioned JSON result and exit promptly with status zero only on + success. + +The main process invokes this probe synchronously with a bounded timeout before +BrowserWindow creation. Probe success is cached for the process lifetime. +The probe environment prepends the packaged `native/lib` directory only when +the manifest declares a bundled runtime. The system profile does not inject a +private loader path. Snap additionally rebuilds its loader and graphics-driver +variables from validated host GL, `$SNAP/graphics`, exact GNOME-platform, and +generic core22 roots; ambient preload/audit/library/driver overrides and +caller-provided architecture triplets are not inherited. The direct provider +wrapper launch also removes shell startup/options, tracing hooks, and exported +functions and fixes `PATH` to immutable core22 system directories. + +Packaging CI invokes the full main-process gate with the exact +`--embedded-mpv-runtime-probe` application switch. It executes before +BrowserWindow startup, writes one availability JSON line, and exits zero only +for a usable runtime. CI does not treat a direct helper invocation or an +environment opt-in as proof of packaged capability. + +Frame-copy is usable only when all of the following are true: + +- platform and architecture are supported; +- helper and reader are regular files with correct access modes; +- the runtime manifest is present, parses, matches Linux/x64, names the actual + artifacts, and uses an allowed profile/origin; +- every manifest-declared bundled file exists as a readable regular file; +- `--runtime-probe` succeeds and returns the expected protocol version. + +Any failure returns `false`, keeps the renderer sandbox enabled, and makes the +native service choose native-view. The capability result includes a stable +reason code for tracing and diagnostics but does not crash startup. + +If dependencies disappear after startup, helper spawn/early-exit remains a +session error and follows the existing renderer fallback path. The helper is +never loaded into Electron as a library. + +## Packaging And CI Validation + +Unit and packaging tests cover: + +- profile-to-target mapping and rejection of mixed system/bundled passes; +- Linux runtime staging, manifest normalization, closure collection, RPATH, + file modes, and stale-artifact cleanup; +- package validation for system, portable, Flatpak, foreign architecture, and + malformed/incomplete manifests; +- capability probe timeout, nonzero exit, invalid JSON, manifest mismatch, + missing dependency, and successful result caching; +- exclusion of all native payloads from `app.asar`, including marker-only ARM + and system-package stale x64 artifacts; +- helper probe protocol and failure behavior; +- package metadata dependencies for DEB/RPM/Pacman. + +Linux CI must: + +1. build or restore the pinned x64 LGPL runtime; +2. build the addon, reader, and helper once against that staged runtime; +3. prove with `readelf`/`ldd` that Electron and `embedded_mpv.node` do not link + libmpv and the helper does; +4. package the three profiles independently; +5. unpack or mount each produced format and validate its real payload, modes, + manifest, RPATH, dependency closure, and profile; +6. install/run the application-level packaged gate inside the actual Snap and + Flatpak (with the exact Freedesktop 24.08 EGL external-platform path + reconstructed inside `/app`), and probe the AppImage payload; +7. install system packages in matching disposable distro containers and run + the helper probe after the declared libmpv dependency is installed; +8. run a packaged Electron smoke test that confirms frame-copy capability, + creates a helper session against a deterministic local media fixture, sees + at least one frame/snapshot, and then repeats with libmpv hidden or removed + to prove non-crashing native-view fallback. + +Checks that require Linux kernel/package tooling or GPU/EGL are CI-only. +macOS development can run all pure Node/Jest tests and static source checks, +but cannot establish Linux ELF, package-manager, sandbox, or rendering +behavior. + +## Documentation And Release Compliance + +Update `docs/architecture/embedded-mpv-native.md`, +`tools/embedded-mpv/README.md`, `vendor/embedded-mpv/README.md`, +`AGENTS.md`, and `CLAUDE.md`. The docs must describe the x64 format matrix, +profile selection, manifest/probe contract, process-isolation invariant, +fallback behavior, source-distribution obligations, codec baseline, and ARM +status. + +Release artifacts must publish the generated runtime manifest and exact source +archives/metadata required by the recorded LGPL source-distribution statement. +The libplacebo payload is a VCS-metadata-free working-tree snapshot with exact +commit/submodule records, so clone-local `.git` state cannot perturb the +compliance tar. Automated Snap publication must wait for a public `v*` release +that already contains both the Snap assets and the exact source archive. Snap +Store publication remains outside this implementation and requires its +separate release workflow; repository integration follows explicit maintainer +authorization. diff --git a/electron-builder.json b/electron-builder.json index a874298dd..fca1b100e 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -20,6 +20,7 @@ "filter": ["**/*"] }, "electron-backend/**/*", + "!electron-backend/native{,/**/*}", "web/**/*", "!**/*.map" ], @@ -130,8 +131,32 @@ "grade": "stable", "summary": "IPTV application for M3U playlists, Xtream Codes API, and Stalker portals", "executableArgs": ["--ozone-platform=x11"], + "plugs": [ + "default", + { + "graphics-core22": { + "interface": "content", + "target": "$SNAP/graphics", + "default-provider": "mesa-core22" + } + }, + { + "shared-memory": { + "interface": "shared-memory", + "private": true + } + } + ], "environment": { "DISABLE_WAYLAND": "1" + }, + "layout": { + "/usr/share/libdrm": { + "bind": "$SNAP/graphics/libdrm" + }, + "/usr/share/drirc.d": { + "symlink": "$SNAP/graphics/drirc.d" + } } }, "win": { diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index 7c26a1253..74fd73112 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -37,11 +37,16 @@ export interface EmbeddedMpvSupport { engine?: EmbeddedMpvEngine; /** * True when this machine could run the frame-copy engine (macOS arm64 - * or Linux, with the helper binary present), regardless of whether it - * is active. + * or Linux x64, after its helper/runtime capability gate), regardless of + * whether it is active. * Drives the Settings toggle; switching engines requires an app restart. */ frameCopyAvailable?: boolean; + /** + * Stable fail-closed capability reason when frameCopyAvailable is false. + * Intended for startup tracing and support diagnostics, not user copy. + */ + frameCopyUnavailableReason?: string; } /** diff --git a/package.json b/package.json index cc2cfde26..76f64db8a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "verify:package-layout": "node tools/packaging/verify-electron-package-layout.mjs", "embedded-mpv:build-native:homebrew": "IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 nx run electron-backend:build-embedded-mpv", "embedded-mpv:build-runtime": "node tools/embedded-mpv/build-macos-runtime.mjs", + "embedded-mpv:build-runtime:linux": "node tools/embedded-mpv/build-linux-runtime.mjs", "embedded-mpv:stage-runtime": "node tools/embedded-mpv/stage-runtime.mjs", "embedded-mpv:stage-runtime:macos": "node tools/embedded-mpv/stage-macos-runtime.mjs", "embedded-mpv:stage-runtime:windows-archive": "node tools/embedded-mpv/stage-windows-runtime-archive.mjs", @@ -211,7 +212,8 @@ "tslib": "^2.8.1", "tsx": "4.21.0", "typescript": "5.9.3", - "typescript-eslint": "^8.46.2" + "typescript-eslint": "^8.46.2", + "yaml": "2.8.2" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c1e5b8aa..b151b699a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: typescript-eslint: specifier: ^8.46.2 version: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + yaml: + specifier: 2.8.2 + version: 2.8.2 packages: diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index a4807823b..677122365 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -1,21 +1,33 @@ # Embedded MPV Runtime -This folder contains tooling for preparing MPV runtime/build inputs for IPTVnator's experimental embedded MPV player. macOS and Windows bundle `libmpv`; Linux uses staged MPV headers for compilation and launches the system `mpv` executable at runtime. On Linux, `apps/electron-backend/build-embedded-mpv.js` also falls back to system headers when nothing is staged (`libmpv-dev`; override with `LIBMPV_INCLUDE_DIR`/`LINUX_NATIVE_LIBRARY_DIR`), so a plain distro dev setup builds without staging. The frame-copy helper (`iptvnator_mpv_helper`) additionally needs `libegl-dev`, `libgl-dev`, `libopengl-dev` (for the unversioned glvnd `libOpenGL.so` the linker resolves `-lOpenGL` against), and `libgbm-dev`, and links the system `libmpv` — allowed because it is a separate process; the in-process-libmpv ban still binds the addon. On Windows the same binding.gyp run builds `iptvnator_mpv_helper.exe` against the staged vendored runtime (import library + DLL, resolved from the helper's own directory at runtime) plus `opengl32.lib`; no toolchain beyond the MSVC workload and Windows SDK that node-gyp already requires. +This directory owns the source builders, staging, manifests, and archive +helpers for IPTVnator's experimental Embedded MPV runtime. + +The Linux architecture has a strict process boundary: + +- Electron, `embedded_mpv.node`, and `embedded_mpv_frame_reader.node` must not + load or link libmpv. +- Native-view starts a separate system `mpv --wid` process. +- Frame-copy starts `iptvnator_mpv_helper`; only that helper may link libmpv. + +Do not weaken this boundary to simplify packaging. A missing helper/runtime +must make frame-copy unavailable and leave native-view as the safe x64 +fallback. ## Runtime Policy -Release builds must use an LGPL-compatible runtime: +Release builds use an LGPL-compatible, dynamically linked runtime: -- FFmpeg must be built without `--enable-gpl` and without `--enable-nonfree`. -- mpv must be built with `-Dlibmpv=true` and `-Dgpl=false`. -- The runtime must be dynamically linked so users can inspect and replace LGPL libraries. -- The exact source URLs, versions, build flags, local patches, and checksums must be published with the release. +- FFmpeg is built without `--enable-gpl` and `--enable-nonfree`. +- mpv is built with `-Dlibmpv=true` and `-Dgpl=false`. +- Bundled libraries remain individually replaceable under `native/lib`. +- Exact source URLs, versions, checksums or git commits, submodules, licenses, + build flags, local patches, and build scripts are published with the release. -Do not ship the Homebrew `mpv` runtime. It is acceptable only for local development when `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1` is set, and release packaging rejects it. +Homebrew mpv is local-development-only. It requires +`IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`, and release validation rejects it. -## Expected Layout - -The native addon build consumes: +## Generated Layout ```text vendor/embedded-mpv/ @@ -29,126 +41,349 @@ vendor/embedded-mpv/ runtime-manifest.json win32-x64/ include/mpv/client.h - lib/libmpv-2.dll # or mpv-2.dll/mpv.dll/libmpv.dll - lib/libmpv.dll.a # or mpv.lib/mpv-2.lib + lib/libmpv-2.dll # accepted basename variants are preserved + lib/libmpv.dll.a # or an MSVC import library runtime-manifest.json linux-x64/ include/mpv/client.h + lib/libmpv.so + lib/libmpv.so.2 + lib/ + notices/embedded-mpv-notices.json + notices/THIRD_PARTY_NOTICES.txt + notices/licenses// runtime-manifest.json ``` -The generated `lib/` and `include/` directories are release inputs, not source files. They are ignored by git by default. +These directories are generated release inputs and are ignored by git. +`runtime-manifest.json` is the profile-neutral source/build manifest. Packaging +writes a normalized `embedded-mpv-runtime.json` beside the native artifacts. +Bundled Linux profiles flatten the three notice entries from `notices/` into +that same native directory; system and marker-only profiles remove them. -## Staging A Built Runtime +## Building And Staging -After building an LGPL-compatible prefix for one platform/architecture, stage it with: +Stage an existing compatible prefix with: ```bash -pnpm embedded-mpv:stage-runtime -- darwin arm64 /path/to/lgpl-prefix -pnpm embedded-mpv:stage-runtime -- darwin x64 /path/to/lgpl-prefix -pnpm embedded-mpv:stage-runtime -- win32 x64 /path/to/lgpl-prefix -pnpm embedded-mpv:stage-runtime -- linux x64 /path/to/lgpl-prefix +pnpm embedded-mpv:stage-runtime -- darwin arm64 /path/to/prefix +pnpm embedded-mpv:stage-runtime -- darwin x64 /path/to/prefix +pnpm embedded-mpv:stage-runtime -- win32 x64 /path/to/prefix +pnpm embedded-mpv:stage-runtime -- linux x64 /path/to/prefix ``` -For compatibility, the legacy macOS-only staging command is still available: +Build the pinned macOS or Linux source runtime first when no prefix exists: ```bash -pnpm embedded-mpv:stage-runtime:macos -- arm64 /path/to/lgpl-prefix -pnpm embedded-mpv:stage-runtime:macos -- x64 /path/to/lgpl-prefix +pnpm embedded-mpv:build-runtime -- arm64 /tmp/macos-prefix +pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/macos-prefix + +pnpm embedded-mpv:build-runtime:linux -- /tmp/linux-prefix +pnpm embedded-mpv:stage-runtime -- linux x64 /tmp/linux-prefix ``` -The prefix must contain `include/mpv/client.h` and the platform runtime/build files: +The Linux builder runs only on Linux x64. It requires the tool versions and +system development interfaces declared in `build-linux-runtime.cjs`, including +Meson 1.6 or newer, gperf 3.1 or newer, Ninja, CMake, NASM, pkg-config, +patchelf, and `readelf`. +It builds into an owned staging directory and publishes atomically, so it will +not delete or overwrite an arbitrary destination. -- macOS: `lib/libmpv.2.dylib` or `lib/libmpv.dylib` plus all non-system dylib dependencies -- Windows: `lib/mpv.lib`, `lib/mpv-2.lib`, or `libmpv.dll.a`, and `bin/` or `lib/` containing `mpv-2.dll`, `libmpv-2.dll`, `mpv.dll`, or `libmpv.dll` -- Linux: `include/mpv/client.h`; CI also records the `libmpv-dev` and `mpv` package versions used as build inputs. Linux runtime playback uses the system `mpv` executable and does not bundle `libmpv.so`. +The pinned Linux source stack currently includes FFmpeg 8.1, mpv 0.41.0, +libplacebo 7.360.1, libass 0.17.3, FreeType 2.13.3, FriBidi 1.0.16, +HarfBuzz 8.5.0, Expat 2.8.2, Fontconfig 2.16.0, OpenSSL 3.5.7, hwdata +0.409, and libdisplay-info 0.1.1. The builder stages a private pinned +`pnp.ids`/`hwdata.pc`; libdisplay-info is not allowed to consume the build +host's `/usr/share/hwdata`. -If the prefix contains `runtime-manifest.json`, the staging script copies its build metadata into the vendored manifest. At minimum, record: +Before publication, the Linux builder verifies: -- FFmpeg version, source URL, checksum, configure flags, and patches -- mpv version, source URL, checksum, Meson flags, and patches -- source-distribution URL for the corresponding release +- every archive digest and git/submodule commit; +- the exact FFmpeg/mpv flags and LGPL policy; +- an exact `libmpv.so.2` SONAME and complete reachable shared-library closure; +- `$ORIGIN` RUNPATHs with no build-prefix paths or undeclared host fallback; +- the external system-library allowlist; +- `GLIBC_2.35` and `GLIBCXX_3.4.30` ABI ceilings; +- file hashes, byte sizes, build inputs, licenses, and source obligations. -## Building The CI Runtime +## Linux Package Profiles -Tagged macOS release builds build the runtime from pinned source archives before `electron-backend:build`. The workflow can also enable this path temporarily for macOS PR artifact testing: +Set one exact `IPTVNATOR_LINUX_FRAME_COPY_PROFILE` per packaging pass: -```bash -pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix -pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix -``` +| Profile | Formats | Runtime handling | +| ---------- | ---------------- | ---------------------------------------------------------------------------- | +| `system` | DEB, RPM, Pacman | Remove `native/lib`; require the format-specific system runtime listed below | +| `portable` | AppImage, Snap | Retain the pinned LGPL closure under `native/lib` | +| `flatpak` | Flatpak | Retain the same pinned LGPL closure under `native/lib` | -Linux CI does not build libmpv from source. It installs Ubuntu runner packages -(`libmpv-dev` and `mpv`), stages their headers and build metadata under -`vendor/embedded-mpv/linux-x64/`, and requires the native addon/package layout -to be present. Linux playback does not load or bundle `libmpv` in the Electron -process; the addon creates an X11 child window and starts a system `mpv --wid` -process at runtime. +The system helper directly links libmpv, EGL, GL, and GBM. Package metadata +therefore declares the full interface set: -Windows CI does not build libmpv from source. It restores an exact-keyed cache -for `vendor/embedded-mpv/win32-x64/`; on cache miss it stages a checksum-pinned -LGPL-compatible archive from repository configuration: +- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm` +- Pacman: `mpv`, `libglvnd`, `mesa` -```text -IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL -IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 -``` +The helper links `libGL.so.1` (`-lGL`) rather than `libOpenGL.so.0`; the +former is the direct GL interface supplied by all three system contracts and +Snap's `mesa-core22`. -The values can be repository variables or secrets. Prefer variables when PR -artifact builds from same-repository branches should include Embedded MPV. For -non-tag artifact builds only, the workflow falls back to a checksum-pinned -`zhongfly/mpv-winbuild` `mpv-dev-lgpl-x86_64` archive when those variables are -unset. Tagged release builds must provide the repository configuration -explicitly. +The DEB metadata is release-tested on Ubuntu 24.04 (Noble). Ubuntu 22.04 +(Jammy) only provides `libmpv1`; use the x64 AppImage on that distribution +rather than relaxing the runtime contract. CI explicitly installs the distro +Mesa software renderer for headless smoke. IPTVnator does not add DRI-driver +packages as direct dependencies; any transitive graphics-driver stack remains +under the distro's dependency policy. -The Windows job is pinned to `windows-2022` while the current Electron -`node-gyp` toolchain cannot identify Visual Studio 18 from `windows-latest`. +The Snap is `base: core22` with strict confinement. It retains Electron +Builder's default plugs and adds an auto-connected private `shared-memory` +plug plus `graphics-core22`, targeting a real empty mode-0755 `$SNAP/graphics` +with external `mesa-core22` as default provider. The graphics provider supplies +EGL/GL/GLX/GBM/DRM/VA, while Electron Builder's exact GNOME content runtime +supplies ALSA/PulseAudio. Neither provider is bundled into IPTVnator's Snap, +source archive, notices, or package-size accounting. The package hook creates +the empty content target because core22 does not synthesize one; the extracted +artifact verifier rejects a missing, redirected, non-empty, or wrongly +permissioned target. Snap metadata must also contain exactly the canonical +graphics-provider layouts: bind `/usr/share/libdrm` from +`$SNAP/graphics/libdrm`, and symlink `/usr/share/drirc.d` to +`$SNAP/graphics/drirc.d`. -The archive must contain a Windows x64 prefix with `include/mpv/client.h`, a -libmpv import library, and `mpv-2.dll`/`mpv.dll` or -`libmpv-2.dll`/`libmpv.dll`. The archive can use either the normal prefix layout -(`lib/` and `bin/`) or the common `mpv-dev-lgpl` flat layout with the import -library and DLL in the archive root. The staged runtime preserves the DLL -basename from the archive because Windows import libraries encode the DLL name -that native binaries must load at runtime. Package validation reads the -frame-copy helper's PE imports and requires that exact DLL basename beside -`iptvnator_mpv_helper.exe`; a different accepted MPV DLL name or a copy only -under `native/lib/` is not sufficient. If -`runtime-manifest.json` is missing, CI generates a minimal manifest from the -archive URL/path and checksum; release-ready runtime archives should still -provide full source/build metadata. +The bounded probe and every playback helper share one sanitized loader +environment derived from the validated, cached runtime mode. Ambient +ELF audit/preload/origin/library overrides, direct EGL/GBM/GL/VA/Vulkan paths, +shell startup/options, tracing hooks, exported Bash functions, and +caller-provided architecture triplets are removed or replaced. The +extracted-artifact verifier uses the same deny-set for its direct helper smoke +and preserves feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. System +packages then use the default loader; bundled packages put their validated +`native/lib` first. Packaged addon/helper lookup is package-owned +`app.asar.unpacked` only; cwd/dist candidates are development-only. +AppImage and Flatpak use normal host/sandbox lookup for the declared external +interfaces. Inside the exact packaged Flatpak `/app` context, the helper +reconstructs only Freedesktop Platform 24.08's immutable +`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS` value; the GL extension's +`add-ld-path` remains available through the sandbox loader cache. Flatpak CI +therefore invokes `flatpak run com.fourgray.iptvnator +--embedded-mpv-runtime-probe` instead of executing the helper around the +application gate. In a genuine Snap mount, filtered `SNAP_LIBRARY_PATH` GL roots +under `/var/lib/snapd/lib/gl` come next, then the fixed x64 +`$SNAP/graphics` roots, then the core22 base +`/usr/lib/x86_64-linux-gnu`, exact `$SNAP/gnome-platform` graphics/audio roots, +and finally generic `$SNAP` library roots. Keeping the base ABI ahead of the +older GNOME content runtime prevents its `libedit.so.2` from injecting an +unavailable `libtinfo.so.5` dependency into mesa-core22's software renderer. +The helper rebuilds GBM, GL/VA driver, EGL vendor/platform, and Vulkan layer +variables from those trusted locations. A Linux session without the validated +cached mode is rejected before spawn. +Both the bounded probe and playback execute through +`$SNAP/graphics/bin/graphics-core22-provider-wrapper`. The graphics mount must +be a real directory and the wrapper a regular, non-symlinked, readable +executable. Otherwise the gate returns the stable +`snap-graphics-provider-unavailable` reason before spawning the helper. The +wrapper child also drops shell startup/options, tracing hooks, and exported +`BASH_FUNC_*` functions, and uses a fixed core22 system `PATH`; ambient Bash +configuration therefore cannot replace the probe before helper execution. -During temporary PR and `master` artifact testing, CI restores an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/-/` runtime before falling back to the macOS source build or Windows runtime archive where available. The cache key includes the target platform, architecture, macOS deployment target, Xcode version when available, a hash of the Windows runtime checksum when applicable, and hashes of the runtime build/staging scripts. Cache entries are saved only from trusted repository refs and are treated strictly as a speed optimization; tagged macOS release builds continue to rebuild from pinned sources unless a future signed and attested runtime artifact flow is introduced. +Installed-Snap CI disconnects `graphics-core22`, requires the application-level +diagnostic to emit `snap-graphics-provider-unavailable` and exit with the +controlled status `1`, then reconnects the provider and requires a successful +diagnostic. This keeps the canonical layouts and missing-provider fallback in +the same regression contract. -The builder currently pins: +Profiles cannot share one Electron Builder pass because its targets reuse the +same unpacked application directory. A missing or unsupported profile, or a +target from another profile, fails packaging. -- FFmpeg `8.1`, configured without `--enable-gpl` or `--enable-nonfree`, and with autodetected external libraries disabled -- mpv `0.41.0`, configured with `-Dlibmpv=true -Dgpl=false` -- libplacebo `7.360.1`, checked out from git with the `glad`, Python template, `fast_float`, and `Vulkan-Headers` submodules required by its Meson build -- libass `0.17.3` plus FreeType, FriBidi, and HarfBuzz - -The build manifest records source URLs, downloaded archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, and the exact FFmpeg/mpv flags. The staged macOS/Windows manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS/Windows release packaging. +Linux frame-copy release artifacts are x64-only. Non-x64 packages are always +marker-only even if environment variables point at the x64 staged runtime. ## Build Integration -`apps/electron-backend/build-embedded-mpv.js` builds the native addon against the staged runtime/build inputs, copies macOS/Windows runtime libraries into `apps/electron-backend/native/build/Release/lib/`, rewrites macOS Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`. Linux builds use the staged (or system) MPV headers and system X11 development libraries, write an `external-mpv-process` manifest, and the addon must not copy or link directly to `libmpv`; CI validates this with package checks and `ldd`. The frame-copy helper executable built by the same run is the inverse: CI verifies it DOES link `libmpv` (separate process). +`apps/electron-backend/build-embedded-mpv.js` builds the addon, frame reader, +and helper against the staged inputs. On Linux it links the helper to the +verified staged libmpv path rather than a generic host `-lmpv`, then checks +with `readelf` that: -For local macOS development with Homebrew `mpv`, use: +- the helper has exactly the declared libmpv `DT_NEEDED`; +- the helper RUNPATH is `$ORIGIN/lib`; +- the addon and frame reader have no libmpv dependency; +- no runtime dependency contains an absolute/build-prefix loader path. + +The package hook copies native artifacts into +`app.asar.unpacked/electron-backend/native/`, selects the system or bundled +layout, restores exact file modes, writes the packaged manifest, and validates +the bundled legal payload. AppImage, Snap, and Flatpak receive +`embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and +`licenses//**`; DEB, RPM, Pacman, and marker-only packages must not +retain them. Package validation also scans the Electron executable and all +shipped Electron libraries for a direct libmpv dependency. Before target +packaging, that scan is recursive over the pristine Electron tree. After Snap +has merged its template runtime into the payload root, the post-target scan +excludes exactly its package-manager `lib/**` and `usr/lib/**` trees while +remaining recursive everywhere else. + +At startup, Linux x64 frame-copy is advertised only after the main process +validates that manifest/files and successfully executes: + +```bash +iptvnator_mpv_helper --runtime-probe +``` + +The bounded probe initializes idle libmpv plus EGL/OpenGL and mpv render +contexts, then creates, maps, validates, and destroys a minimal `16x16` +shared-memory ring named `/impv-fc-runtime-probe-`. It does not open media +or enter media/command loops. A timeout, loader failure, malformed protocol, +missing file, hash mismatch, unusable graphics path, or shm lifecycle failure +returns a stable reason and keeps the BrowserWindow sandbox enabled. The +application diagnostic retains `helper-probe-failed` as the top-level reason +for nonzero helper exits and adds `helperReason` only when the helper emitted +one exact protocol-v1 line with a fixed allowlisted reason. Its optional +`helperDetail` is restricted to 1–1024 printable ASCII characters; invalid +detail suppresses both helper fields. Every probe has the same explicit 16 MiB +aggregate captured-output ceiling, regardless of tracing. With +`IPTVNATOR_TRACE_PLAYER=1`, non-empty captured helper stderr is written +separately as one JSON-escaped stderr line: its `stderr` field contains at most +the first 16,384 characters and its `truncated` boolean is always explicit. +Empty captures, disabled tracing, and trace-writer failures do not emit a +record or alter availability. The installed-Snap probe therefore tests the +private shared-memory confinement +needed by playback rather than only loader and graphics startup. +Packaging CI invokes the same gate through +`snap run iptvnator --embedded-mpv-runtime-probe`. This packaging-only +application switch runs before BrowserWindow startup, emits one availability +JSON line, and returns zero only for a usable runtime; it never directly loads +libmpv in Electron. The installed-Snap smoke adds `EGL_LOG_LEVEL=debug` and +`LIBGL_DEBUG=verbose` under that bounded trace channel to expose GLVND/Mesa +loader failures without weakening the hostile-environment gate. + +Electron Builder excludes `electron-backend/native{,/**/*}` from `app.asar`. +Only `afterPack` writes the profile-normalized +`app.asar.unpacked/electron-backend/native` tree. Layout and final-artifact +verification enumerate `app.asar` and reject any stale native entry, preventing +hidden x64 helpers, bundled libraries, or notices in system and marker-only +packages. + +## CI And Source Distribution + +Linux CI builds or restores the pinned source runtime once, then packages and +verifies `system`, `portable`, and `flatpak` independently. Every artifact is +extracted for manifest, mode, package-metadata, ELF-isolation, and helper-probe +checks. System formats are probed after their declared dependency is installed; +Snap and Flatpak also require a sandboxed probe where the runner supports it. +For a locally installed `--dangerous` Snap, CI explicitly installs and +connects `mesa-core22` and `gnome-3-28-1804`, verifies both connections, and +then runs the application-level diagnostic under Xvfb. +The Linux packaging matrix alone depends on the runtime-builder job. macOS and +Windows use an independent matrix, while both matrices share the same anchored +step list; draft release assembly remains atomic and requires both matrices. + +The Linux runtime cache contains only staged headers/libraries/manifest plus +immutable source inputs: exact downloaded archives (including hwdata), a clean +recursive libplacebo checkout, and collected license files. It never caches +finished notices or the compliance tarball. After either a build or cache hit, +CI revalidates those inputs, regenerates `vendor/embedded-mpv/linux-x64/notices` +for the current runtime manifest, and creates +`linux-frame-copy-runtime-sources.tar.xz` for the current repository +revision/diff. Before archiving, the clean cached libplacebo checkout is +converted into a non-dereferenced working-tree snapshot with every `.git` +entry removed; the validated main/submodule commits remain in the source +index. + +That source-compliance archive uses normalized tar metadata and contains the +exact unique archive hash set, VCS-free libplacebo sources and the exact pinned +six recursive submodule records, license inputs, generated notices, +runtime/source index metadata, and the builder, stager, manifest, +notice-generator, and source-snapshot code. +Submodule identity is canonicalized as `full-commit safe/path`; optional +clone-depth-dependent `git describe` annotations are discarded. +The source index carries a globally sorted inventory of every libplacebo +directory, file, and symlink. Regular-file hashes, sizes, normalized executable +bits, exact safe link targets, aggregate counts/bytes, and the canonical +inventory digest are checked against the trusted pinned v7.360.1 checkout. The +tar has an exact member/type layout, and `metadata/archive-sha256.txt` is +checked against the actual source archive bytes. Listing continues past every +tar end marker so concatenated xz streams cannot hide undeclared members. +The notice generator rejects missing, undeclared, symlinked, size-mismatched, +or hash-mismatched license files. + +Once CI creates the final `linux-frame-copy-runtime-sources.tar.xz`, it writes +`source-archive-binding.json` beside the staged runtime with the archive's +SHA-256 and repository revision. Bundled x64 AppImage, Snap, and Flatpak +manifests copy that exact object as `sourceArchive`; system packages and +marker-only non-x64 packages omit it. + +The packaged x64 Playwright smoke depends on its fixture-contract target and +passes Chromium `--ignore-gpu-blocklist` so Mesa llvmpipe can provide WebGL2 in +CI. This affects only Chromium's software-renderer admission; the manifest, +hash, loader, and helper probes still fail closed, and `--no-sandbox` remains +root-only. + +Snap publication is a separate `release.published` workflow for public `v*` +GitHub releases. It verifies that the public release already contains at least +one Snap and exactly one non-empty +`linux-frame-copy-runtime-sources.tar.xz` before uploading anything. The +release verifier hashes the downloaded archive, checks its clean released +revision, exact member/type layout and safe link targets, source checksum +metadata, source index, actual pinned source-member hashes, six recursive +libplacebo submodule records, legal payload, exact trusted libplacebo tree +inventory/digest, released tooling, and runtime manifest. Checkout and both +artifact-transfer actions use full pinned commits, and checkout does not +persist its repository credential. The verifier bounds +source members, the archive, SquashFS listing, extracted size, entry count, +command time, and job time; every Snap must use the canonical +`/usr/lib/iptvnator` layout and pass the existing static package validator. +The public-release boundary also reapplies the exact strict +`meta/snap.yaml` graphics/shared-memory/layout contract and enumerates the +extracted `resources/app.asar`, rejecting any archived +`electron-backend/native/**` payload. Its bounded ASAR header reader depends +only on Node built-ins and released local tooling, so verification remains +runnable in the clean tag checkout without `node_modules`. +Exactly one x64 Snap is accepted, and only when its exact `sourceArchive` and +`sourceRuntime` match the downloaded archive; any non-x64 Snap must be +marker-only. A secretless job copies each asset through a no-follow descriptor, +checks hashes before and after inspection, writes an exact receipt, fully +reverifies a root-owned read-only snapshot, and transfers only that data +through the pinned artifact service while publishing the exact receipt digest +separately as a job output. + +The dependent publish job runs on a bounded GitHub-hosted `ubuntu-latest` +runner with no checkout or release-tag code. It verifies that separate digest, +the exact receipt schema, every asset size/hash, and the expected regular-file +layout, rejects links and extras, root-seals the transferred data again, and +installs the official stable Snapcraft snap. Only its final fixed shell step +receives the Store credential; it executes no released code, resolves no PATH +command, and passes the credential only to each exact +`/snap/bin/snapcraft upload --release=edge` process. GitHub credentials remain +scoped to asset selection/download. Candidate/stable +promotion is manual after installed-Snap frame-copy and missing-runtime +fallback smoke; GitHub Actions never promotes automatically. + +Windows CI stages a checksum-pinned x64 LGPL archive. The DLL basename encoded +in its import library is preserved and must be present beside +`iptvnator_mpv_helper.exe`. Tagged releases require explicit repository +configuration; the public fallback is for non-tag artifacts only. The upstream +keeps only its latest 30 daily builds, so the fallback URL and checksum plus any +matching repository variables must be refreshed as one pair before they age +out. A permanent mirror must publish the corresponding source/build records and +license notices with the binary. + +## Local Development + +Linux can use distribution development packages for an unshipped local build +(`libmpv-dev`, EGL/GL/GBM development files, and X11 headers). Overrides: +`LIBMPV_INCLUDE_DIR` selects the header root. `LINUX_NATIVE_LIBRARY_DIR` +selects a link-time library directory that must already be visible to the +system dynamic loader; it is not inherited as a helper `LD_LIBRARY_PATH`. +Required/release package builds must use the pinned staged runtime and manifest. + +On macOS: ```bash pnpm run serve:backend:embedded-mpv ``` -The script rebuilds the native addon with `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1` before starting Electron with the experimental player enabled. Use this only for local testing; release packaging rejects the resulting `homebrew-dev` runtime manifest. +This explicitly permits Homebrew for the local native build and enables the +experiment. It is not a release path. -The `afterPack` hook copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, runtime manifest, and runtime libraries are available as real files where needed. Linux packages include the addon and manifest, but no bundled `libmpv.so`, and the hook strips `iptvnator_mpv_helper` from Linux packages (it links the build host's system libmpv; the frame-copy engine stays dev-build-only on Linux until bundled-runtime staging lands). - -During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that macOS/Windows packages use a `vendored-lgpl` runtime/build input set. macOS artifacts additionally verify that Mach-O dependencies have no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV. Linux artifacts verify that the addon and `external-mpv-process` manifest are present, that no bundled `libmpv.so` files are present, and the runtime support check verifies that `mpv` is available on `PATH`. - -Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a release artifact that must include Embedded MPV. The same variable is temporarily enabled for macOS PR and `master` push artifacts while the bundled runtime is being tested. Linux CI packaging requires Embedded MPV after staging the Ubuntu package build inputs. Windows CI packaging now requires Embedded MPV for x64 artifacts: the job restores the staged runtime cache or stages the checksum-pinned runtime archive, then fails backend build, package make, or package-layout verification if the addon/runtime is missing. - -## Platform Notes - -- macOS keeps the existing libmpv render-context backend because mpv `wid` stays black inside Electron on macOS. -- Windows uses an embedded child `HWND` and passes it to mpv through `wid`. The experimental frame-copy engine instead renders offscreen through WGL into the app canvas (no child window) and shares frames over a session-local named file mapping. -- Linux uses an X11 child window and starts a system `mpv --wid` process for that window. Native Wayland is not supported in v1; run under X11/Xwayland so `DISPLAY` is set and `mpv` can honor the X11 window id. The experimental frame-copy engine has no window embedding at all (offscreen EGL into a renderer canvas) and therefore works under native Wayland — dev builds only for now. +See `docs/architecture/embedded-mpv-native.md` for the runtime capability, +fallback, controls, and packaged-release contracts. diff --git a/tools/embedded-mpv/build-linux-runtime.cjs b/tools/embedded-mpv/build-linux-runtime.cjs new file mode 100644 index 000000000..a8b4d370c --- /dev/null +++ b/tools/embedded-mpv/build-linux-runtime.cjs @@ -0,0 +1,1693 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); + +const HWDATA_BUILD_INPUT = Object.freeze({ + consumer: 'libdisplay-info', + relativePath: 'pnp.ids', + purpose: 'PNP vendor lookup table compiled into libdisplay-info.', +}); +const EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES = Object.freeze([ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear', +]); + +const SOURCE_PACKAGES = Object.freeze( + [ + { + id: 'freetype', + version: '2.13.3', + sourceKind: 'archive', + sourceUrl: + 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz', + expectedSha256: + '0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289', + license: 'FreeType License (FTL)', + }, + { + id: 'fribidi', + version: '1.0.16', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz', + expectedSha256: + '1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c', + license: 'LGPL-2.1-or-later', + }, + { + id: 'harfbuzz', + version: '8.5.0', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz', + expectedSha256: + '77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27', + license: 'MIT', + }, + { + id: 'expat', + version: '2.8.2', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz', + expectedSha256: + '3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4', + license: 'MIT', + }, + { + id: 'fontconfig', + version: '2.16.0', + sourceKind: 'archive', + sourceUrl: + 'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz', + expectedSha256: + '6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220', + license: 'MIT', + }, + { + id: 'libass', + version: '0.17.3', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz', + expectedSha256: + 'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959', + license: 'ISC', + }, + { + id: 'openssl', + version: '3.5.7', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz', + expectedSha256: + 'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8', + license: 'Apache-2.0', + }, + { + id: 'ffmpeg', + version: '8.1', + sourceKind: 'archive', + sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz', + expectedSha256: + 'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a', + license: 'LGPL-2.1-or-later', + }, + { + id: 'libplacebo', + version: '7.360.1', + sourceKind: 'git', + sourceUrl: 'https://github.com/haasn/libplacebo.git', + sourceTag: 'v7.360.1', + expectedGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4', + expectedSubmodules: EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES, + license: 'LGPL-2.1-or-later', + }, + { + id: 'hwdata', + version: '0.409', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz', + expectedSha256: + '23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd', + license: 'GPL-2.0-or-later OR XFree86-1.0', + buildInput: HWDATA_BUILD_INPUT, + }, + { + id: 'libdisplay-info', + version: '0.1.1', + sourceKind: 'archive', + sourceUrl: + 'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz', + expectedSha256: + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + license: 'MIT', + }, + { + id: 'mpv', + version: '0.41.0', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz', + expectedSha256: + 'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209', + license: 'LGPL-2.1-or-later with -Dgpl=false', + }, + ].map((sourcePackage) => Object.freeze(sourcePackage)) +); + +const BUILD_ORDER = Object.freeze( + SOURCE_PACKAGES.map((sourcePackage) => sourcePackage.id) +); + +const FFMPEG_CONFIGURE_FLAGS = Object.freeze([ + '--enable-shared', + '--disable-static', + '--disable-programs', + '--disable-doc', + '--disable-debug', + '--disable-autodetect', + '--disable-gpl', + '--disable-nonfree', + '--disable-version3', + '--enable-pic', + '--enable-pthreads', + '--enable-openssl', + '--disable-gnutls', + '--disable-mbedtls', + '--disable-libtls', + '--enable-network', + '--disable-protocols', + '--enable-protocol=file', + '--enable-protocol=http', + '--enable-protocol=https', + '--enable-protocol=httpproxy', + '--enable-protocol=tcp', + '--enable-protocol=tls', + '--enable-protocol=udp', + '--enable-protocol=crypto', + '--enable-protocol=data', + '--enable-demuxer=hls', + '--enable-vaapi', + '--disable-vdpau', + '--disable-vulkan', + '--disable-libdrm', + '--disable-cuda-llvm', + '--disable-cuvid', + '--disable-nvdec', + '--disable-nvenc', + '--disable-xlib', + '--disable-sdl2', + '--disable-openal', +]); + +const MPV_MESON_FLAGS = Object.freeze([ + '-Dgpl=false', + '-Dlibmpv=true', + '-Dcplayer=false', + '-Dbuild-date=false', + '-Dtests=false', + '-Dfuzzers=false', + '-Ddisable-packet-pool=false', + '-Dcdda=disabled', + '-Dcplugins=disabled', + '-Ddvbin=disabled', + '-Ddvdnav=disabled', + '-Diconv=enabled', + '-Djavascript=disabled', + '-Djpeg=disabled', + '-Dlcms2=disabled', + '-Dlibarchive=disabled', + '-Dlibavdevice=disabled', + '-Dlibbluray=disabled', + '-Dlua=disabled', + '-Dpthread-debug=disabled', + '-Drubberband=disabled', + '-Dsdl2-gamepad=disabled', + '-Duchardet=disabled', + '-Duwp=disabled', + '-Dvapoursynth=disabled', + '-Dvector=enabled', + '-Dwin32-threads=disabled', + '-Dx11-clipboard=disabled', + '-Dzimg=disabled', + '-Dzlib=disabled', + '-Dalsa=enabled', + '-Daudiounit=disabled', + '-Dcoreaudio=disabled', + '-Davfoundation=disabled', + '-Djack=disabled', + '-Dopenal=disabled', + '-Daudiotrack=disabled', + '-Daaudio=disabled', + '-Dopensles=disabled', + '-Doss-audio=disabled', + '-Dpipewire=disabled', + '-Dpulse=enabled', + '-Dsdl2-audio=disabled', + '-Dsndio=disabled', + '-Dwasapi=disabled', + '-Dcaca=disabled', + '-Dcocoa=disabled', + '-Dd3d11=disabled', + '-Ddirect3d=disabled', + '-Ddmabuf-wayland=disabled', + '-Ddrm=enabled', + '-Degl=enabled', + '-Degl-android=disabled', + '-Degl-angle=disabled', + '-Degl-angle-lib=disabled', + '-Degl-angle-win32=disabled', + '-Degl-drm=disabled', + '-Degl-wayland=disabled', + '-Degl-x11=disabled', + '-Dgbm=enabled', + '-Dgl=enabled', + '-Dgl-cocoa=disabled', + '-Dgl-dxinterop=disabled', + '-Dgl-win32=disabled', + '-Dgl-x11=disabled', + '-Dsdl2-video=disabled', + '-Dshaderc=disabled', + '-Dsixel=disabled', + '-Dspirv-cross=disabled', + '-Dplain-gl=enabled', + '-Dvdpau=disabled', + '-Dvdpau-gl-x11=disabled', + '-Dvaapi=enabled', + '-Dvaapi-drm=enabled', + '-Dvaapi-wayland=disabled', + '-Dvaapi-win32=disabled', + '-Dvaapi-x11=disabled', + '-Dvulkan=disabled', + '-Dwayland=disabled', + '-Dx11=disabled', + '-Dxv=disabled', + '-Dandroid-media-ndk=disabled', + '-Dcuda-hwaccel=disabled', + '-Dcuda-interop=disabled', + '-Dd3d-hwaccel=disabled', + '-Dd3d9-hwaccel=disabled', + '-Dgl-dxinterop-d3d9=disabled', + '-Dios-gl=disabled', + '-Dvideotoolbox-gl=disabled', + '-Dvideotoolbox-pl=disabled', + '-Dmacos-10-15-4-features=disabled', + '-Dmacos-11-features=disabled', + '-Dmacos-11-3-features=disabled', + '-Dmacos-12-features=disabled', + '-Dmacos-cocoa-cb=disabled', + '-Dmacos-media-player=disabled', + '-Dmacos-touchbar=disabled', + '-Dswift-build=disabled', + '-Dwin32-smtc=disabled', + '-Dhtml-build=disabled', + '-Dmanpage-build=disabled', + '-Dpdf-build=disabled', +]); + +const BUILD_RECIPES = Object.freeze({ + freetype: Object.freeze({ + buildSystem: 'configure', + sharedOnly: true, + args: Object.freeze([ + '--enable-shared', + '--disable-static', + '--without-brotli', + '--without-bzip2', + '--without-harfbuzz', + '--without-png', + '--without-zlib', + ]), + }), + fribidi: Object.freeze({ + buildSystem: 'configure', + sharedOnly: true, + args: Object.freeze([ + '--enable-shared', + '--disable-static', + '--disable-docs', + '--disable-bin', + ]), + }), + harfbuzz: Object.freeze({ + buildSystem: 'meson', + sharedOnly: true, + args: Object.freeze([ + '-Dglib=disabled', + '-Dgobject=disabled', + '-Dcairo=disabled', + '-Dchafa=disabled', + '-Dicu=disabled', + '-Dfreetype=enabled', + '-Dtests=disabled', + '-Dintrospection=disabled', + '-Ddocs=disabled', + '-Dutilities=disabled', + '-Dbenchmark=disabled', + ]), + }), + expat: Object.freeze({ + buildSystem: 'cmake', + sharedOnly: true, + args: Object.freeze([ + '-DEXPAT_SHARED_LIBS=ON', + '-DEXPAT_BUILD_TOOLS=OFF', + '-DEXPAT_BUILD_EXAMPLES=OFF', + '-DEXPAT_BUILD_TESTS=OFF', + '-DEXPAT_BUILD_DOCS=OFF', + ]), + }), + fontconfig: Object.freeze({ + buildSystem: 'meson', + sharedOnly: true, + args: Object.freeze([ + '-Ddoc=disabled', + '-Dtests=disabled', + '-Dtools=disabled', + '-Dcache-build=disabled', + '-Dnls=disabled', + '-Dxml-backend=expat', + '-Dbaseconfig-dir=/etc/fonts', + '-Dconfig-dir=/etc/fonts/conf.d', + '-Dtemplate-dir=/usr/share/fontconfig/conf.avail', + '-Dcache-dir=/var/cache/fontconfig', + '-Dxml-dir=/usr/share/xml/fontconfig', + ]), + }), + libass: Object.freeze({ + buildSystem: 'configure', + sharedOnly: true, + args: Object.freeze([ + '--enable-shared', + '--disable-static', + '--enable-fontconfig', + '--disable-coretext', + '--disable-directwrite', + '--disable-libunibreak', + ]), + }), + openssl: Object.freeze({ + buildSystem: 'openssl', + sharedOnly: true, + args: Object.freeze([ + 'shared', + 'no-apps', + 'no-docs', + 'no-tests', + 'no-engine', + 'no-legacy', + 'no-module', + 'no-weak-ssl-ciphers', + '--openssldir=/etc/ssl', + ]), + }), + ffmpeg: Object.freeze({ + buildSystem: 'ffmpeg', + sharedOnly: true, + args: FFMPEG_CONFIGURE_FLAGS, + }), + libplacebo: Object.freeze({ + buildSystem: 'meson', + sharedOnly: true, + args: Object.freeze([ + '-Dopengl=enabled', + '-Dvulkan=disabled', + '-Dvk-proc-addr=disabled', + '-Dglslang=disabled', + '-Dshaderc=disabled', + '-Dlcms=disabled', + '-Ddovi=disabled', + '-Dlibdovi=disabled', + '-Ddemos=false', + '-Dtests=false', + '-Dbench=false', + '-Dfuzz=false', + '-Dunwind=disabled', + '-Dxxhash=disabled', + ]), + }), + hwdata: Object.freeze({ + buildSystem: 'data', + sharedOnly: false, + args: Object.freeze([]), + }), + 'libdisplay-info': Object.freeze({ + buildSystem: 'meson', + sharedOnly: true, + args: Object.freeze([]), + }), + mpv: Object.freeze({ + buildSystem: 'meson', + sharedOnly: true, + args: MPV_MESON_FLAGS, + }), +}); + +const REQUIRED_TOOLS = Object.freeze([ + 'cc', + 'cmake', + 'curl', + 'git', + 'gperf', + 'make', + 'meson', + 'nasm', + 'ninja', + 'patchelf', + 'perl', + 'pkg-config', + 'python3', + 'readelf', + 'tar', +]); + +const MINIMUM_TOOL_VERSIONS = Object.freeze({ + cc: '9.0.0', + cmake: '3.16.0', + curl: '7.71.0', + git: '2.30.0', + gperf: '3.1.0', + make: '4.0.0', + meson: '1.6.0', + nasm: '2.15.05', + ninja: '1.10.0', + patchelf: '0.14.0', + perl: '5.30.0', + 'pkg-config': '0.29.0', + python3: '3.8.0', + readelf: '2.35.0', + tar: '1.30.0', +}); + +const DEFAULT_SYSTEM_PKG_CONFIG_DIRS = Object.freeze([ + '/usr/lib/x86_64-linux-gnu/pkgconfig', + '/usr/lib64/pkgconfig', + '/usr/lib/pkgconfig', + '/usr/share/pkgconfig', +]); + +const EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES = Object.freeze([ + 'alsa', + 'egl', + 'gbm', + 'gl', + 'libdrm', + 'libpulse', + 'libva', + 'libva-drm', +]); + +const GLIBC_TOOLCHAIN_ALLOWLIST = Object.freeze([ + 'ld-linux-x86-64.so.2', + 'libc.so.6', + 'libdl.so.2', + 'libgcc_s.so.1', + 'libm.so.6', + 'libpthread.so.0', + 'librt.so.1', + 'libstdc++.so.6', +]); + +const EXTERNAL_SYSTEM_LIBRARIES = Object.freeze( + [ + { + name: 'libEGL.so.1', + interface: 'EGL', + reason: 'System graphics-driver interface used by the frame-copy helper.', + }, + { + name: 'libGL.so.1', + interface: 'OpenGL', + reason: 'System OpenGL compatibility interface supplied by the graphics stack.', + }, + { + name: 'libGLX.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.', + }, + { + name: 'libOpenGL.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL interface supplied by the graphics stack.', + }, + { + name: 'libasound.so.2', + interface: 'ALSA', + reason: 'Linux system audio interface intentionally used by libmpv.', + }, + { + name: 'libdrm.so.2', + interface: 'DRM', + reason: 'Kernel graphics interface used by system GBM and VA-API drivers.', + }, + { + name: 'libgbm.so.1', + interface: 'GBM', + reason: 'System graphics-buffer interface used by headless EGL rendering.', + }, + { + name: 'libpulse.so.0', + interface: 'PulseAudio', + reason: 'Linux desktop audio interface intentionally used by libmpv.', + }, + { + name: 'libva-drm.so.2', + interface: 'VA-API DRM', + reason: 'System VA-API DRM interface used for hardware decoding.', + }, + { + name: 'libva.so.2', + interface: 'VA-API', + reason: 'System video-acceleration interface used for hardware decoding.', + }, + ].map((externalLibrary) => Object.freeze(externalLibrary)) +); + +const RUNTIME_EXTERNAL_CONFIGURATION = Object.freeze({ + fontconfig: Object.freeze({ + configDirectory: '/etc/fonts', + templateDirectory: '/usr/share/fontconfig', + cacheDirectory: '/var/cache/fontconfig', + ownership: 'system', + }), + openssl: Object.freeze({ + configFile: '/etc/ssl/openssl.cnf', + certificateFile: '/etc/ssl/cert.pem', + certificateDirectory: '/etc/ssl/certs', + ownership: 'system', + }), +}); + +const OUTPUT_OWNERSHIP_MARKER = '.iptvnator-linux-runtime-owner'; +const OUTPUT_OWNERSHIP_MARKER_CONTENT = + 'iptvnator-embedded-mpv-linux-runtime-v1\n'; + +const PORTABLE_ABI_BASELINE = Object.freeze({ + distribution: 'Ubuntu 22.04', + glibcMaximum: '2.35', + glibcxxMaximum: '3.4.30', +}); + +const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const externalSystemLibraryNames = new Set( + EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name) +); +const allowedExternalLibraryNames = new Set([ + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...externalSystemLibraryNames, +]); + +function assertArchiveMatchesPin(sourcePackage, actualSha256) { + if (actualSha256 !== sourcePackage.expectedSha256) { + throw new Error( + `${sourcePackage.id} archive SHA-256 mismatch: expected ${sourcePackage.expectedSha256}, received ${actualSha256}.` + ); + } +} + +function assertGitCommitMatchesPin(sourcePackage, actualGitCommit) { + if (actualGitCommit !== sourcePackage.expectedGitCommit) { + throw new Error( + `${sourcePackage.id} git commit mismatch: expected ${sourcePackage.expectedGitCommit}, received ${actualGitCommit}.` + ); + } +} + +function assertGitSubmodulesMatchPin(sourcePackage, actualSubmodules) { + if ( + !isDeepStrictEqual(actualSubmodules, sourcePackage.expectedSubmodules) + ) { + throw new Error( + `${sourcePackage.id} git submodules do not match the exact pinned recursive records.` + ); + } +} + +function canonicalizeGitSubmoduleStatus(output) { + if (typeof output !== 'string') { + throw new Error('Git submodule status output must be a string.'); + } + if (output.trim() === '') { + return []; + } + + const records = []; + const seenRecords = new Set(); + const seenPaths = new Set(); + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line === '') { + continue; + } + if (/^[-+U]/.test(line)) { + throw new Error( + `Git submodule checkout is not clean: ${rawLine.trimEnd()}` + ); + } + const match = line.match( + /^([a-f0-9]{40,64})\s+([A-Za-z0-9_+./-]+)(?:\s+\([^\r\n]*\))?$/ + ); + if (!match) { + throw new Error( + `Git submodule status contains an unsafe or malformed record: ${rawLine.trimEnd()}` + ); + } + const submodulePath = match[2]; + if ( + path.posix.isAbsolute(submodulePath) || + submodulePath + .split('/') + .some( + (segment) => + segment === '' || segment === '.' || segment === '..' + ) + ) { + throw new Error( + `Git submodule status contains an unsafe path: ${submodulePath}` + ); + } + const record = `${match[1]} ${submodulePath}`; + if (seenRecords.has(record) || seenPaths.has(submodulePath)) { + throw new Error( + `Git submodule status contains a duplicate record: ${record}` + ); + } + seenRecords.add(record); + seenPaths.add(submodulePath); + records.push(record); + } + return records; +} + +function parseVersion(value) { + if (typeof value !== 'string') { + return null; + } + const match = value.match(/\b(\d+\.\d+(?:\.\d+)*)\b/); + return match?.[1] ?? null; +} + +function compareVersions(left, right) { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) { + return Math.sign(difference); + } + } + return 0; +} + +function assertMinimumToolVersions(toolVersions) { + for (const tool of REQUIRED_TOOLS) { + const declaredVersion = toolVersions?.[tool]; + if (typeof declaredVersion !== 'string' || !declaredVersion.trim()) { + throw new Error(`Missing required tool version for ${tool}.`); + } + const actualVersion = parseVersion(declaredVersion); + if (!actualVersion) { + throw new Error( + `Unable to parse required tool version for ${tool}: ${declaredVersion}.` + ); + } + const minimumVersion = MINIMUM_TOOL_VERSIONS[tool]; + if (compareVersions(actualVersion, minimumVersion) < 0) { + throw new Error( + `${tool} ${actualVersion} is unsupported; ${tool} requires ${minimumVersion} or newer for Linux runtime builds.` + ); + } + } +} + +function assertUniqueMesonOptionAssignments(buildRecipes) { + if (!buildRecipes || typeof buildRecipes !== 'object') { + throw new TypeError('Linux runtime build recipes must be an object.'); + } + for (const [packageId, recipe] of Object.entries(buildRecipes)) { + if (recipe?.buildSystem !== 'meson') { + continue; + } + if (!Array.isArray(recipe.args)) { + throw new Error( + `${packageId} Meson recipe must declare an argument array.` + ); + } + + const assignmentsByOption = new Map(); + for (const flag of recipe.args) { + const match = + typeof flag === 'string' ? flag.match(/^(-D[^=]+)=/) : null; + if (!match) { + continue; + } + const option = match[1]; + const assignmentCount = (assignmentsByOption.get(option) ?? 0) + 1; + assignmentsByOption.set(option, assignmentCount); + if (assignmentCount > 1) { + throw new Error( + `${packageId} Meson recipe must assign ${option} exactly once.` + ); + } + } + } +} + +function lstatIfExists(fileSystem, filePath) { + try { + return fileSystem.lstatSync(filePath); + } catch (error) { + if (error?.code === 'ENOENT') { + return null; + } + throw error; + } +} + +function assertOwnedOutputDestination(outputPrefix, fileSystem = fs) { + const outputStat = lstatIfExists(fileSystem, outputPrefix); + if (!outputStat) { + return; + } + if (!outputStat.isDirectory() || outputStat.isSymbolicLink()) { + throw new Error( + `Existing output ${outputPrefix} must be a non-symbolic-link directory carrying the IPTVnator ownership marker.` + ); + } + + const markerPath = path.join(outputPrefix, OUTPUT_OWNERSHIP_MARKER); + const markerStat = lstatIfExists(fileSystem, markerPath); + if ( + !markerStat || + !markerStat.isFile() || + markerStat.isSymbolicLink() || + fileSystem.readFileSync(markerPath, 'utf8') !== + OUTPUT_OWNERSHIP_MARKER_CONTENT + ) { + throw new Error( + `Existing output ${outputPrefix} is missing the valid IPTVnator ownership marker.` + ); + } +} + +function ownedStagingPrefixPath(outputPrefix, token) { + return path.join( + path.dirname(outputPrefix), + `.${path.basename(outputPrefix)}.iptvnator-stage-${token}` + ); +} + +function createOwnedStagingPrefix( + outputPrefix, + { fileSystem = fs, token = crypto.randomBytes(8).toString('hex') } = {} +) { + assertOwnedOutputDestination(outputPrefix, fileSystem); + const outputParent = path.dirname(outputPrefix); + const stagingPrefix = ownedStagingPrefixPath(outputPrefix, token); + if (lstatIfExists(fileSystem, stagingPrefix)) { + throw new Error( + `Refusing to reuse existing Linux runtime staging path ${stagingPrefix}.` + ); + } + + fileSystem.mkdirSync(outputParent, { recursive: true }); + fileSystem.mkdirSync(stagingPrefix); + try { + fileSystem.writeFileSync( + path.join(stagingPrefix, OUTPUT_OWNERSHIP_MARKER), + OUTPUT_OWNERSHIP_MARKER_CONTENT, + { mode: 0o644 } + ); + } catch (error) { + fileSystem.rmSync(stagingPrefix, { recursive: true, force: true }); + throw error; + } + return stagingPrefix; +} + +function publishOwnedOutput({ + outputPrefix, + stagingPrefix, + fileSystem = fs, + token = crypto.randomBytes(8).toString('hex'), +}) { + assertOwnedOutputDestination(outputPrefix, fileSystem); + assertOwnedOutputDestination(stagingPrefix, fileSystem); + if (!lstatIfExists(fileSystem, stagingPrefix)) { + throw new Error( + `Linux runtime staging prefix does not exist: ${stagingPrefix}.` + ); + } + + const backupPrefix = path.join( + path.dirname(outputPrefix), + `.${path.basename(outputPrefix)}.iptvnator-backup-${token}` + ); + if (lstatIfExists(fileSystem, backupPrefix)) { + throw new Error( + `Refusing to reuse existing Linux runtime backup path ${backupPrefix}.` + ); + } + + let movedPreviousOutput = false; + let published = false; + try { + if (lstatIfExists(fileSystem, outputPrefix)) { + fileSystem.renameSync(outputPrefix, backupPrefix); + movedPreviousOutput = true; + } + fileSystem.renameSync(stagingPrefix, outputPrefix); + published = true; + if (movedPreviousOutput) { + fileSystem.rmSync(backupPrefix, { + recursive: true, + force: true, + }); + } + } catch (error) { + if ( + movedPreviousOutput && + !lstatIfExists(fileSystem, outputPrefix) && + lstatIfExists(fileSystem, backupPrefix) + ) { + fileSystem.renameSync(backupPrefix, outputPrefix); + } + throw error; + } finally { + if (lstatIfExists(fileSystem, stagingPrefix)) { + fileSystem.rmSync(stagingPrefix, { + recursive: true, + force: true, + }); + } + if (published && lstatIfExists(fileSystem, backupPrefix)) { + fileSystem.rmSync(backupPrefix, { + recursive: true, + force: true, + }); + } + } +} + +function joinEnvironmentParts(parts, separator = ' ') { + return parts.filter((value) => value && value.trim()).join(separator); +} + +function resolveSystemPkgConfigDirs(environment = {}) { + const explicitDirectories = + environment.IPTVNATOR_EMBEDDED_MPV_SYSTEM_PKG_CONFIG_DIRS; + if (!explicitDirectories) { + return [...DEFAULT_SYSTEM_PKG_CONFIG_DIRS]; + } + + const directories = explicitDirectories + .split(path.delimiter) + .map((directory) => directory.trim()) + .filter(Boolean); + if ( + directories.length === 0 || + directories.some((directory) => !path.isAbsolute(directory)) + ) { + throw new Error( + 'IPTVNATOR_EMBEDDED_MPV_SYSTEM_PKG_CONFIG_DIRS must contain only absolute paths.' + ); + } + return [ + ...new Set(directories.map((directory) => path.normalize(directory))), + ]; +} + +function createBuildEnvironment({ + prefix, + baseEnv = process.env, + systemPkgConfigDirs = [], +}) { + const prefixPkgConfigDirs = [ + path.join(prefix, 'lib', 'pkgconfig'), + path.join(prefix, 'share', 'pkgconfig'), + ]; + const pkgConfigLibDirs = [ + ...new Set([ + ...prefixPkgConfigDirs, + ...systemPkgConfigDirs.filter(Boolean), + ]), + ]; + const prefixLibDir = path.join(prefix, 'lib'); + const ignoredVariables = new Set([ + 'CFLAGS', + 'CPPFLAGS', + 'CXXFLAGS', + 'LDFLAGS', + 'LD_LIBRARY_PATH', + 'LIBRARY_PATH', + 'CPATH', + 'C_INCLUDE_PATH', + 'CPLUS_INCLUDE_PATH', + 'CMAKE_PREFIX_PATH', + 'CMAKE_LIBRARY_PATH', + 'CMAKE_INCLUDE_PATH', + 'FONTCONFIG_PATH', + 'OPENSSL_MODULES', + ]); + const inheritedEnvironment = Object.fromEntries( + Object.entries(baseEnv).filter( + ([name]) => + !ignoredVariables.has(name) && !name.startsWith('PKG_CONFIG') + ) + ); + + return { + ...inheritedEnvironment, + PATH: joinEnvironmentParts( + [path.join(prefix, 'bin'), baseEnv.PATH], + path.delimiter + ), + PKG_CONFIG_PATH: prefixPkgConfigDirs.join(path.delimiter), + PKG_CONFIG_LIBDIR: pkgConfigLibDirs.join(path.delimiter), + CMAKE_PREFIX_PATH: prefix, + CPPFLAGS: `-I${path.join(prefix, 'include')}`, + CFLAGS: joinEnvironmentParts([ + '-fPIC', + `-I${path.join(prefix, 'include')}`, + ]), + CXXFLAGS: joinEnvironmentParts([ + '-fPIC', + `-I${path.join(prefix, 'include')}`, + ]), + LDFLAGS: joinEnvironmentParts([ + `-L${prefixLibDir}`, + `-Wl,-rpath-link,${prefixLibDir}`, + ]), + LD_LIBRARY_PATH: prefixLibDir, + FONTCONFIG_PATH: path.join(prefix, 'etc', 'fonts'), + OPENSSL_MODULES: path.join(prefixLibDir, 'ossl-modules'), + }; +} + +function createPinnedHwdataPkgConfigEnvironment({ buildEnvironment, prefix }) { + if (!buildEnvironment || typeof buildEnvironment !== 'object') { + throw new TypeError( + 'Pinned hwdata requires the Linux runtime build environment.' + ); + } + const prefixPkgConfigDirs = [ + path.join(prefix, 'lib', 'pkgconfig'), + path.join(prefix, 'share', 'pkgconfig'), + ]; + const pinnedPkgConfigPath = prefixPkgConfigDirs.join(path.delimiter); + return { + ...buildEnvironment, + PKG_CONFIG_PATH: pinnedPkgConfigPath, + PKG_CONFIG_LIBDIR: pinnedPkgConfigPath, + }; +} + +function assertPinnedHwdataResolution({ + pcFileDir, + pkgDataDir, + prefix, + version, +}) { + const expectedPcFileDir = path.join(prefix, 'share', 'pkgconfig'); + const expectedPkgDataDir = path.join(prefix, 'share', 'hwdata'); + if (path.resolve(pcFileDir) !== path.resolve(expectedPcFileDir)) { + throw new Error( + `Pinned hwdata pkg-config metadata resolved outside the staged prefix: ${pcFileDir}.` + ); + } + if (path.resolve(pkgDataDir) !== path.resolve(expectedPkgDataDir)) { + throw new Error( + `Pinned hwdata data resolved outside the staged prefix: ${pkgDataDir}.` + ); + } + const hwdataPackage = SOURCE_PACKAGES.find(({ id }) => id === 'hwdata'); + if (version !== hwdataPackage.version) { + throw new Error( + `Pinned hwdata version mismatch: expected ${hwdataPackage.version}, received ${version}.` + ); + } +} + +function preparePinnedHwdataBuildInput({ + buildEnvironment, + fileSystem = fs, + prefix, + runCapture, + sourcePath, +}) { + if (typeof runCapture !== 'function') { + throw new TypeError( + 'Pinned hwdata preparation requires a command capture function.' + ); + } + const hwdataPackage = SOURCE_PACKAGES.find(({ id }) => id === 'hwdata'); + const sourceRoot = fileSystem.realpathSync(sourcePath); + const sourceInputPath = path.join( + sourcePath, + hwdataPackage.buildInput.relativePath + ); + const sourceInputStat = fileSystem.lstatSync(sourceInputPath); + if (!sourceInputStat.isFile() || sourceInputStat.isSymbolicLink()) { + throw new Error( + `Pinned hwdata build input must be a regular file: ${sourceInputPath}.` + ); + } + const realSourceInputPath = fileSystem.realpathSync(sourceInputPath); + assertPathInside( + sourceRoot, + realSourceInputPath, + 'Pinned hwdata build input' + ); + const pnpIds = fileSystem.readFileSync(realSourceInputPath); + if (pnpIds.length === 0) { + throw new Error('Pinned hwdata pnp.ids build input must not be empty.'); + } + + const pkgDataDir = path.join(prefix, 'share', 'hwdata'); + const pcFileDir = path.join(prefix, 'share', 'pkgconfig'); + fileSystem.mkdirSync(pkgDataDir, { recursive: true }); + fileSystem.mkdirSync(pcFileDir, { recursive: true }); + fileSystem.writeFileSync(path.join(pkgDataDir, 'pnp.ids'), pnpIds, { + mode: 0o644, + }); + fileSystem.writeFileSync( + path.join(pcFileDir, 'hwdata.pc'), + [ + `prefix=${prefix}`, + 'datadir=${prefix}/share', + `pkgdatadir=${pkgDataDir}`, + '', + 'Name: hwdata', + 'Description: Pinned PNP hardware identification data', + `Version: ${hwdataPackage.version}`, + '', + ].join('\n'), + { mode: 0o644 } + ); + + const pinnedEnvironment = createPinnedHwdataPkgConfigEnvironment({ + buildEnvironment, + prefix, + }); + const captureOptions = { env: pinnedEnvironment }; + const resolvedPcFileDir = runCapture( + 'pkg-config', + ['--variable=pcfiledir', 'hwdata'], + captureOptions + ); + const resolvedPkgDataDir = runCapture( + 'pkg-config', + ['--variable=pkgdatadir', 'hwdata'], + captureOptions + ); + const resolvedVersion = runCapture( + 'pkg-config', + ['--modversion', 'hwdata'], + captureOptions + ); + assertPinnedHwdataResolution({ + pcFileDir: resolvedPcFileDir, + pkgDataDir: resolvedPkgDataDir, + prefix, + version: resolvedVersion, + }); + return pinnedEnvironment; +} + +function resolveLinuxPackageBuildEnvironment(packageId, context) { + if (packageId !== 'libdisplay-info') { + return context.buildEnvironment; + } + if (!context.hwdataBuildEnvironment) { + throw new Error( + 'libdisplay-info requires the staged pinned hwdata build environment.' + ); + } + return context.hwdataBuildEnvironment; +} + +function sha256Buffer(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +function runtimeLibraryNames(libDir) { + return fs + .readdirSync(libDir, { withFileTypes: true }) + .filter( + (entry) => + (entry.isFile() || entry.isSymbolicLink()) && + SHARED_LIBRARY_PATTERN.test(entry.name) + ) + .map((entry) => entry.name) + .sort(); +} + +function assertPathInside(parentPath, candidatePath, label) { + const relativePath = path.relative(parentPath, candidatePath); + if ( + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + throw new Error(`${label} resolves outside ${parentPath}.`); + } +} + +function materializeLibrarySymlinks(libDir, selectedNames = null) { + const realLibDir = fs.realpathSync(libDir); + for (const name of runtimeLibraryNames(libDir)) { + if (selectedNames && !selectedNames.has(name)) { + continue; + } + const libraryPath = path.join(libDir, name); + const stat = fs.lstatSync(libraryPath); + if (!stat.isSymbolicLink()) { + continue; + } + + const realLibraryPath = fs.realpathSync(libraryPath); + assertPathInside( + realLibDir, + realLibraryPath, + `Runtime library alias ${name}` + ); + const targetStat = fs.statSync(realLibraryPath); + if (!targetStat.isFile()) { + throw new Error( + `Runtime library alias ${name} does not resolve to a regular file.` + ); + } + const contents = fs.readFileSync(realLibraryPath); + fs.unlinkSync(libraryPath); + fs.writeFileSync(libraryPath, contents, { + mode: targetStat.mode & 0o777, + }); + } +} + +function selectReachableRuntimeLibraryNames(entries) { + if (!Array.isArray(entries)) { + throw new TypeError('Runtime dynamic entries must be an array.'); + } + + const entriesByName = new Map(); + for (const entry of entries) { + if ( + !entry || + typeof entry.name !== 'string' || + !SHARED_LIBRARY_PATTERN.test(entry.name) + ) { + throw new Error( + 'Runtime dynamic entry has an invalid library name.' + ); + } + if (entriesByName.has(entry.name)) { + throw new Error( + `Runtime dynamic entries contain duplicate library ${entry.name}.` + ); + } + entriesByName.set(entry.name, entry); + } + + const linkerAlias = entriesByName.get('libmpv.so'); + if (!linkerAlias) { + throw new Error( + 'Linux runtime must contain the libmpv.so linker alias.' + ); + } + if ( + typeof linkerAlias.soname !== 'string' || + !SHARED_LIBRARY_PATTERN.test(linkerAlias.soname) || + !entriesByName.has(linkerAlias.soname) + ) { + throw new Error( + 'libmpv.so must declare a bundled SONAME before runtime pruning.' + ); + } + + const reachableNames = new Set(['libmpv.so']); + const pendingNames = [linkerAlias.soname]; + while (pendingNames.length > 0) { + const libraryName = pendingNames.shift(); + if (reachableNames.has(libraryName)) { + continue; + } + reachableNames.add(libraryName); + const entry = entriesByName.get(libraryName); + if (!entry) { + throw new Error( + `Reachable runtime library ${libraryName} is missing its dynamic entry.` + ); + } + for (const neededName of entry.needed ?? []) { + if ( + entriesByName.has(neededName) && + !reachableNames.has(neededName) + ) { + pendingNames.push(neededName); + } + } + } + + return [...reachableNames].sort(); +} + +function retainRuntimeLibraries(libDir, retainedNames) { + if (!Array.isArray(retainedNames) || retainedNames.length === 0) { + throw new Error('Runtime retention list must be a non-empty array.'); + } + const retainedNameSet = new Set(retainedNames); + if (retainedNameSet.size !== retainedNames.length) { + throw new Error('Runtime retention list contains duplicate libraries.'); + } + + const availableNames = runtimeLibraryNames(libDir); + for (const retainedName of retainedNameSet) { + if (!availableNames.includes(retainedName)) { + throw new Error( + `Retained runtime library does not exist: ${retainedName}.` + ); + } + } + + materializeLibrarySymlinks(libDir, retainedNameSet); + for (const libraryName of availableNames) { + if (!retainedNameSet.has(libraryName)) { + fs.rmSync(path.join(libDir, libraryName)); + } + } + + for (const retainedName of retainedNameSet) { + const retainedPath = path.join(libDir, retainedName); + const stat = fs.lstatSync(retainedPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Retained runtime library ${retainedName} must be a materialized regular file.` + ); + } + } +} + +function createRuntimeFileRecords(libDir) { + return runtimeLibraryNames(libDir).map((name) => { + const libraryPath = path.join(libDir, name); + const stat = fs.lstatSync(libraryPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Runtime library ${name} must be a materialized regular file.` + ); + } + const contents = fs.readFileSync(libraryPath); + return { + name, + size: contents.length, + sha256: sha256Buffer(contents), + }; + }); +} + +function parseReadelfDynamic(output) { + const dynamic = { + needed: [], + rpath: [], + runpath: [], + soname: null, + }; + const dynamicEntryPattern = + /\((NEEDED|RPATH|RUNPATH|SONAME)\)[^[]*\[([^\]]*)\]/g; + for (const match of output.matchAll(dynamicEntryPattern)) { + const [, tag, value] = match; + if (tag === 'SONAME') { + dynamic.soname = value; + continue; + } + if (tag === 'NEEDED') { + dynamic.needed.push(value); + continue; + } + const field = tag.toLowerCase(); + dynamic[field].push( + ...value.split(':').filter((pathEntry) => pathEntry.length > 0) + ); + } + + for (const field of ['needed', 'rpath', 'runpath']) { + dynamic[field] = [...new Set(dynamic[field])].sort(); + } + return dynamic; +} + +function parseReadelfVersionInfo(output, name) { + if (typeof output !== 'string' || typeof name !== 'string' || !name) { + throw new TypeError( + 'readelf version output and runtime library name are required.' + ); + } + + let requiredGlibc = null; + let requiredGlibcxx = null; + const versionPattern = /\b(GLIBCXX|GLIBC)_(\d+(?:\.\d+)+)\b/g; + for (const [, namespace, version] of output.matchAll(versionPattern)) { + if ( + namespace === 'GLIBC' && + (!requiredGlibc || compareVersions(version, requiredGlibc) > 0) + ) { + requiredGlibc = version; + } + if ( + namespace === 'GLIBCXX' && + (!requiredGlibcxx || compareVersions(version, requiredGlibcxx) > 0) + ) { + requiredGlibcxx = version; + } + } + + return { name, requiredGlibc, requiredGlibcxx }; +} + +function assertPortableAbiRecords(records) { + if (!Array.isArray(records)) { + throw new TypeError('Runtime ABI records must be an array.'); + } + for (const record of records) { + for (const [field, maximum] of [ + ['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum], + ['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum], + ]) { + const version = record?.[field]; + if (version && compareVersions(version, maximum) > 0) { + throw new Error( + `Portable ABI baseline ${PORTABLE_ABI_BASELINE.distribution} rejects newer symbol ${version} required by ${record.name}; maximum ${field} is ${maximum}.` + ); + } + } + } +} + +function assertPortableBuildHostGlibc(glibcVersion) { + if ( + typeof glibcVersion !== 'string' || + !/^\d+(?:\.\d+)+$/.test(glibcVersion) + ) { + throw new Error( + 'Unable to determine the Linux build host glibc version.' + ); + } + if (compareVersions(glibcVersion, PORTABLE_ABI_BASELINE.glibcMaximum) > 0) { + throw new Error( + `Build host glibc ${glibcVersion} exceeds the portable ABI baseline ${PORTABLE_ABI_BASELINE.distribution} maximum ${PORTABLE_ABI_BASELINE.glibcMaximum}.` + ); + } +} + +function validateRuntimeDependencyClosure({ + entries, + runtimeFileNames, + buildPrefix, +}) { + if (!Array.isArray(entries) || !Array.isArray(runtimeFileNames)) { + throw new TypeError( + 'Runtime closure entries and runtime file names must be arrays.' + ); + } + + const bundledNames = new Set(runtimeFileNames); + const entryNames = new Set(); + const externalDependencies = new Set(); + const normalizedEntries = [...entries] + .map((entry) => ({ + name: entry.name, + soname: entry.soname ?? null, + needed: [...new Set(entry.needed ?? [])].sort(), + rpath: [...new Set(entry.rpath ?? [])].sort(), + runpath: [...new Set(entry.runpath ?? [])].sort(), + })) + .sort((left, right) => left.name.localeCompare(right.name)); + + for (const entry of normalizedEntries) { + if (!bundledNames.has(entry.name)) { + throw new Error( + `Dynamic closure contains undeclared runtime file ${entry.name}.` + ); + } + if (entryNames.has(entry.name)) { + throw new Error( + `Dynamic closure contains duplicate runtime file ${entry.name}.` + ); + } + entryNames.add(entry.name); + + if ( + entry.soname !== null && + (typeof entry.soname !== 'string' || + !SHARED_LIBRARY_PATTERN.test(entry.soname) || + path.basename(entry.soname) !== entry.soname) + ) { + throw new Error( + `${entry.name} SONAME must be null or a safe shared-library basename.` + ); + } + if ( + entry.name === 'libmpv.so' && + (typeof entry.soname !== 'string' || + !/^libmpv\.so\.\d+(?:\.\d+)*$/.test(entry.soname) || + !bundledNames.has(entry.soname)) + ) { + throw new Error( + 'libmpv.so must declare a versioned SONAME present in the runtime closure.' + ); + } + + if (entry.rpath.length > 0) { + throw new Error( + `${entry.name} has forbidden RPATH ${entry.rpath.join(':')}.` + ); + } + if (entry.runpath.length !== 1 || entry.runpath[0] !== '$ORIGIN') { + const renderedRunpath = + entry.runpath.length > 0 ? entry.runpath.join(':') : ''; + throw new Error( + `${entry.name} RUNPATH must be exactly $ORIGIN; got ${renderedRunpath}.` + ); + } + if ( + buildPrefix && + [...entry.rpath, ...entry.runpath].some((value) => + value.includes(buildPrefix) + ) + ) { + throw new Error( + `${entry.name} RPATH/RUNPATH contains build prefix ${buildPrefix}.` + ); + } + + for (const dependencyName of entry.needed) { + if (bundledNames.has(dependencyName)) { + continue; + } + if (!allowedExternalLibraryNames.has(dependencyName)) { + throw new Error( + `Runtime dependency is not bundled or allowlisted: ${entry.name} -> ${dependencyName}.` + ); + } + externalDependencies.add(dependencyName); + } + } + + for (const runtimeFileName of bundledNames) { + if (!entryNames.has(runtimeFileName)) { + throw new Error( + `Runtime library ${runtimeFileName} is missing from the dynamic closure.` + ); + } + } + + return { + entries: normalizedEntries, + externalDependencies: [...externalDependencies].sort(), + }; +} + +function parseCliInvocation({ platform, arch, argv, cwd }) { + if (platform !== 'linux' || arch !== 'x64') { + throw new Error( + `Embedded MPV runtime source builds are supported on Linux x64 only; received ${platform}/${arch}.` + ); + } + + const args = argv[0] === '--' ? argv.slice(1) : argv; + if (args.length !== 1 || !args[0]) { + throw new Error( + [ + 'Usage: node tools/embedded-mpv/build-linux-runtime.mjs ', + '', + 'Builds the pinned LGPL-compatible Linux x64 libmpv runtime from source.', + ].join('\n') + ); + } + + return { prefix: path.resolve(cwd, args[0]) }; +} + +function sourceManifestMetadata(sourceRecord) { + const metadata = { + version: sourceRecord.version, + sourceUrl: sourceRecord.sourceUrl, + ...(sourceRecord.sourceTag + ? { sourceTag: sourceRecord.sourceTag } + : {}), + ...(sourceRecord.sourceSha256 + ? { sourceSha256: sourceRecord.sourceSha256 } + : {}), + ...(sourceRecord.sourceGitCommit + ? { sourceGitCommit: sourceRecord.sourceGitCommit } + : {}), + ...(sourceRecord.sourceSubmodules + ? { sourceSubmodules: [...sourceRecord.sourceSubmodules] } + : {}), + ...(sourceRecord.buildInput + ? { buildInput: { ...sourceRecord.buildInput } } + : {}), + license: sourceRecord.license, + }; + + if ( + sourceRecord.sourceKind === 'archive' && + !SHA256_PATTERN.test(sourceRecord.sourceSha256 ?? '') + ) { + throw new Error( + `Archive source ${sourceRecord.id} is missing its downloaded SHA-256 digest.` + ); + } + if ( + sourceRecord.sourceKind === 'git' && + !/^[a-f0-9]{40,64}$/.test(sourceRecord.sourceGitCommit ?? '') + ) { + throw new Error( + `Git source ${sourceRecord.id} is missing its exact commit digest.` + ); + } + return metadata; +} + +function createLinuxRuntimeManifest({ + sourceRecords, + runtimeFiles, + abiRecords, + dependencyClosure, + buildHost, + generatedAt = new Date().toISOString(), + ffmpegConfigureFlags = FFMPEG_CONFIGURE_FLAGS, + mpvMesonFlags = MPV_MESON_FLAGS, +}) { + const packages = {}; + for (const sourcePackage of SOURCE_PACKAGES) { + const sourceRecord = sourceRecords[sourcePackage.id]; + if (!sourceRecord) { + throw new Error(`Missing source metadata for ${sourcePackage.id}.`); + } + if (sourcePackage.sourceKind === 'archive') { + assertArchiveMatchesPin(sourcePackage, sourceRecord.sourceSha256); + } else { + assertGitCommitMatchesPin( + sourcePackage, + sourceRecord.sourceGitCommit + ); + assertGitSubmodulesMatchPin( + sourcePackage, + sourceRecord.sourceSubmodules + ); + } + packages[sourcePackage.id] = sourceManifestMetadata(sourceRecord); + } + assertPortableAbiRecords(abiRecords); + + const runtimeTotalBytes = runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ); + return { + schemaVersion: 1, + origin: 'vendored-lgpl-source-build', + platform: 'linux', + arch: 'x64', + generatedAt, + packages, + ffmpeg: { + ...packages.ffmpeg, + licensePolicy: + 'LGPL, built with OpenSSL and without GPL, nonfree, or version-3-only components.', + configureFlags: [...ffmpegConfigureFlags], + }, + mpv: { + ...packages.mpv, + licensePolicy: + 'LGPL-compatible libmpv built with -Dgpl=false and without the CLI player.', + mesonFlags: [...mpvMesonFlags], + }, + runtimeFiles: runtimeFiles.map((runtimeFile) => ({ ...runtimeFile })), + runtimeTotalBytes, + runtimeAbi: { + baseline: { ...PORTABLE_ABI_BASELINE }, + files: abiRecords.map((record) => ({ ...record })), + }, + runtimeExternalConfiguration: { + fontconfig: { ...RUNTIME_EXTERNAL_CONFIGURATION.fontconfig }, + openssl: { ...RUNTIME_EXTERNAL_CONFIGURATION.openssl }, + }, + runtimeDependencyClosure: { + entries: dependencyClosure.entries.map((entry) => ({ + name: entry.name, + soname: entry.soname ?? null, + needed: [...entry.needed], + rpath: [...entry.rpath], + runpath: [...entry.runpath], + })), + externalDependencies: [...dependencyClosure.externalDependencies], + }, + externalSystemLibraries: EXTERNAL_SYSTEM_LIBRARIES.map( + (externalLibrary) => ({ ...externalLibrary }) + ), + buildHost, + sourceDistribution: + 'Attach a source archive to the corresponding Linux binary release containing the exact downloaded source archives, including the pinned dual-licensed hwdata archive whose pnp.ids is compiled into the MIT-licensed libdisplay-info source archive, a checkout or git bundle of the recorded libplacebo commit and submodules, tools/embedded-mpv/build-linux-runtime.mjs, tools/embedded-mpv/build-linux-runtime.cjs, this runtime manifest, and any local patches.', + }; +} + +module.exports = { + BUILD_RECIPES, + BUILD_ORDER, + DEFAULT_SYSTEM_PKG_CONFIG_DIRS, + EXTERNAL_SYSTEM_LIBRARIES, + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES, + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, + FFMPEG_CONFIGURE_FLAGS, + GLIBC_TOOLCHAIN_ALLOWLIST, + MINIMUM_TOOL_VERSIONS, + MPV_MESON_FLAGS, + OUTPUT_OWNERSHIP_MARKER, + PORTABLE_ABI_BASELINE, + REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, + SOURCE_PACKAGES, + assertArchiveMatchesPin, + assertGitCommitMatchesPin, + assertGitSubmodulesMatchPin, + assertMinimumToolVersions, + assertOwnedOutputDestination, + assertPortableAbiRecords, + assertPortableBuildHostGlibc, + assertUniqueMesonOptionAssignments, + canonicalizeGitSubmoduleStatus, + compareVersions, + createBuildEnvironment, + createLinuxRuntimeManifest, + createOwnedStagingPrefix, + createRuntimeFileRecords, + materializeLibrarySymlinks, + ownedStagingPrefixPath, + parseCliInvocation, + parseReadelfDynamic, + parseReadelfVersionInfo, + parseVersion, + preparePinnedHwdataBuildInput, + resolveSystemPkgConfigDirs, + resolveLinuxPackageBuildEnvironment, + runtimeLibraryNames, + sha256Buffer, + publishOwnedOutput, + retainRuntimeLibraries, + selectReachableRuntimeLibraryNames, + validateRuntimeDependencyClosure, +}; diff --git a/tools/embedded-mpv/build-linux-runtime.mjs b/tools/embedded-mpv/build-linux-runtime.mjs new file mode 100644 index 000000000..e9efefd8f --- /dev/null +++ b/tools/embedded-mpv/build-linux-runtime.mjs @@ -0,0 +1,865 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { + BUILD_RECIPES, + BUILD_ORDER, + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, + MPV_MESON_FLAGS, + PORTABLE_ABI_BASELINE, + REQUIRED_TOOLS, + SOURCE_PACKAGES, + assertArchiveMatchesPin, + assertGitCommitMatchesPin, + assertMinimumToolVersions, + assertOwnedOutputDestination, + assertPortableAbiRecords, + assertPortableBuildHostGlibc, + assertUniqueMesonOptionAssignments, + canonicalizeGitSubmoduleStatus, + createBuildEnvironment, + createLinuxRuntimeManifest, + createOwnedStagingPrefix, + createRuntimeFileRecords, + ownedStagingPrefixPath, + parseCliInvocation, + parseReadelfDynamic, + parseReadelfVersionInfo, + preparePinnedHwdataBuildInput, + retainRuntimeLibraries, + resolveLinuxPackageBuildEnvironment, + resolveSystemPkgConfigDirs, + runtimeLibraryNames, + selectReachableRuntimeLibraryNames, + sha256Buffer, + publishOwnedOutput, + validateRuntimeDependencyClosure, +} = require('./build-linux-runtime.cjs'); +const { + validateLinuxRuntimeManifest, +} = require('./linux-runtime-manifest.cjs'); + +const scriptPath = fileURLToPath(import.meta.url); +const workspaceRoot = path.resolve(path.dirname(scriptPath), '..', '..'); +const sourcePackageById = new Map( + SOURCE_PACKAGES.map((sourcePackage) => [sourcePackage.id, sourcePackage]) +); + +function log(message) { + process.stdout.write(`[embedded-mpv-linux-runtime] ${message}\n`); +} + +function commandLine(command, args) { + return [command, ...args] + .map((value) => + /^[A-Za-z0-9_./:=+,-]+$/.test(value) ? value : JSON.stringify(value) + ) + .join(' '); +} + +function spawn(command, args, options, capture) { + log(commandLine(command, args)); + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env, + encoding: capture ? 'utf8' : undefined, + stdio: capture ? 'pipe' : 'inherit', + }); + + if (result.error) { + throw new Error( + `Unable to run ${commandLine(command, args)}: ${result.error.message}` + ); + } + if (result.status !== 0) { + const details = capture + ? [result.stdout, result.stderr].filter(Boolean).join('\n').trim() + : ''; + throw new Error( + `${commandLine(command, args)} failed with status ${ + result.status ?? 1 + }.${details ? `\n${details}` : ''}` + ); + } + return result; +} + +function createCommandRunner({ buildEnvironment }) { + return { + run(command, args, options = {}) { + spawn( + command, + args, + { + cwd: options.cwd ?? workspaceRoot, + env: options.env ?? buildEnvironment, + }, + false + ); + }, + runCapture(command, args, options = {}) { + const result = spawn( + command, + args, + { + cwd: options.cwd ?? workspaceRoot, + env: options.env ?? buildEnvironment, + }, + true + ); + return [result.stdout, result.stderr] + .filter(Boolean) + .join('\n') + .trim(); + }, + }; +} + +function commandExists(command) { + const result = spawnSync( + 'sh', + ['-c', 'command -v "$1" >/dev/null 2>&1', 'sh', command], + { stdio: 'ignore' } + ); + return result.status === 0; +} + +function ensureTools() { + const missingTools = REQUIRED_TOOLS.filter( + (command) => !commandExists(command) + ); + if (missingTools.length > 0) { + throw new Error( + `Missing required Linux runtime build tools: ${missingTools.join( + ', ' + )}.` + ); + } +} + +function resolveParallelism(environment) { + const explicitJobs = environment.IPTVNATOR_EMBEDDED_MPV_JOBS; + const makeJobs = environment.MAKEFLAGS?.match( + /(?:^|\s)-j\s*(\d+)(?:\s|$)/ + )?.[1]; + const value = + explicitJobs ?? + makeJobs ?? + String(os.availableParallelism?.() ?? os.cpus().length); + if (!/^[1-9]\d*$/.test(value)) { + throw new Error( + `IPTVNATOR_EMBEDDED_MPV_JOBS must be a positive integer; received ${value}.` + ); + } + return value; +} + +function containsPath(parentPath, candidatePath) { + const relativePath = path.relative(parentPath, candidatePath); + return ( + relativePath === '' || + (!relativePath.startsWith(`..${path.sep}`) && + relativePath !== '..' && + !path.isAbsolute(relativePath)) + ); +} + +function assertSafeOutputPrefix(prefix, buildRoot) { + const filesystemRoot = path.parse(prefix).root; + if ( + prefix === filesystemRoot || + containsPath(prefix, workspaceRoot) || + containsPath(prefix, buildRoot) + ) { + throw new Error( + `Refusing unsafe output prefix ${prefix}; choose a dedicated directory that does not contain the repository or build cache.` + ); + } +} + +function archiveExtension(sourceUrl) { + for (const extension of ['.tar.xz', '.tar.gz', '.tar.bz2', '.tgz']) { + if (new URL(sourceUrl).pathname.endsWith(extension)) { + return extension; + } + } + throw new Error(`Unsupported source archive URL: ${sourceUrl}`); +} + +function archivePathFor(sourcePackage, archiveRoot) { + return path.join( + archiveRoot, + `${sourcePackage.id}-${sourcePackage.version}${archiveExtension( + sourcePackage.sourceUrl + )}` + ); +} + +function sourcePathFor(packageId, sourceRoot) { + return path.join(sourceRoot, packageId); +} + +function sha256File(filePath) { + return sha256Buffer(fs.readFileSync(filePath)); +} + +function downloadArchive(sourcePackage, context) { + const archivePath = archivePathFor(sourcePackage, context.archiveRoot); + if (!fs.existsSync(archivePath)) { + const temporaryArchivePath = `${archivePath}.partial`; + fs.rmSync(temporaryArchivePath, { force: true }); + context.run('curl', [ + '--fail', + '--location', + '--retry', + '3', + '--retry-all-errors', + '--connect-timeout', + '30', + '--proto', + '=https', + '--tlsv1.2', + '--output', + temporaryArchivePath, + sourcePackage.sourceUrl, + ]); + fs.renameSync(temporaryArchivePath, archivePath); + } + + const sourceSha256 = sha256File(archivePath); + assertArchiveMatchesPin(sourcePackage, sourceSha256); + const packageSourcePath = sourcePathFor( + sourcePackage.id, + context.sourceRoot + ); + fs.rmSync(packageSourcePath, { recursive: true, force: true }); + fs.mkdirSync(packageSourcePath, { recursive: true }); + context.run('tar', [ + '--extract', + '--file', + archivePath, + '--directory', + packageSourcePath, + '--strip-components', + '1', + '--no-same-owner', + ]); + + return { + ...sourcePackage, + sourceSha256, + }; +} + +function cloneGitSource(sourcePackage, context) { + const packageSourcePath = sourcePathFor( + sourcePackage.id, + context.sourceRoot + ); + fs.rmSync(packageSourcePath, { recursive: true, force: true }); + context.run('git', [ + 'clone', + '--depth', + '1', + '--branch', + sourcePackage.sourceTag, + sourcePackage.sourceUrl, + packageSourcePath, + ]); + const sourceGitCommit = context.runCapture('git', ['rev-parse', 'HEAD'], { + cwd: packageSourcePath, + }); + assertGitCommitMatchesPin(sourcePackage, sourceGitCommit); + context.run( + 'git', + ['submodule', 'update', '--init', '--recursive', '--depth', '1'], + { cwd: packageSourcePath } + ); + + const submoduleOutput = context.runCapture( + 'git', + ['submodule', 'status', '--recursive'], + { cwd: packageSourcePath } + ); + + return { + ...sourcePackage, + sourceGitCommit, + sourceSubmodules: canonicalizeGitSubmoduleStatus(submoduleOutput), + }; +} + +function acquireSources(context) { + fs.mkdirSync(context.archiveRoot, { recursive: true }); + fs.mkdirSync(context.sourceRoot, { recursive: true }); + const sourceRecords = {}; + + for (const packageId of BUILD_ORDER) { + const sourcePackage = sourcePackageById.get(packageId); + log( + `Acquiring ${sourcePackage.id} ${sourcePackage.version} from ${sourcePackage.sourceUrl}` + ); + sourceRecords[packageId] = + sourcePackage.sourceKind === 'git' + ? cloneGitSource(sourcePackage, context) + : downloadArchive(sourcePackage, context); + } + return sourceRecords; +} + +function configureInstall(packageId, recipe, context) { + const sourcePath = sourcePathFor(packageId, context.sourceRoot); + context.run('./configure', [`--prefix=${context.prefix}`, ...recipe.args], { + cwd: sourcePath, + }); + context.run('make', [`-j${context.parallelism}`], { cwd: sourcePath }); + context.run('make', ['install'], { cwd: sourcePath }); +} + +function mesonSetupArgs(prefix, recipeArgs) { + return [ + `--prefix=${prefix}`, + '--libdir=lib', + '--buildtype=release', + '--default-library=shared', + '--wrap-mode=nodownload', + '--auto-features=disabled', + '-Db_ndebug=true', + ...recipeArgs, + ]; +} + +function mesonInstall(packageId, recipe, context) { + const sourcePath = sourcePathFor(packageId, context.sourceRoot); + const buildPath = path.join(sourcePath, 'build-iptvnator'); + const buildEnvironment = resolveLinuxPackageBuildEnvironment( + packageId, + context + ); + fs.rmSync(buildPath, { recursive: true, force: true }); + context.run( + 'meson', + ['setup', buildPath, ...mesonSetupArgs(context.prefix, recipe.args)], + { cwd: sourcePath, env: buildEnvironment } + ); + context.run( + 'meson', + ['compile', '--jobs', context.parallelism, '-C', buildPath], + { cwd: sourcePath, env: buildEnvironment } + ); + context.run('meson', ['install', '-C', buildPath], { + cwd: sourcePath, + env: buildEnvironment, + }); +} + +function prepareHwdata(context) { + context.hwdataBuildEnvironment = preparePinnedHwdataBuildInput({ + buildEnvironment: context.buildEnvironment, + prefix: context.prefix, + runCapture: (command, args, options) => + context.runCapture(command, args, options), + sourcePath: sourcePathFor('hwdata', context.sourceRoot), + }); +} + +function pathInsideDestdir(destdir, absolutePath) { + return path.join( + destdir, + absolutePath.slice(path.parse(absolutePath).root.length) + ); +} + +export function copyDirectoryContents(sourceDirectory, destinationDirectory) { + if (!fs.existsSync(sourceDirectory)) { + return; + } + fs.mkdirSync(destinationDirectory, { recursive: true }); + for (const entry of fs.readdirSync(sourceDirectory)) { + fs.cpSync( + path.join(sourceDirectory, entry), + path.join(destinationDirectory, entry), + { + recursive: true, + force: true, + verbatimSymlinks: true, + } + ); + } +} + +function installWithDestdir(packageId, context, install, externalPaths = []) { + const destdir = path.join(context.buildRoot, 'install-roots', packageId); + fs.rmSync(destdir, { recursive: true, force: true }); + fs.mkdirSync(destdir, { recursive: true }); + try { + install(destdir); + copyDirectoryContents( + pathInsideDestdir(destdir, context.prefix), + context.prefix + ); + for (const { destination, source } of externalPaths) { + copyDirectoryContents( + pathInsideDestdir(destdir, source), + path.join(context.prefix, destination) + ); + } + } finally { + fs.rmSync(destdir, { recursive: true, force: true }); + } +} + +function fontconfigInstall(recipe, context) { + const sourcePath = sourcePathFor('fontconfig', context.sourceRoot); + const buildPath = path.join(sourcePath, 'build-iptvnator'); + fs.rmSync(buildPath, { recursive: true, force: true }); + context.run( + 'meson', + ['setup', buildPath, ...mesonSetupArgs(context.prefix, recipe.args)], + { cwd: sourcePath } + ); + context.run( + 'meson', + ['compile', '--jobs', context.parallelism, '-C', buildPath], + { cwd: sourcePath } + ); + installWithDestdir( + 'fontconfig', + context, + (destdir) => + context.run('meson', ['install', '-C', buildPath], { + cwd: sourcePath, + env: { ...context.buildEnvironment, DESTDIR: destdir }, + }), + [ + { source: '/etc/fonts', destination: path.join('etc', 'fonts') }, + { + source: '/usr/share/fontconfig', + destination: path.join('share', 'fontconfig'), + }, + { + source: '/usr/share/xml/fontconfig', + destination: path.join('share', 'xml', 'fontconfig'), + }, + { + source: '/var/cache/fontconfig', + destination: path.join('var', 'cache', 'fontconfig'), + }, + ] + ); +} + +function cmakeInstall(packageId, recipe, context) { + const sourcePath = sourcePathFor(packageId, context.sourceRoot); + const buildPath = path.join(sourcePath, 'build-iptvnator'); + fs.rmSync(buildPath, { recursive: true, force: true }); + context.run('cmake', [ + '-S', + sourcePath, + '-B', + buildPath, + '-G', + 'Ninja', + `-DCMAKE_INSTALL_PREFIX=${context.prefix}`, + '-DCMAKE_INSTALL_LIBDIR=lib', + '-DCMAKE_BUILD_TYPE=Release', + '-DBUILD_SHARED_LIBS=ON', + ...recipe.args, + ]); + context.run('cmake', [ + '--build', + buildPath, + '--parallel', + context.parallelism, + ]); + context.run('cmake', ['--install', buildPath]); +} + +function opensslInstall(recipe, context) { + const sourcePath = sourcePathFor('openssl', context.sourceRoot); + context.run( + 'perl', + [ + './Configure', + 'linux-x86_64', + `--prefix=${context.prefix}`, + '--libdir=lib', + ...recipe.args, + ], + { cwd: sourcePath } + ); + context.run('make', [`-j${context.parallelism}`], { cwd: sourcePath }); + installWithDestdir('openssl', context, (destdir) => + context.run('make', ['install_sw', `DESTDIR=${destdir}`], { + cwd: sourcePath, + }) + ); +} + +function ffmpegInstall(recipe, context) { + const sourcePath = sourcePathFor('ffmpeg', context.sourceRoot); + const configureFlags = [`--prefix=${context.prefix}`, ...recipe.args]; + context.run('./configure', configureFlags, { cwd: sourcePath }); + context.run('make', [`-j${context.parallelism}`], { cwd: sourcePath }); + context.run('make', ['install'], { cwd: sourcePath }); + context.ffmpegConfigureFlags = configureFlags; +} + +function buildRuntime(context) { + for (const packageId of BUILD_ORDER) { + const recipe = BUILD_RECIPES[packageId]; + log(`Building ${packageId} as shared libraries`); + if (packageId === 'fontconfig') { + fontconfigInstall(recipe, context); + continue; + } + switch (recipe.buildSystem) { + case 'data': + prepareHwdata(context); + break; + case 'configure': + configureInstall(packageId, recipe, context); + break; + case 'meson': + mesonInstall(packageId, recipe, context); + break; + case 'cmake': + cmakeInstall(packageId, recipe, context); + break; + case 'openssl': + opensslInstall(recipe, context); + break; + case 'ffmpeg': + ffmpegInstall(recipe, context); + break; + default: + throw new Error( + `Unsupported build system ${recipe.buildSystem} for ${packageId}.` + ); + } + } +} + +function removeFilesMatching(root, pattern) { + if (!fs.existsSync(root)) { + return; + } + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + removeFilesMatching(entryPath, pattern); + } else if (entry.isFile() && pattern.test(entry.name)) { + fs.rmSync(entryPath); + } + } +} + +function removeNonRuntimeBuildOutputs(prefix) { + removeFilesMatching(path.join(prefix, 'lib'), /\.(?:a|la)$/); + for (const relativePath of [ + 'bin', + path.join('share', 'doc'), + path.join('share', 'gtk-doc'), + path.join('share', 'man'), + ]) { + fs.rmSync(path.join(prefix, relativePath), { + recursive: true, + force: true, + }); + } +} + +function assertElfLibrary(libraryPath) { + const descriptor = fs.openSync(libraryPath, 'r'); + try { + const header = Buffer.alloc(4); + const bytesRead = fs.readSync(descriptor, header, 0, header.length, 0); + if ( + bytesRead !== 4 || + !header.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + ) { + throw new Error( + `Runtime shared library is not an ELF file: ${libraryPath}.` + ); + } + } finally { + fs.closeSync(descriptor); + } +} + +function postProcessRuntime(context) { + const libDir = path.join(context.prefix, 'lib'); + if (!fs.existsSync(libDir)) { + throw new Error(`Runtime library directory was not built: ${libDir}.`); + } + + const unprunedDynamicEntries = runtimeLibraryNames(libDir).map( + (libraryName) => { + const libraryPath = path.join(libDir, libraryName); + assertElfLibrary(libraryPath); + return { + name: libraryName, + ...parseReadelfDynamic( + context.runCapture('readelf', ['-d', libraryPath]) + ), + }; + } + ); + const retainedNames = selectReachableRuntimeLibraryNames( + unprunedDynamicEntries + ); + retainRuntimeLibraries(libDir, retainedNames); + + const abiRecords = []; + const dynamicEntries = []; + for (const libraryName of runtimeLibraryNames(libDir)) { + const libraryPath = path.join(libDir, libraryName); + assertElfLibrary(libraryPath); + context.run('patchelf', ['--set-rpath', '$ORIGIN', libraryPath]); + const dynamic = parseReadelfDynamic( + context.runCapture('readelf', ['-d', libraryPath]) + ); + dynamicEntries.push({ name: libraryName, ...dynamic }); + const versionInfo = context.runCapture('readelf', [ + '--version-info', + libraryPath, + ]); + abiRecords.push(parseReadelfVersionInfo(versionInfo, libraryName)); + } + assertPortableAbiRecords(abiRecords); + + const runtimeFiles = createRuntimeFileRecords(libDir); + if (runtimeFiles.length === 0) { + throw new Error( + 'The Linux runtime build produced no shared libraries.' + ); + } + const dependencyClosure = validateRuntimeDependencyClosure({ + entries: dynamicEntries, + runtimeFileNames: runtimeFiles.map(({ name }) => name), + buildPrefix: context.prefix, + }); + + return { + abiBaseline: PORTABLE_ABI_BASELINE, + abiRecords, + dependencyClosure, + runtimeFiles, + }; +} + +function firstLine(value) { + return ( + value + .split(/\r?\n/) + .find((line) => line.trim()) + ?.trim() ?? '' + ); +} + +function collectBuildHost(context) { + const tools = context.toolVersions; + if (!tools) { + throw new Error('Linux runtime tool versions were not preflighted.'); + } + return { + platform: process.platform, + arch: process.arch, + release: os.release(), + glibcVersion: context.glibcVersion, + systemPkgConfigDirs: [...context.systemPkgConfigDirs], + systemPkgConfigPackages: { + ...context.systemPkgConfigPackages, + }, + tools: { ...tools }, + }; +} + +function detectBuildHostGlibcVersion() { + const glibcVersion = + process.report?.getReport?.()?.header?.glibcVersionRuntime; + assertPortableBuildHostGlibc(glibcVersion); + return glibcVersion; +} + +function collectToolVersions(context) { + const versionArgs = { + cc: ['--version'], + cmake: ['--version'], + curl: ['--version'], + git: ['--version'], + gperf: ['--version'], + make: ['--version'], + meson: ['--version'], + nasm: ['-v'], + ninja: ['--version'], + patchelf: ['--version'], + perl: ['-e', 'printf "%vd\\n", $^V'], + 'pkg-config': ['--version'], + python3: ['--version'], + readelf: ['--version'], + tar: ['--version'], + }; + const toolVersions = {}; + for (const tool of REQUIRED_TOOLS) { + toolVersions[tool] = firstLine( + context.runCapture(tool, versionArgs[tool] ?? ['--version']) + ); + } + return toolVersions; +} + +function verifySystemPkgConfigPackages(context) { + const systemPkgConfigPackages = {}; + for (const packageName of EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES) { + context.run('pkg-config', ['--exists', packageName]); + systemPkgConfigPackages[packageName] = context.runCapture( + 'pkg-config', + ['--modversion', packageName] + ); + } + return systemPkgConfigPackages; +} + +function writeManifest(context, sourceRecords, runtimeMetadata) { + const mpvMesonFlags = mesonSetupArgs(context.prefix, MPV_MESON_FLAGS); + const manifest = createLinuxRuntimeManifest({ + sourceRecords, + runtimeFiles: runtimeMetadata.runtimeFiles, + abiRecords: runtimeMetadata.abiRecords, + dependencyClosure: runtimeMetadata.dependencyClosure, + buildHost: collectBuildHost(context), + ffmpegConfigureFlags: context.ffmpegConfigureFlags, + mpvMesonFlags, + }); + const manifestErrors = validateLinuxRuntimeManifest(manifest); + if (manifestErrors.length > 0) { + throw new Error( + [ + 'Generated Linux runtime manifest is invalid:', + ...manifestErrors.map((error) => `- ${error}`), + ].join('\n') + ); + } + + const manifestPath = path.join(context.prefix, 'runtime-manifest.json'); + const temporaryManifestPath = `${manifestPath}.tmp`; + fs.writeFileSync( + temporaryManifestPath, + `${JSON.stringify(manifest, null, 2)}\n`, + { mode: 0o644 } + ); + fs.renameSync(temporaryManifestPath, manifestPath); + return manifest; +} + +function createBuildContext(prefix, environment) { + const buildRoot = path.resolve( + environment.IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT ?? + path.join( + os.tmpdir(), + 'iptvnator-embedded-mpv-runtime', + 'linux-x64' + ) + ); + assertSafeOutputPrefix(prefix, buildRoot); + const systemPkgConfigDirs = resolveSystemPkgConfigDirs(environment); + const buildEnvironment = createBuildEnvironment({ + prefix, + baseEnv: environment, + systemPkgConfigDirs, + }); + const runner = createCommandRunner({ buildEnvironment }); + return { + ...runner, + prefix, + buildRoot, + archiveRoot: path.join(buildRoot, 'archives'), + sourceRoot: path.join(buildRoot, 'sources'), + parallelism: resolveParallelism(environment), + systemPkgConfigDirs, + buildEnvironment, + ffmpegConfigureFlags: null, + }; +} + +export function main({ + argv = process.argv.slice(2), + platform = process.platform, + arch = process.arch, + cwd = process.cwd(), + environment = process.env, +} = {}) { + const { prefix: outputPrefix } = parseCliInvocation({ + platform, + arch, + argv, + cwd, + }); + assertUniqueMesonOptionAssignments(BUILD_RECIPES); + ensureTools(); + assertOwnedOutputDestination(outputPrefix); + const stagingToken = `${process.pid}-${crypto + .randomBytes(8) + .toString('hex')}`; + const stagingPrefix = ownedStagingPrefixPath(outputPrefix, stagingToken); + const context = createBuildContext(stagingPrefix, environment); + assertSafeOutputPrefix(outputPrefix, context.buildRoot); + const toolVersions = collectToolVersions(context); + assertMinimumToolVersions(toolVersions); + context.toolVersions = toolVersions; + context.glibcVersion = detectBuildHostGlibcVersion(); + context.systemPkgConfigPackages = verifySystemPkgConfigPackages(context); + fs.mkdirSync(context.buildRoot, { recursive: true }); + let stagingCreated = false; + try { + const createdStagingPrefix = createOwnedStagingPrefix(outputPrefix, { + token: stagingToken, + }); + if (createdStagingPrefix !== stagingPrefix) { + throw new Error( + 'Linux runtime staging prefix changed unexpectedly.' + ); + } + stagingCreated = true; + const sourceRecords = acquireSources(context); + buildRuntime(context); + removeNonRuntimeBuildOutputs(context.prefix); + const runtimeMetadata = postProcessRuntime(context); + const manifest = writeManifest(context, sourceRecords, runtimeMetadata); + publishOwnedOutput({ + outputPrefix, + stagingPrefix, + }); + stagingCreated = false; + log( + `Built ${manifest.runtimeFiles.length} LGPL-compatible runtime libraries (${manifest.runtimeTotalBytes} bytes) at ${outputPrefix}` + ); + } finally { + if (stagingCreated) { + fs.rmSync(stagingPrefix, { recursive: true, force: true }); + } + } +} + +const invokedScriptPath = process.argv[1] + ? path.resolve(process.argv[1]) + : undefined; +if (invokedScriptPath === scriptPath) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} diff --git a/tools/embedded-mpv/build-linux-runtime.test.mjs b/tools/embedded-mpv/build-linux-runtime.test.mjs new file mode 100644 index 000000000..61a2cbd0a --- /dev/null +++ b/tools/embedded-mpv/build-linux-runtime.test.mjs @@ -0,0 +1,1638 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const builderScript = path.join(currentDir, 'build-linux-runtime.mjs'); +const builderHelpers = path.join(currentDir, 'build-linux-runtime.cjs'); +const workspaceRoot = path.resolve(currentDir, '..', '..'); +const require = createRequire(import.meta.url); +const { + BUILD_RECIPES, + BUILD_ORDER, + DEFAULT_SYSTEM_PKG_CONFIG_DIRS, + EXTERNAL_SYSTEM_LIBRARIES, + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, + FFMPEG_CONFIGURE_FLAGS, + GLIBC_TOOLCHAIN_ALLOWLIST, + MINIMUM_TOOL_VERSIONS, + MPV_MESON_FLAGS, + OUTPUT_OWNERSHIP_MARKER, + PORTABLE_ABI_BASELINE, + REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, + SOURCE_PACKAGES, + assertArchiveMatchesPin, + assertGitCommitMatchesPin, + assertMinimumToolVersions, + assertOwnedOutputDestination, + assertPortableAbiRecords, + assertPortableBuildHostGlibc, + assertUniqueMesonOptionAssignments, + canonicalizeGitSubmoduleStatus, + createBuildEnvironment, + createLinuxRuntimeManifest, + createOwnedStagingPrefix, + createRuntimeFileRecords, + materializeLibrarySymlinks, + parseCliInvocation, + parseReadelfDynamic, + parseReadelfVersionInfo, + preparePinnedHwdataBuildInput, + publishOwnedOutput, + retainRuntimeLibraries, + resolveLinuxPackageBuildEnvironment, + resolveSystemPkgConfigDirs, + selectReachableRuntimeLibraryNames, + validateRuntimeDependencyClosure, +} = require('./build-linux-runtime.cjs'); +const { + validateLinuxRuntimeManifest, +} = require('./linux-runtime-manifest.cjs'); + +function createPinnedSourceRecords() { + return Object.fromEntries( + SOURCE_PACKAGES.map((sourcePackage) => [ + sourcePackage.id, + { + ...sourcePackage, + ...(sourcePackage.sourceKind === 'git' + ? { + sourceGitCommit: sourcePackage.expectedGitCommit, + sourceSubmodules: [ + ...sourcePackage.expectedSubmodules, + ], + } + : { + sourceSha256: sourcePackage.expectedSha256, + }), + }, + ]) + ); +} + +test('provides the Linux source runtime builder entrypoint and helpers', () => { + assert.equal(fs.existsSync(builderScript), true); + assert.equal(fs.existsSync(builderHelpers), true); +}); + +test('canonicalizes exact submodule identities independently of clone depth', () => { + const commit = '450bd2232225d6c7728a4108055ac2e37cef6475'; + const path = '3rdparty/Vulkan-Headers'; + assert.deepEqual( + canonicalizeGitSubmoduleStatus(` ${commit} ${path} (v1.4.337)\n`), + [`${commit} ${path}`] + ); + assert.deepEqual( + canonicalizeGitSubmoduleStatus(`${commit} ${path} (450bd22)`), + [`${commit} ${path}`] + ); +}); + +test('rejects non-clean, unsafe, and duplicate submodule status records', () => { + const commit = 'a'.repeat(40); + for (const output of [ + `-${commit} 3rdparty/missing`, + `+${commit} 3rdparty/moved`, + `U${commit} 3rdparty/conflicted`, + ` ${commit} ../outside`, + ` ${commit} 3rdparty/example\n ${commit} 3rdparty/example`, + ]) { + assert.throws( + () => canonicalizeGitSubmoduleStatus(output), + /not clean|unsafe|duplicate/i + ); + } +}); + +test('pins the complete source stack and preserves dependency build order', () => { + assert.deepEqual( + SOURCE_PACKAGES.map(({ id, version }) => ({ id, version })), + [ + { id: 'freetype', version: '2.13.3' }, + { id: 'fribidi', version: '1.0.16' }, + { id: 'harfbuzz', version: '8.5.0' }, + { id: 'expat', version: '2.8.2' }, + { id: 'fontconfig', version: '2.16.0' }, + { id: 'libass', version: '0.17.3' }, + { id: 'openssl', version: '3.5.7' }, + { id: 'ffmpeg', version: '8.1' }, + { id: 'libplacebo', version: '7.360.1' }, + { id: 'hwdata', version: '0.409' }, + { id: 'libdisplay-info', version: '0.1.1' }, + { id: 'mpv', version: '0.41.0' }, + ] + ); + assert.deepEqual( + BUILD_ORDER, + SOURCE_PACKAGES.map(({ id }) => id) + ); + + for (const sourcePackage of SOURCE_PACKAGES) { + assert.match(sourcePackage.sourceUrl, /^https:\/\//); + assert.ok(sourcePackage.license); + if (sourcePackage.id === 'libplacebo') { + assert.equal(sourcePackage.sourceTag, 'v7.360.1'); + assert.equal(sourcePackage.sourceKind, 'git'); + } else { + assert.equal(sourcePackage.sourceKind, 'archive'); + } + } + + const byId = new Map( + SOURCE_PACKAGES.map((sourcePackage) => [ + sourcePackage.id, + sourcePackage, + ]) + ); + assert.equal( + byId.get('expat').sourceUrl, + 'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz' + ); + assert.equal( + byId.get('fontconfig').sourceUrl, + 'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz' + ); + assert.equal( + byId.get('openssl').sourceUrl, + 'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz' + ); + assert.deepEqual(byId.get('hwdata'), { + id: 'hwdata', + version: '0.409', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz', + expectedSha256: + '23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd', + license: 'GPL-2.0-or-later OR XFree86-1.0', + buildInput: { + consumer: 'libdisplay-info', + relativePath: 'pnp.ids', + purpose: 'PNP vendor lookup table compiled into libdisplay-info.', + }, + }); + assert.deepEqual(byId.get('libdisplay-info'), { + id: 'libdisplay-info', + version: '0.1.1', + sourceKind: 'archive', + sourceUrl: + 'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz', + expectedSha256: + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + license: 'MIT', + }); + assert.ok( + BUILD_ORDER.indexOf('hwdata') < BUILD_ORDER.indexOf('libdisplay-info') + ); + assert.ok( + BUILD_ORDER.indexOf('libdisplay-info') < BUILD_ORDER.indexOf('mpv') + ); +}); + +test('hardcodes the verified official archive digests and libplacebo commit', () => { + const expectedArchivePins = { + expat: '3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4', + ffmpeg: 'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a', + fontconfig: + '6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220', + freetype: + '0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289', + fribidi: + '1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c', + harfbuzz: + '77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27', + hwdata: '23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd', + libass: 'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959', + 'libdisplay-info': + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + mpv: 'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209', + openssl: + 'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8', + }; + assert.deepEqual( + Object.fromEntries( + SOURCE_PACKAGES.filter(({ sourceKind }) => sourceKind === 'archive') + .map(({ id, expectedSha256 }) => [id, expectedSha256]) + .sort(([left], [right]) => left.localeCompare(right)) + ), + expectedArchivePins + ); + assert.equal( + SOURCE_PACKAGES.find(({ id }) => id === 'libplacebo').expectedGitCommit, + 'cee9b076f2c63104ccfd497fa79c39a867293ec4' + ); +}); + +test('rejects archive and git sources that differ from immutable pins', () => { + const freetype = SOURCE_PACKAGES.find(({ id }) => id === 'freetype'); + assert.doesNotThrow(() => + assertArchiveMatchesPin(freetype, freetype.expectedSha256) + ); + assert.throws( + () => assertArchiveMatchesPin(freetype, '0'.repeat(64)), + /freetype.*SHA-256 mismatch.*expected 055035.*received 000000/i + ); + + const libplacebo = SOURCE_PACKAGES.find(({ id }) => id === 'libplacebo'); + assert.doesNotThrow(() => + assertGitCommitMatchesPin(libplacebo, libplacebo.expectedGitCommit) + ); + assert.throws( + () => assertGitCommitMatchesPin(libplacebo, '0'.repeat(40)), + /libplacebo.*commit mismatch.*expected cee9b.*received 000000/i + ); +}); + +test('verifies source pins before archive extraction or git submodules', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.ok( + builderSource.indexOf( + 'assertArchiveMatchesPin(sourcePackage, sourceSha256)' + ) < builderSource.indexOf("context.run('tar'") + ); + assert.ok( + builderSource.indexOf( + 'assertGitCommitMatchesPin(sourcePackage, sourceGitCommit)' + ) < + builderSource.indexOf( + "['submodule', 'update', '--init', '--recursive', '--depth', '1']" + ) + ); +}); + +test('rejects source metadata that does not match the immutable pins', () => { + const sourceRecords = createPinnedSourceRecords(); + sourceRecords.freetype.sourceSha256 = '0'.repeat(64); + assert.throws( + () => + createLinuxRuntimeManifest({ + sourceRecords, + runtimeFiles: [ + { + name: 'libmpv.so.2', + size: 1, + sha256: 'a'.repeat(64), + }, + ], + dependencyClosure: { + entries: [ + { + name: 'libmpv.so.2', + needed: ['libc.so.6'], + rpath: [], + runpath: ['$ORIGIN'], + }, + ], + externalDependencies: ['libc.so.6'], + }, + buildHost: { + platform: 'linux', + arch: 'x64', + tools: {}, + }, + }), + /freetype.*SHA-256 mismatch/i + ); +}); + +test('defines shared-only source recipes with font discovery before playback', () => { + assert.deepEqual(Object.keys(BUILD_RECIPES), BUILD_ORDER); + assert.ok(BUILD_RECIPES.freetype.args.includes('--enable-shared')); + assert.ok(BUILD_RECIPES.freetype.args.includes('--disable-static')); + assert.ok(BUILD_RECIPES.fribidi.args.includes('--disable-docs')); + assert.ok(BUILD_RECIPES.fribidi.args.includes('--disable-bin')); + for (const flag of [ + '-Dtests=disabled', + '-Ddocs=disabled', + '-Dutilities=disabled', + ]) { + assert.ok(BUILD_RECIPES.harfbuzz.args.includes(flag), flag); + } + for (const flag of [ + '-DEXPAT_SHARED_LIBS=ON', + '-DEXPAT_BUILD_TOOLS=OFF', + '-DEXPAT_BUILD_EXAMPLES=OFF', + '-DEXPAT_BUILD_TESTS=OFF', + '-DEXPAT_BUILD_DOCS=OFF', + ]) { + assert.ok(BUILD_RECIPES.expat.args.includes(flag), flag); + } + for (const flag of [ + '-Ddoc=disabled', + '-Dtests=disabled', + '-Dtools=disabled', + '-Dcache-build=disabled', + '-Dxml-backend=expat', + '-Dbaseconfig-dir=/etc/fonts', + '-Dconfig-dir=/etc/fonts/conf.d', + '-Dtemplate-dir=/usr/share/fontconfig/conf.avail', + '-Dcache-dir=/var/cache/fontconfig', + '-Dxml-dir=/usr/share/xml/fontconfig', + ]) { + assert.ok(BUILD_RECIPES.fontconfig.args.includes(flag), flag); + } + assert.ok(BUILD_RECIPES.libass.args.includes('--enable-fontconfig')); + assert.equal( + BUILD_RECIPES.libass.args.includes('--disable-fontconfig'), + false + ); + for (const flag of [ + 'shared', + 'no-apps', + 'no-docs', + 'no-tests', + '--openssldir=/etc/ssl', + ]) { + assert.ok(BUILD_RECIPES.openssl.args.includes(flag), flag); + } + assert.ok( + BUILD_ORDER.indexOf('fontconfig') < BUILD_ORDER.indexOf('libass') + ); + assert.ok(BUILD_ORDER.indexOf('openssl') < BUILD_ORDER.indexOf('ffmpeg')); + assert.equal(BUILD_RECIPES.hwdata.buildSystem, 'data'); + assert.equal(BUILD_RECIPES.hwdata.sharedOnly, false); + assert.equal(BUILD_RECIPES['libdisplay-info'].buildSystem, 'meson'); + for (const packageId of BUILD_ORDER.filter( + (packageId) => packageId !== 'hwdata' + )) { + assert.equal(BUILD_RECIPES[packageId].sharedOnly, true, packageId); + } +}); + +test('stages pinned hwdata and excludes host pkg-config fallback', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-pinned-hwdata-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const sourcePath = path.join(root, 'sources', 'hwdata'); + const prefix = path.join(root, 'runtime'); + fs.mkdirSync(sourcePath, { recursive: true }); + const pnpIds = 'ABC Example Display Vendor\n'; + fs.writeFileSync(path.join(sourcePath, 'pnp.ids'), pnpIds); + + const invocations = []; + const buildEnvironment = { + PATH: '/usr/bin', + PKG_CONFIG_PATH: '/host/pkgconfig', + PKG_CONFIG_LIBDIR: + '/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig', + }; + const pinnedEnvironment = preparePinnedHwdataBuildInput({ + buildEnvironment, + prefix, + runCapture(command, args, options) { + invocations.push({ args, command, env: options.env }); + assert.equal(options.env.PKG_CONFIG_PATH.includes('/host'), false); + assert.equal(options.env.PKG_CONFIG_LIBDIR.includes('/usr'), false); + if (args[0] === '--variable=pcfiledir') { + return path.join(prefix, 'share', 'pkgconfig'); + } + if (args[0] === '--variable=pkgdatadir') { + return path.join(prefix, 'share', 'hwdata'); + } + if (args[0] === '--modversion') { + return '0.409'; + } + throw new Error(`Unexpected pkg-config query: ${args.join(' ')}`); + }, + sourcePath, + }); + + assert.equal( + fs.readFileSync( + path.join(prefix, 'share', 'hwdata', 'pnp.ids'), + 'utf8' + ), + pnpIds + ); + const pkgConfig = fs.readFileSync( + path.join(prefix, 'share', 'pkgconfig', 'hwdata.pc'), + 'utf8' + ); + assert.match(pkgConfig, /^Version: 0\.409$/m); + assert.match( + pkgConfig, + new RegExp( + `^pkgdatadir=${path + .join(prefix, 'share', 'hwdata') + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, + 'm' + ) + ); + assert.doesNotMatch(pkgConfig, /\/usr\/share\/hwdata/); + assert.equal(pinnedEnvironment.PKG_CONFIG_PATH.includes('/host'), false); + assert.equal(pinnedEnvironment.PKG_CONFIG_LIBDIR.includes('/usr'), false); + assert.equal( + resolveLinuxPackageBuildEnvironment('libdisplay-info', { + buildEnvironment, + hwdataBuildEnvironment: pinnedEnvironment, + }), + pinnedEnvironment + ); + assert.equal( + resolveLinuxPackageBuildEnvironment('mpv', { + buildEnvironment, + hwdataBuildEnvironment: pinnedEnvironment, + }), + buildEnvironment + ); + assert.throws( + () => + resolveLinuxPackageBuildEnvironment('libdisplay-info', { + buildEnvironment, + }), + /libdisplay-info.*pinned hwdata/i + ); + assert.deepEqual( + invocations.map(({ args, command }) => [command, ...args]), + [ + ['pkg-config', '--variable=pcfiledir', 'hwdata'], + ['pkg-config', '--variable=pkgdatadir', 'hwdata'], + ['pkg-config', '--modversion', 'hwdata'], + ] + ); + + assert.throws( + () => + preparePinnedHwdataBuildInput({ + buildEnvironment, + prefix: path.join(root, 'rejected-runtime'), + runCapture(_command, args) { + return args[0] === '--modversion' + ? '0.409' + : '/usr/share/hwdata'; + }, + sourcePath, + }), + /pinned hwdata.*host|host.*hwdata|resolved outside/i + ); +}); + +test('rejects duplicate option assignments in every Meson recipe', () => { + assert.doesNotThrow(() => + assertUniqueMesonOptionAssignments(BUILD_RECIPES) + ); + + for (const [packageId, duplicateFlag] of [ + ['fontconfig', '-Dxml-backend=libxml2'], + ['libplacebo', '-Dvulkan=enabled'], + ]) { + const duplicateRecipes = { + ...BUILD_RECIPES, + [packageId]: { + ...BUILD_RECIPES[packageId], + args: [...BUILD_RECIPES[packageId].args, duplicateFlag], + }, + }; + assert.throws( + () => assertUniqueMesonOptionAssignments(duplicateRecipes), + new RegExp( + `${packageId}.*${duplicateFlag.split('=')[0]}.*once`, + 'i' + ) + ); + } +}); + +test('constructs a prefix-only build environment and ignores hostile host flags', () => { + const environment = createBuildEnvironment({ + prefix: '/opt/runtime', + baseEnv: { + PATH: '/usr/bin', + KEEP_ME: 'benign', + CPPFLAGS: '-I/host/include', + CFLAGS: '-O0 -march=native', + CXXFLAGS: '-stdlib=hostile', + LDFLAGS: '-L/host/lib -Wl,--as-needed', + LD_LIBRARY_PATH: '/host/runtime', + LIBRARY_PATH: '/host/implicit', + CPATH: '/host/cpath', + C_INCLUDE_PATH: '/host/c-include', + CPLUS_INCLUDE_PATH: '/host/cxx-include', + CMAKE_PREFIX_PATH: '/host/cmake', + PKG_CONFIG_PATH: '/host/pkgconfig', + PKG_CONFIG_LIBDIR: '/host/pkgconfig-libdir', + PKG_CONFIG_SYSROOT_DIR: '/host/sysroot', + FONTCONFIG_PATH: '/host/fonts', + OPENSSL_MODULES: '/host/openssl-modules', + }, + systemPkgConfigDirs: [ + '/usr/lib/x86_64-linux-gnu/pkgconfig', + '/usr/share/pkgconfig', + ], + }); + + assert.equal(environment.PATH, '/opt/runtime/bin:/usr/bin'); + assert.equal( + environment.PKG_CONFIG_PATH, + '/opt/runtime/lib/pkgconfig:/opt/runtime/share/pkgconfig' + ); + assert.equal( + environment.PKG_CONFIG_LIBDIR, + [ + '/opt/runtime/lib/pkgconfig', + '/opt/runtime/share/pkgconfig', + '/usr/lib/x86_64-linux-gnu/pkgconfig', + '/usr/share/pkgconfig', + ].join(':') + ); + assert.equal(environment.CPPFLAGS, '-I/opt/runtime/include'); + assert.equal(environment.CFLAGS, '-fPIC -I/opt/runtime/include'); + assert.equal(environment.CXXFLAGS, '-fPIC -I/opt/runtime/include'); + assert.equal( + environment.LDFLAGS, + '-L/opt/runtime/lib -Wl,-rpath-link,/opt/runtime/lib' + ); + assert.equal(environment.LD_LIBRARY_PATH, '/opt/runtime/lib'); + assert.equal(environment.CMAKE_PREFIX_PATH, '/opt/runtime'); + assert.equal(environment.KEEP_ME, 'benign'); + for (const variable of [ + 'LIBRARY_PATH', + 'CPATH', + 'C_INCLUDE_PATH', + 'CPLUS_INCLUDE_PATH', + 'PKG_CONFIG_SYSROOT_DIR', + ]) { + assert.equal(environment[variable], undefined, variable); + } + assert.doesNotMatch(JSON.stringify(environment), /\/host\//); +}); + +test('rejects an existing unmarked output without changing its contents', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-output-ownership-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const outputPrefix = path.join(root, 'usr', 'local'); + fs.mkdirSync(outputPrefix, { recursive: true }); + fs.writeFileSync(path.join(outputPrefix, 'keep-me'), 'untouched'); + + assert.throws( + () => assertOwnedOutputDestination(outputPrefix), + /existing output.*ownership marker/i + ); + assert.equal( + fs.readFileSync(path.join(outputPrefix, 'keep-me'), 'utf8'), + 'untouched' + ); +}); + +test('atomically publishes only owned outputs and rolls back failures', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-output-publish-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const outputPrefix = path.join(root, 'runtime'); + fs.mkdirSync(outputPrefix); + fs.writeFileSync( + path.join(outputPrefix, OUTPUT_OWNERSHIP_MARKER), + 'iptvnator-embedded-mpv-linux-runtime-v1\n' + ); + fs.writeFileSync(path.join(outputPrefix, 'state'), 'previous'); + + const failedStagingPrefix = createOwnedStagingPrefix(outputPrefix, { + token: 'failed', + }); + fs.writeFileSync(path.join(failedStagingPrefix, 'state'), 'replacement'); + let renameCount = 0; + const failingFileSystem = { + ...fs, + renameSync(source, destination) { + renameCount += 1; + if (renameCount === 2) { + throw new Error('injected publication failure'); + } + return fs.renameSync(source, destination); + }, + }; + assert.throws( + () => + publishOwnedOutput({ + fileSystem: failingFileSystem, + outputPrefix, + stagingPrefix: failedStagingPrefix, + token: 'rollback', + }), + /injected publication failure/ + ); + assert.equal( + fs.readFileSync(path.join(outputPrefix, 'state'), 'utf8'), + 'previous' + ); + assert.equal(fs.existsSync(failedStagingPrefix), false); + assert.deepEqual( + fs.readdirSync(root).filter((name) => name.includes('backup')), + [] + ); + + const successfulStagingPrefix = createOwnedStagingPrefix(outputPrefix, { + token: 'successful', + }); + fs.writeFileSync( + path.join(successfulStagingPrefix, 'state'), + 'replacement' + ); + publishOwnedOutput({ + outputPrefix, + stagingPrefix: successfulStagingPrefix, + token: 'success', + }); + assert.equal( + fs.readFileSync(path.join(outputPrefix, 'state'), 'utf8'), + 'replacement' + ); + assert.equal( + fs.readFileSync( + path.join(outputPrefix, OUTPUT_OWNERSHIP_MARKER), + 'utf8' + ), + 'iptvnator-embedded-mpv-linux-runtime-v1\n' + ); +}); + +test('builds in an owned sibling before atomically publishing output', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.doesNotMatch( + builderSource, + /fs\.rmSync\(context\.prefix, \{ recursive: true, force: true \}\)/ + ); + const toolPreflight = builderSource.indexOf( + 'assertMinimumToolVersions(toolVersions)' + ); + const stagingMutation = builderSource.indexOf( + 'createOwnedStagingPrefix(outputPrefix' + ); + const publication = builderSource.indexOf('publishOwnedOutput({'); + assert.notEqual(stagingMutation, -1); + assert.notEqual(publication, -1); + assert.ok(toolPreflight < stagingMutation); + assert.ok(stagingMutation < publication); +}); + +test('uses fixed Linux x64 pkg-config directories without host discovery', () => { + assert.deepEqual(DEFAULT_SYSTEM_PKG_CONFIG_DIRS, [ + '/usr/lib/x86_64-linux-gnu/pkgconfig', + '/usr/lib64/pkgconfig', + '/usr/lib/pkgconfig', + '/usr/share/pkgconfig', + ]); + assert.deepEqual( + resolveSystemPkgConfigDirs({}), + DEFAULT_SYSTEM_PKG_CONFIG_DIRS + ); + assert.deepEqual( + resolveSystemPkgConfigDirs({ + IPTVNATOR_EMBEDDED_MPV_SYSTEM_PKG_CONFIG_DIRS: + '/opt/interfaces/pkgconfig:/usr/share/pkgconfig:/opt/interfaces/pkgconfig', + }), + ['/opt/interfaces/pkgconfig', '/usr/share/pkgconfig'] + ); + assert.throws( + () => + resolveSystemPkgConfigDirs({ + IPTVNATOR_EMBEDDED_MPV_SYSTEM_PKG_CONFIG_DIRS: + 'relative/pkgconfig', + }), + /must contain only absolute paths/ + ); + + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.doesNotMatch(builderSource, /--variable['"],\s*['"]pc_path/); +}); + +test('declares only the intended Linux system interface packages', () => { + assert.deepEqual(EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, [ + 'alsa', + 'egl', + 'gbm', + 'gl', + 'libdrm', + 'libpulse', + 'libva', + 'libva-drm', + ]); + + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.match( + builderSource, + /pkg-config['"],\s*\[['"]--exists['"],\s*packageName\]/ + ); + assert.match( + builderSource, + /pkg-config['"],\s*\[['"]--modversion['"],\s*packageName\]/ + ); +}); + +test('requires ELF patching and inspection tools in addition to the build toolchain', () => { + for (const tool of [ + 'cc', + 'cmake', + 'curl', + 'git', + 'gperf', + 'make', + 'meson', + 'nasm', + 'ninja', + 'patchelf', + 'pkg-config', + 'readelf', + 'tar', + ]) { + assert.ok(REQUIRED_TOOLS.includes(tool), tool); + } +}); + +test('preflights every required tool against a supported minimum version', () => { + assert.deepEqual( + Object.keys(MINIMUM_TOOL_VERSIONS).sort(), + [...REQUIRED_TOOLS].sort() + ); + assert.equal(MINIMUM_TOOL_VERSIONS.meson, '1.6.0'); + assert.equal(MINIMUM_TOOL_VERSIONS.nasm, '2.15.05'); + assert.equal(MINIMUM_TOOL_VERSIONS.gperf, '3.1.0'); + + const supportedVersions = Object.fromEntries( + REQUIRED_TOOLS.map((tool) => [ + tool, + `${tool} ${MINIMUM_TOOL_VERSIONS[tool]}`, + ]) + ); + assert.doesNotThrow(() => assertMinimumToolVersions(supportedVersions)); + + const missingTool = { ...supportedVersions }; + delete missingTool.nasm; + assert.throws( + () => assertMinimumToolVersions(missingTool), + /missing required tool version.*nasm/i + ); + + for (const [tool, oldVersion] of [ + ['gperf', 'GNU gperf 3.0.4'], + ['meson', 'meson 1.5.9'], + ['nasm', 'NASM version 2.15.04'], + ]) { + assert.throws( + () => + assertMinimumToolVersions({ + ...supportedVersions, + [tool]: oldVersion, + }), + new RegExp(`${tool}.*requires.*${MINIMUM_TOOL_VERSIONS[tool]}`, 'i') + ); + } +}); + +test('completes tool and system-package preflight before filesystem mutation', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + const buildRootMutation = builderSource.indexOf( + 'fs.mkdirSync(context.buildRoot' + ); + const toolPreflight = builderSource.indexOf( + 'assertMinimumToolVersions(toolVersions)' + ); + const packagePreflight = builderSource.indexOf( + 'verifySystemPkgConfigPackages(context)' + ); + const mesonPolicyPreflight = builderSource.indexOf( + 'assertUniqueMesonOptionAssignments(BUILD_RECIPES)' + ); + assert.notEqual(mesonPolicyPreflight, -1); + assert.notEqual(toolPreflight, -1); + assert.notEqual(packagePreflight, -1); + assert.ok(mesonPolicyPreflight < buildRootMutation); + assert.ok(toolPreflight < buildRootMutation); + assert.ok(packagePreflight < buildRootMutation); +}); + +test('uses DESTDIR with standard fontconfig and OpenSSL runtime paths', () => { + assert.deepEqual(RUNTIME_EXTERNAL_CONFIGURATION, { + fontconfig: { + configDirectory: '/etc/fonts', + templateDirectory: '/usr/share/fontconfig', + cacheDirectory: '/var/cache/fontconfig', + ownership: 'system', + }, + openssl: { + configFile: '/etc/ssl/openssl.cnf', + certificateFile: '/etc/ssl/cert.pem', + certificateDirectory: '/etc/ssl/certs', + ownership: 'system', + }, + }); + assert.doesNotMatch( + JSON.stringify(RUNTIME_EXTERNAL_CONFIGURATION), + /build|prefix|tmp/i + ); + + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.match(builderSource, /DESTDIR/); + assert.match(builderSource, /installWithDestdir/); + assert.doesNotMatch( + builderSource, + /--openssldir=\$\{path\.join\(context\.prefix/ + ); +}); + +test('preserves relative library symlinks copied out of DESTDIR', async (t) => { + const { copyDirectoryContents } = await import( + pathToFileURL(builderScript).href + ); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-destdir-links-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const sourceDirectory = path.join(root, 'destdir', 'lib'); + const destinationDirectory = path.join(root, 'prefix', 'lib'); + fs.mkdirSync(sourceDirectory, { recursive: true }); + fs.writeFileSync(path.join(sourceDirectory, 'libcrypto.so.3'), 'crypto'); + fs.symlinkSync( + 'libcrypto.so.3', + path.join(sourceDirectory, 'libcrypto.so') + ); + + copyDirectoryContents(sourceDirectory, destinationDirectory); + fs.rmSync(path.join(root, 'destdir'), { + recursive: true, + force: true, + }); + + assert.equal( + fs.readlinkSync(path.join(destinationDirectory, 'libcrypto.so')), + 'libcrypto.so.3' + ); + assert.equal( + fs.readFileSync( + path.join(destinationDirectory, 'libcrypto.so'), + 'utf8' + ), + 'crypto' + ); +}); + +test('uses an explicit LGPL FFmpeg HTTPS and HLS protocol baseline', () => { + const required = [ + '--enable-shared', + '--disable-static', + '--disable-programs', + '--disable-doc', + '--disable-debug', + '--disable-autodetect', + '--disable-gpl', + '--disable-nonfree', + '--enable-pic', + '--enable-pthreads', + '--enable-openssl', + '--enable-network', + '--enable-protocol=file', + '--enable-protocol=http', + '--enable-protocol=https', + '--enable-protocol=tcp', + '--enable-protocol=tls', + '--enable-protocol=udp', + '--enable-protocol=crypto', + '--enable-protocol=data', + '--enable-demuxer=hls', + '--enable-vaapi', + ]; + + for (const flag of required) { + assert.ok(FFMPEG_CONFIGURE_FLAGS.includes(flag), `missing ${flag}`); + } + for (const forbidden of [ + '--enable-gpl', + '--enable-nonfree', + '--enable-version3', + ]) { + assert.equal(FFMPEG_CONFIGURE_FLAGS.includes(forbidden), false); + } +}); + +test('uses valid mpv v0.41 Meson option names and pins the Linux backends', () => { + const upstreamMpv041Options = new Set([ + 'aaudio', + 'alsa', + 'android-media-ndk', + 'audiotrack', + 'audiounit', + 'avfoundation', + 'build-date', + 'caca', + 'cdda', + 'coreaudio', + 'cocoa', + 'cplayer', + 'cplugins', + 'cuda-hwaccel', + 'cuda-interop', + 'd3d-hwaccel', + 'd3d11', + 'd3d9-hwaccel', + 'direct3d', + 'disable-packet-pool', + 'dmabuf-wayland', + 'drm', + 'dvbin', + 'dvdnav', + 'egl', + 'egl-android', + 'egl-angle', + 'egl-angle-lib', + 'egl-angle-win32', + 'egl-drm', + 'egl-wayland', + 'egl-x11', + 'fuzzers', + 'gbm', + 'gl', + 'gl-cocoa', + 'gl-dxinterop', + 'gl-dxinterop-d3d9', + 'gl-win32', + 'gl-x11', + 'gpl', + 'html-build', + 'iconv', + 'ios-gl', + 'jack', + 'javascript', + 'jpeg', + 'lcms2', + 'libarchive', + 'libavdevice', + 'libbluray', + 'libmpv', + 'lua', + 'macos-10-15-4-features', + 'macos-11-3-features', + 'macos-11-features', + 'macos-12-features', + 'macos-cocoa-cb', + 'macos-media-player', + 'macos-touchbar', + 'manpage-build', + 'openal', + 'opensles', + 'oss-audio', + 'pdf-build', + 'pipewire', + 'plain-gl', + 'pthread-debug', + 'pulse', + 'rubberband', + 'sdl2-audio', + 'sdl2-gamepad', + 'sdl2-video', + 'shaderc', + 'sixel', + 'sndio', + 'spirv-cross', + 'swift-build', + 'tests', + 'uchardet', + 'uwp', + 'vaapi', + 'vaapi-drm', + 'vaapi-wayland', + 'vaapi-win32', + 'vaapi-x11', + 'vapoursynth', + 'vdpau', + 'vdpau-gl-x11', + 'vector', + 'videotoolbox-gl', + 'videotoolbox-pl', + 'vulkan', + 'wasapi', + 'wayland', + 'win32-smtc', + 'win32-threads', + 'x11', + 'x11-clipboard', + 'xv', + 'zimg', + 'zlib', + ]); + + for (const flag of MPV_MESON_FLAGS) { + const optionName = flag.slice(2).split('=')[0]; + assert.ok( + upstreamMpv041Options.has(optionName), + `unknown mpv v0.41 option ${optionName}` + ); + assert.doesNotMatch(flag, /=auto$/); + } + + for (const required of [ + '-Dgpl=false', + '-Dlibmpv=true', + '-Dcplayer=false', + '-Dtests=false', + '-Dlua=disabled', + '-Djavascript=disabled', + '-Dcplugins=disabled', + '-Dmanpage-build=disabled', + '-Dhtml-build=disabled', + '-Dpdf-build=disabled', + '-Dlibarchive=disabled', + '-Dlibbluray=disabled', + '-Ddvdnav=disabled', + '-Dcdda=disabled', + '-Ddvbin=disabled', + '-Dvulkan=disabled', + '-Dshaderc=disabled', + '-Dspirv-cross=disabled', + '-Ddrm=enabled', + '-Dgl=enabled', + '-Dplain-gl=enabled', + '-Degl=enabled', + '-Dgbm=enabled', + '-Dpulse=enabled', + '-Dalsa=enabled', + '-Dvaapi=enabled', + '-Dvaapi-drm=enabled', + ]) { + assert.ok(MPV_MESON_FLAGS.includes(required), `missing ${required}`); + } + assert.equal(MPV_MESON_FLAGS.includes('-Ddrm=disabled'), false); + for (const optionName of ['drm', 'gbm', 'vaapi-drm']) { + const assignments = MPV_MESON_FLAGS.filter((flag) => + flag.startsWith(`-D${optionName}=`) + ); + assert.deepEqual(assignments, [`-D${optionName}=enabled`]); + } +}); + +test('keeps the external dependency allowlists explicit and deterministic', () => { + assert.deepEqual(GLIBC_TOOLCHAIN_ALLOWLIST, [ + 'ld-linux-x86-64.so.2', + 'libc.so.6', + 'libdl.so.2', + 'libgcc_s.so.1', + 'libm.so.6', + 'libpthread.so.0', + 'librt.so.1', + 'libstdc++.so.6', + ]); + assert.deepEqual( + EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), + [ + 'libEGL.so.1', + 'libGL.so.1', + 'libGLX.so.0', + 'libOpenGL.so.0', + 'libasound.so.2', + 'libdrm.so.2', + 'libgbm.so.1', + 'libpulse.so.0', + 'libva-drm.so.2', + 'libva.so.2', + ] + ); + for (const externalLibrary of EXTERNAL_SYSTEM_LIBRARIES) { + assert.ok(externalLibrary.interface); + assert.ok(externalLibrary.reason); + } +}); + +test('parses readelf dynamic sections and validates an ORIGIN-only closure', () => { + const mpvDynamic = parseReadelfDynamic(` + 0x000000000000000e (SONAME) Library soname: [libmpv.so.2] + 0x0000000000000001 (NEEDED) Shared library: [libavcodec.so.62] + 0x0000000000000001 (NEEDED) Shared library: [libEGL.so.1] + 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] + 0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN] +`); + assert.deepEqual(mpvDynamic, { + needed: ['libEGL.so.1', 'libavcodec.so.62', 'libc.so.6'], + rpath: [], + runpath: ['$ORIGIN'], + soname: 'libmpv.so.2', + }); + + const closure = validateRuntimeDependencyClosure({ + entries: [ + { + name: 'libavcodec.so.62', + ...parseReadelfDynamic(` + 0x0000000000000001 (NEEDED) Shared library: [libm.so.6] + 0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN] +`), + }, + { name: 'libmpv.so.2', ...mpvDynamic }, + ], + runtimeFileNames: ['libavcodec.so.62', 'libmpv.so.2'], + buildPrefix: '/tmp/iptvnator-prefix', + }); + assert.deepEqual(closure.externalDependencies, [ + 'libEGL.so.1', + 'libc.so.6', + 'libm.so.6', + ]); + assert.deepEqual(closure.entries[1].needed, [ + 'libEGL.so.1', + 'libavcodec.so.62', + 'libc.so.6', + ]); + assert.equal(closure.entries[1].soname, 'libmpv.so.2'); + + const aliasClosure = validateRuntimeDependencyClosure({ + entries: [ + { + name: 'libmpv.so', + ...mpvDynamic, + }, + { + name: 'libmpv.so.2', + ...mpvDynamic, + }, + { + name: 'libavcodec.so.62', + needed: ['libm.so.6'], + rpath: [], + runpath: ['$ORIGIN'], + soname: 'libavcodec.so.62', + }, + ], + runtimeFileNames: ['libavcodec.so.62', 'libmpv.so', 'libmpv.so.2'], + buildPrefix: '/tmp/iptvnator-prefix', + }); + assert.equal( + aliasClosure.entries.find(({ name }) => name === 'libmpv.so').soname, + 'libmpv.so.2' + ); + assert.throws( + () => + validateRuntimeDependencyClosure({ + entries: aliasClosure.entries.map((entry) => + entry.name === 'libmpv.so' + ? { ...entry, soname: 'libmpv.so.99' } + : entry + ), + runtimeFileNames: [ + 'libavcodec.so.62', + 'libmpv.so', + 'libmpv.so.2', + ], + buildPrefix: '/tmp/iptvnator-prefix', + }), + /libmpv\.so must declare a versioned SONAME present in the runtime closure/ + ); + + assert.throws( + () => + validateRuntimeDependencyClosure({ + entries: [ + { + name: 'libmpv.so.2', + needed: ['libsurprise.so.1'], + rpath: [], + runpath: ['$ORIGIN'], + }, + ], + runtimeFileNames: ['libmpv.so.2'], + buildPrefix: '/tmp/iptvnator-prefix', + }), + /not bundled or allowlisted.*libsurprise\.so\.1/ + ); + for (const forbiddenRunpath of [ + '/tmp/iptvnator-prefix/lib', + '/usr/local/lib', + '$ORIGIN:/tmp/host-lib', + ]) { + assert.throws( + () => + validateRuntimeDependencyClosure({ + entries: [ + { + name: 'libmpv.so.2', + needed: ['libc.so.6'], + rpath: [], + runpath: [forbiddenRunpath], + }, + ], + runtimeFileNames: ['libmpv.so.2'], + buildPrefix: '/tmp/iptvnator-prefix', + }), + /RUNPATH/ + ); + } +}); + +test('retains only the reachable SONAME closure plus the libmpv linker alias', (t) => { + const entries = [ + { + name: 'libmpv.so', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libc.so.6'], + }, + { + name: 'libmpv.so.2', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libc.so.6'], + }, + { + name: 'libmpv.so.2.0.0', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libc.so.6'], + }, + { + name: 'libavcodec.so', + soname: 'libavcodec.so.62', + needed: ['libm.so.6'], + }, + { + name: 'libavcodec.so.62', + soname: 'libavcodec.so.62', + needed: ['libm.so.6'], + }, + { + name: 'libavcodec.so.62.1.0', + soname: 'libavcodec.so.62', + needed: ['libm.so.6'], + }, + { + name: 'libunused.so.1', + soname: 'libunused.so.1', + needed: ['libc.so.6'], + }, + ]; + const retainedNames = selectReachableRuntimeLibraryNames(entries); + assert.deepEqual(retainedNames, [ + 'libavcodec.so.62', + 'libmpv.so', + 'libmpv.so.2', + ]); + + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-runtime-prune-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const libDir = path.join(root, 'lib'); + fs.mkdirSync(libDir); + fs.writeFileSync(path.join(libDir, 'libmpv.so.2.0.0'), 'mpv'); + fs.symlinkSync('libmpv.so.2.0.0', path.join(libDir, 'libmpv.so.2')); + fs.symlinkSync('libmpv.so.2', path.join(libDir, 'libmpv.so')); + fs.writeFileSync(path.join(libDir, 'libavcodec.so.62.1.0'), 'codec'); + fs.symlinkSync( + 'libavcodec.so.62.1.0', + path.join(libDir, 'libavcodec.so.62') + ); + fs.symlinkSync('libavcodec.so.62', path.join(libDir, 'libavcodec.so')); + fs.writeFileSync(path.join(libDir, 'libunused.so.1'), 'unused'); + + retainRuntimeLibraries(libDir, retainedNames); + assert.deepEqual(fs.readdirSync(libDir).sort(), retainedNames); + for (const retainedName of retainedNames) { + assert.equal( + fs.lstatSync(path.join(libDir, retainedName)).isFile(), + true, + retainedName + ); + } +}); + +test('enforces the Ubuntu 22.04 GLIBC and GLIBCXX symbol ceilings', () => { + assert.deepEqual(PORTABLE_ABI_BASELINE, { + distribution: 'Ubuntu 22.04', + glibcMaximum: '2.35', + glibcxxMaximum: '3.4.30', + }); + const record = parseReadelfVersionInfo( + ` + 0x0010: Name: GLIBC_2.2.5 Flags: none Version: 7 + 0x0020: Name: GLIBC_2.34 Flags: none Version: 5 + 0x0030: Name: GLIBCXX_3.4.21 Flags: none Version: 4 + 0x0040: Name: GLIBCXX_3.4.29 Flags: none Version: 3 +`, + 'libmpv.so.2' + ); + assert.deepEqual(record, { + name: 'libmpv.so.2', + requiredGlibc: '2.34', + requiredGlibcxx: '3.4.29', + }); + assert.doesNotThrow(() => assertPortableAbiRecords([record])); + + for (const [field, version] of [ + ['requiredGlibc', '2.36'], + ['requiredGlibcxx', '3.4.31'], + ]) { + assert.throws( + () => + assertPortableAbiRecords([ + { + ...record, + [field]: version, + }, + ]), + /portable ABI baseline.*newer symbol/i + ); + } +}); + +test('rejects build hosts newer than the portable glibc baseline', () => { + assert.doesNotThrow(() => assertPortableBuildHostGlibc('2.35')); + assert.throws( + () => assertPortableBuildHostGlibc('2.36'), + /build host glibc 2\.36.*portable ABI baseline.*2\.35/i + ); + assert.throws( + () => assertPortableBuildHostGlibc(undefined), + /unable to determine the Linux build host glibc version/i + ); +}); + +test('inspects every retained runtime file for portable symbol versions', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.match( + builderSource, + /runCapture\('readelf', \[\s*'--version-info',\s*libraryPath,\s*\]\)/ + ); + assert.match(builderSource, /retainRuntimeLibraries\(libDir,/); + assert.match(builderSource, /assertPortableAbiRecords\(abiRecords\)/); +}); + +test('materializes symlink aliases and hashes every runtime library', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-builder-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const libDir = path.join(root, 'lib'); + fs.mkdirSync(libDir); + const contents = Buffer.from('fake-elf-runtime'); + fs.writeFileSync(path.join(libDir, 'libmpv.so.2.0.0'), contents); + fs.symlinkSync('libmpv.so.2.0.0', path.join(libDir, 'libmpv.so.2')); + fs.symlinkSync('libmpv.so.2', path.join(libDir, 'libmpv.so')); + + materializeLibrarySymlinks(libDir); + + for (const name of ['libmpv.so', 'libmpv.so.2', 'libmpv.so.2.0.0']) { + const filePath = path.join(libDir, name); + assert.equal(fs.lstatSync(filePath).isFile(), true); + assert.deepEqual(fs.readFileSync(filePath), contents); + } + assert.deepEqual(createRuntimeFileRecords(libDir), [ + { + name: 'libmpv.so', + size: contents.length, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + }, + { + name: 'libmpv.so.2', + size: contents.length, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + }, + { + name: 'libmpv.so.2.0.0', + size: contents.length, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + }, + ]); +}); + +test('generates a hash-complete manifest accepted by the Linux validator', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-manifest-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const libDir = path.join(root, 'lib'); + fs.mkdirSync(libDir); + fs.writeFileSync(path.join(libDir, 'libavcodec.so.62'), 'avcodec'); + fs.writeFileSync(path.join(libDir, 'libmpv.so'), 'mpv'); + fs.writeFileSync(path.join(libDir, 'libmpv.so.2'), 'mpv'); + const runtimeFiles = createRuntimeFileRecords(libDir); + const sourceRecords = createPinnedSourceRecords(); + const abiRecords = runtimeFiles.map(({ name }) => ({ + name, + requiredGlibc: '2.34', + requiredGlibcxx: null, + })); + const manifest = createLinuxRuntimeManifest({ + sourceRecords, + runtimeFiles, + abiRecords, + dependencyClosure: { + entries: [ + { + name: 'libavcodec.so.62', + soname: 'libavcodec.so.62', + needed: ['libm.so.6'], + rpath: [], + runpath: ['$ORIGIN'], + }, + { + name: 'libmpv.so', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libEGL.so.1'], + rpath: [], + runpath: ['$ORIGIN'], + }, + { + name: 'libmpv.so.2', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libEGL.so.1'], + rpath: [], + runpath: ['$ORIGIN'], + }, + ], + externalDependencies: ['libEGL.so.1', 'libm.so.6'], + }, + buildHost: { + platform: 'linux', + arch: 'x64', + release: 'fixture-kernel', + glibcVersion: '2.35', + systemPkgConfigDirs: [...DEFAULT_SYSTEM_PKG_CONFIG_DIRS], + systemPkgConfigPackages: Object.fromEntries( + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES.map((packageName) => [ + packageName, + `${packageName} fixture version`, + ]) + ), + tools: Object.fromEntries( + REQUIRED_TOOLS.map((tool) => [ + tool, + `${tool} ${MINIMUM_TOOL_VERSIONS[tool]}`, + ]) + ), + }, + generatedAt: '2026-07-17T00:00:00.000Z', + }); + + assert.deepEqual(validateLinuxRuntimeManifest(manifest), []); + assert.equal( + manifest.runtimeTotalBytes, + runtimeFiles.reduce((total, runtimeFile) => total + runtimeFile.size, 0) + ); + assert.deepEqual(manifest.runtimeDependencyClosure.externalDependencies, [ + 'libEGL.so.1', + 'libm.so.6', + ]); + assert.equal( + manifest.runtimeDependencyClosure.entries.find( + ({ name }) => name === 'libmpv.so' + ).soname, + 'libmpv.so.2' + ); + assert.deepEqual( + manifest.externalSystemLibraries, + EXTERNAL_SYSTEM_LIBRARIES + ); + assert.deepEqual(manifest.runtimeAbi, { + baseline: PORTABLE_ABI_BASELINE, + files: abiRecords, + }); + assert.deepEqual( + manifest.runtimeExternalConfiguration, + RUNTIME_EXTERNAL_CONFIGURATION + ); + assert.equal(manifest.buildHost.glibcVersion, '2.35'); + assert.deepEqual( + Object.keys(manifest.buildHost.systemPkgConfigPackages), + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES + ); + for (const sourcePackage of SOURCE_PACKAGES) { + if (sourcePackage.sourceKind === 'archive') { + assert.equal( + manifest.packages[sourcePackage.id].sourceSha256, + sourcePackage.expectedSha256 + ); + } + } + assert.equal( + manifest.packages.libplacebo.sourceGitCommit, + SOURCE_PACKAGES.find(({ id }) => id === 'libplacebo').expectedGitCommit + ); + assert.doesNotMatch(manifest.sourceDistribution, /TBD|TODO/i); + assert.match(manifest.sourceDistribution, /source archives/i); + assert.match(manifest.sourceDistribution, /libdisplay-info/i); + assert.match(manifest.sourceDistribution, /hwdata/i); + assert.match(manifest.sourceDistribution, /pnp\.ids/i); + assert.deepEqual(manifest.packages.hwdata.buildInput, { + consumer: 'libdisplay-info', + relativePath: 'pnp.ids', + purpose: 'PNP vendor lookup table compiled into libdisplay-info.', + }); +}); + +test('guards the CLI to Linux x64 and requires exactly one output prefix', () => { + assert.deepEqual( + parseCliInvocation({ + platform: 'linux', + arch: 'x64', + argv: ['/tmp/runtime'], + cwd: '/workspace', + }), + { prefix: '/tmp/runtime' } + ); + assert.deepEqual( + parseCliInvocation({ + platform: 'linux', + arch: 'x64', + argv: ['--', 'relative/runtime'], + cwd: '/workspace', + }), + { prefix: '/workspace/relative/runtime' } + ); + assert.throws( + () => + parseCliInvocation({ + platform: 'darwin', + arch: 'x64', + argv: ['/tmp/runtime'], + cwd: '/workspace', + }), + /supported on Linux x64 only.*darwin\/x64/ + ); + assert.throws( + () => + parseCliInvocation({ + platform: 'linux', + arch: 'arm64', + argv: ['/tmp/runtime'], + cwd: '/workspace', + }), + /supported on Linux x64 only.*linux\/arm64/ + ); + for (const argv of [[], ['/one', '/two']]) { + assert.throws( + () => + parseCliInvocation({ + platform: 'linux', + arch: 'x64', + argv, + cwd: '/workspace', + }), + /Usage: node tools\/embedded-mpv\/build-linux-runtime\.mjs / + ); + } +}); + +test('does not execute the Linux build when the entrypoint is imported', async () => { + await assert.doesNotReject(() => import(pathToFileURL(builderScript).href)); +}); + +test('patches every materialized ELF runtime to ORIGIN before validating closure', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.match( + builderSource, + /run\('patchelf', \['--set-rpath', '\$ORIGIN', libraryPath\]\)/ + ); + assert.match( + builderSource, + /runCapture\('readelf', \['-d', libraryPath\]\)/ + ); + assert.match(builderSource, /retainRuntimeLibraries\(libDir,/); + assert.match(builderSource, /validateRuntimeDependencyClosure\(\{/); + assert.match(builderSource, /createRuntimeFileRecords\(libDir\)/); + assert.match(builderSource, /runtime-manifest\.json/); + assert.match(builderSource, /validateLinuxRuntimeManifest\(manifest\)/); +}); + +test('registers the builder script and focused test with package and Nx', () => { + const packageMetadata = JSON.parse( + fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8') + ); + assert.equal( + packageMetadata.scripts['embedded-mpv:build-runtime:linux'], + 'node tools/embedded-mpv/build-linux-runtime.mjs' + ); + + const packagingProject = JSON.parse( + fs.readFileSync( + path.join(workspaceRoot, 'tools', 'packaging', 'project.json'), + 'utf8' + ) + ); + const inputs = packagingProject.targets.test.inputs; + for (const registeredInput of [ + '{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.cjs', + '{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs', + '{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs', + ]) { + assert.ok(inputs.includes(registeredInput), registeredInput); + } + assert.match( + packagingProject.targets.test.options.command, + /tools\/embedded-mpv\/build-linux-runtime\.test\.mjs/ + ); + + const lintInputs = packagingProject.targets.lint.inputs; + for (const registeredInput of [ + '{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs', + '{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs', + ]) { + assert.ok(lintInputs.includes(registeredInput), registeredInput); + } + assert.match( + packagingProject.targets.lint.command, + /tools\/embedded-mpv\/build-linux-runtime\.\{mjs,test\.mjs\}/ + ); +}); diff --git a/tools/embedded-mpv/generate-linux-runtime-notices.cjs b/tools/embedded-mpv/generate-linux-runtime-notices.cjs new file mode 100644 index 000000000..ec2a44dc1 --- /dev/null +++ b/tools/embedded-mpv/generate-linux-runtime-notices.cjs @@ -0,0 +1,753 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { SOURCE_PACKAGES } = require('./build-linux-runtime.cjs'); + +const LICENSE_INPUT_MANIFEST = 'linux-runtime-license-inputs.json'; +const NOTICE_MANIFEST = 'embedded-mpv-notices.json'; +const THIRD_PARTY_NOTICES = 'THIRD_PARTY_NOTICES.txt'; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0); +const NOTICE_SOURCE_PACKAGES = Object.freeze( + [...SOURCE_PACKAGES].sort(({ id: left }, { id: right }) => + compareText(left, right) + ) +); + +const LICENSE_PATHS_BY_PACKAGE = Object.freeze({ + freetype: Object.freeze(['LICENSE.TXT', 'docs/FTL.TXT']), + fribidi: Object.freeze(['COPYING']), + harfbuzz: Object.freeze(['COPYING']), + expat: Object.freeze(['COPYING']), + fontconfig: Object.freeze(['COPYING']), + libass: Object.freeze(['COPYING']), + openssl: Object.freeze(['LICENSE.txt']), + ffmpeg: Object.freeze(['LICENSE.md', 'COPYING.LGPLv2.1']), + libplacebo: Object.freeze([ + 'LICENSE', + '3rdparty/Vulkan-Headers/LICENSE.md', + '3rdparty/fast_float/LICENSE-APACHE', + '3rdparty/fast_float/LICENSE-BOOST', + '3rdparty/fast_float/LICENSE-MIT', + '3rdparty/glad/LICENSE', + '3rdparty/jinja/LICENSE.txt', + '3rdparty/markupsafe/LICENSE.txt', + 'demos/3rdparty/nuklear/LICENSE', + ]), + hwdata: Object.freeze(['LICENSE', 'COPYING']), + 'libdisplay-info': Object.freeze(['LICENSE']), + mpv: Object.freeze(['Copyright', 'LICENSE.LGPL']), +}); + +function sha256(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasExactFields(value, fields) { + return ( + isObject(value) && + isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort()) + ); +} + +function isPathInside(root, candidate) { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + +function assertDirectoryWithoutSymlinks(directoryPath, label) { + let stat; + try { + stat = fs.lstatSync(directoryPath); + } catch { + throw new Error(`Missing ${label}: ${directoryPath}`); + } + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`${label} must be a real directory: ${directoryPath}`); + } + return fs.realpathSync(directoryPath); +} + +function assertRegularFileInside(root, relativePath, label) { + if ( + typeof relativePath !== 'string' || + relativePath.length === 0 || + path.isAbsolute(relativePath) || + relativePath.split(/[\\/]/).some((part) => part === '..' || part === '') + ) { + throw new Error( + `${label} has an unsafe relative path: ${relativePath}` + ); + } + const realRoot = assertDirectoryWithoutSymlinks(root, `${label} root`); + const candidate = path.resolve(root, ...relativePath.split('/')); + if (!isPathInside(path.resolve(root), candidate)) { + throw new Error(`${label} resolves outside ${root}: ${relativePath}`); + } + + let cursor = path.resolve(root); + const parts = path.relative(cursor, candidate).split(path.sep); + for (const [index, part] of parts.entries()) { + cursor = path.join(cursor, part); + let stat; + try { + stat = fs.lstatSync(cursor); + } catch { + throw new Error(`Missing ${label}: ${cursor}`); + } + if (stat.isSymbolicLink()) { + throw new Error(`${label} must not use a symbolic link: ${cursor}`); + } + if (index < parts.length - 1 && !stat.isDirectory()) { + throw new Error(`${label} parent must be a directory: ${cursor}`); + } + if (index === parts.length - 1 && !stat.isFile()) { + throw new Error(`${label} must be a regular file: ${cursor}`); + } + } + + const realCandidate = fs.realpathSync(candidate); + if (!isPathInside(realRoot, realCandidate)) { + throw new Error( + `${label} resolves outside its trusted root: ${relativePath}` + ); + } + return realCandidate; +} + +function sourcePackageMetadata(sourcePackage) { + return { + version: sourcePackage.version, + sourceUrl: sourcePackage.sourceUrl, + ...(sourcePackage.sourceTag + ? { sourceTag: sourcePackage.sourceTag } + : {}), + ...(sourcePackage.sourceKind === 'archive' + ? { sourceSha256: sourcePackage.expectedSha256 } + : { sourceGitCommit: sourcePackage.expectedGitCommit }), + license: sourcePackage.license, + }; +} + +function validateRuntimeManifestPackages(runtimeManifest) { + if ( + !isObject(runtimeManifest) || + runtimeManifest.platform !== 'linux' || + runtimeManifest.arch !== 'x64' || + !isObject(runtimeManifest.packages) || + !isDeepStrictEqual( + Object.keys(runtimeManifest.packages).sort(), + SOURCE_PACKAGES.map(({ id }) => id).sort() + ) + ) { + throw new Error( + 'Linux runtime notice generation requires the exact pinned linux-x64 package manifest.' + ); + } + for (const sourcePackage of SOURCE_PACKAGES) { + const actual = runtimeManifest.packages[sourcePackage.id]; + const expected = sourcePackageMetadata(sourcePackage); + for (const [field, expectedValue] of Object.entries(expected)) { + if (!isDeepStrictEqual(actual?.[field], expectedValue)) { + throw new Error( + `Linux runtime package ${sourcePackage.id}.${field} does not match its immutable pin.` + ); + } + } + if ( + sourcePackage.sourceKind === 'git' && + (!Array.isArray(actual.sourceSubmodules) || + actual.sourceSubmodules.length === 0) + ) { + throw new Error( + `Linux runtime package ${sourcePackage.id} must record pinned submodules.` + ); + } + const licensePaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id]; + if (!Array.isArray(licensePaths) || licensePaths.length === 0) { + throw new Error( + `Linux runtime package ${sourcePackage.id} has no pinned upstream license files.` + ); + } + } +} + +function fileRecord(filePath, relativePath) { + const contents = fs.readFileSync(filePath); + return { + path: relativePath.split(path.sep).join('/'), + size: contents.length, + sha256: sha256(contents), + }; +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { + mode: 0o644, + }); +} + +function replaceDirectory(outputRoot, writer) { + const resolvedOutputRoot = path.resolve(outputRoot); + const temporaryRoot = `${resolvedOutputRoot}.tmp-${process.pid}-${crypto + .randomBytes(8) + .toString('hex')}`; + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + fs.mkdirSync(temporaryRoot, { recursive: true }); + try { + const result = writer(temporaryRoot); + fs.rmSync(resolvedOutputRoot, { recursive: true, force: true }); + fs.renameSync(temporaryRoot, resolvedOutputRoot); + return result; + } catch (error) { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + throw error; + } +} + +function expectedLicensePath(packageId, sourceRelativePath) { + return path.posix.join('licenses', packageId, sourceRelativePath); +} + +function collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot, + runtimeManifest, +}) { + validateRuntimeManifestPackages(runtimeManifest); + assertDirectoryWithoutSymlinks(sourceRoot, 'Linux runtime source root'); + + return replaceDirectory(outputRoot, (temporaryRoot) => { + const packages = NOTICE_SOURCE_PACKAGES.map((sourcePackage) => { + const files = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].map( + (sourceRelativePath) => { + const sourcePath = assertRegularFileInside( + path.join(sourceRoot, sourcePackage.id), + sourceRelativePath, + `${sourcePackage.id} upstream license` + ); + const relativeOutputPath = expectedLicensePath( + sourcePackage.id, + sourceRelativePath + ); + const destinationPath = path.join( + temporaryRoot, + ...relativeOutputPath.split('/') + ); + fs.mkdirSync(path.dirname(destinationPath), { + recursive: true, + }); + fs.copyFileSync(sourcePath, destinationPath); + fs.chmodSync(destinationPath, 0o644); + return { + sourcePath: sourceRelativePath, + ...fileRecord(destinationPath, relativeOutputPath), + }; + } + ); + return { + id: sourcePackage.id, + ...sourcePackageMetadata(sourcePackage), + files, + }; + }); + const manifest = { + schemaVersion: 1, + origin: 'pinned-linux-runtime-license-inputs', + platform: 'linux', + arch: 'x64', + packages, + }; + writeJson(path.join(temporaryRoot, LICENSE_INPUT_MANIFEST), manifest); + return manifest; + }); +} + +function listFilesRecursively(root) { + const files = []; + function visit(directoryPath) { + for (const entry of fs + .readdirSync(directoryPath, { withFileTypes: true }) + .sort((left, right) => compareText(left.name, right.name))) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isSymbolicLink()) { + throw new Error( + `Linux runtime legal files must not use symbolic links: ${entryPath}` + ); + } + if (entry.isDirectory()) { + visit(entryPath); + } else if (entry.isFile()) { + files.push( + path.relative(root, entryPath).split(path.sep).join('/') + ); + } else { + throw new Error( + `Linux runtime legal input must be a regular file: ${entryPath}` + ); + } + } + } + visit(root); + return files; +} + +function readJsonFileInside(root, relativePath, label) { + const filePath = assertRegularFileInside(root, relativePath, label); + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + throw new Error( + `Invalid JSON in ${label}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +function validateLicenseInputManifest(inputRoot, runtimeManifest) { + validateRuntimeManifestPackages(runtimeManifest); + const inputManifest = readJsonFileInside( + inputRoot, + LICENSE_INPUT_MANIFEST, + 'Linux runtime license input manifest' + ); + if ( + !hasExactFields(inputManifest, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'packages', + ]) || + inputManifest.schemaVersion !== 1 || + inputManifest.origin !== 'pinned-linux-runtime-license-inputs' || + inputManifest.platform !== 'linux' || + inputManifest.arch !== 'x64' || + !Array.isArray(inputManifest.packages) || + inputManifest.packages.length !== NOTICE_SOURCE_PACKAGES.length + ) { + throw new Error('Invalid Linux runtime license input manifest.'); + } + + const expectedPaths = new Set([LICENSE_INPUT_MANIFEST]); + for (const [index, sourcePackage] of NOTICE_SOURCE_PACKAGES.entries()) { + const packageRecord = inputManifest.packages[index]; + const expectedMetadata = sourcePackageMetadata(sourcePackage); + if ( + !hasExactFields(packageRecord, [ + 'id', + ...Object.keys(expectedMetadata), + 'files', + ]) || + packageRecord.id !== sourcePackage.id || + !Object.entries(expectedMetadata).every(([field, value]) => + isDeepStrictEqual(packageRecord[field], value) + ) || + !Array.isArray(packageRecord.files) || + packageRecord.files.length !== + LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].length + ) { + throw new Error( + `Invalid cached license inputs for ${sourcePackage.id}.` + ); + } + for (const [fileIndex, sourceRelativePath] of LICENSE_PATHS_BY_PACKAGE[ + sourcePackage.id + ].entries()) { + const record = packageRecord.files[fileIndex]; + const expectedPath = expectedLicensePath( + sourcePackage.id, + sourceRelativePath + ); + if ( + !hasExactFields(record, [ + 'sourcePath', + 'path', + 'size', + 'sha256', + ]) || + record.sourcePath !== sourceRelativePath || + record.path !== expectedPath || + !Number.isSafeInteger(record.size) || + record.size <= 0 || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error( + `Invalid cached license file record for ${sourcePackage.id}.` + ); + } + const inputPath = assertRegularFileInside( + inputRoot, + record.path, + `${sourcePackage.id} license input` + ); + const actual = fileRecord(inputPath, record.path); + if (actual.size !== record.size) { + throw new Error( + `Size mismatch for cached license input ${record.path}.` + ); + } + if (actual.sha256 !== record.sha256) { + throw new Error( + `SHA-256 mismatch for cached license input ${record.path}.` + ); + } + expectedPaths.add(record.path); + } + } + for (const actualPath of listFilesRecursively(inputRoot)) { + if (!expectedPaths.has(actualPath)) { + throw new Error( + `Found undeclared license input ${actualPath} in ${inputRoot}.` + ); + } + } + return inputManifest; +} + +function noticePackageRecord(sourcePackage, inputPackage) { + return { + id: sourcePackage.id, + ...sourcePackageMetadata(sourcePackage), + files: inputPackage.files.map( + ({ path: filePath, size, sha256: hash }) => ({ + path: filePath, + size, + sha256: hash, + }) + ), + }; +} + +function createThirdPartyNotices(packages) { + const lines = [ + 'IPTVnator Linux Embedded MPV Third-Party Notices', + '================================================', + '', + 'This file identifies the pinned upstream source packages used by the bundled Linux Embedded MPV runtime.', + 'The complete verbatim upstream license files are included at the paths and SHA-256 digests listed below.', + 'The exact corresponding sources and build scripts are distributed alongside the binary release as linux-frame-copy-runtime-sources.tar.xz.', + '', + ]; + for (const packageRecord of packages) { + lines.push( + `${packageRecord.id} ${packageRecord.version}`, + `License: ${packageRecord.license}`, + `Source: ${packageRecord.sourceUrl}`, + 'Included upstream license files:' + ); + for (const file of packageRecord.files) { + lines.push(`- ${file.path} (SHA-256 ${file.sha256})`); + } + lines.push(''); + } + return `${lines.join('\n')}\n`; +} + +function generateLinuxRuntimeNotices({ + licenseInputRoot, + outputRoot, + runtimeManifest, +}) { + const inputManifest = validateLicenseInputManifest( + licenseInputRoot, + runtimeManifest + ); + return replaceDirectory(outputRoot, (temporaryRoot) => { + const packages = NOTICE_SOURCE_PACKAGES.map((sourcePackage, index) => { + const inputPackage = inputManifest.packages[index]; + for (const file of inputPackage.files) { + const sourcePath = assertRegularFileInside( + licenseInputRoot, + file.path, + `${sourcePackage.id} cached license` + ); + const destinationPath = path.join( + temporaryRoot, + ...file.path.split('/') + ); + fs.mkdirSync(path.dirname(destinationPath), { + recursive: true, + }); + fs.copyFileSync(sourcePath, destinationPath); + fs.chmodSync(destinationPath, 0o644); + } + return noticePackageRecord(sourcePackage, inputPackage); + }); + const noticeContents = Buffer.from(createThirdPartyNotices(packages)); + const noticePath = path.join(temporaryRoot, THIRD_PARTY_NOTICES); + fs.writeFileSync(noticePath, noticeContents, { mode: 0o644 }); + const noticeFile = fileRecord(noticePath, THIRD_PARTY_NOTICES); + const licenseTotalBytes = packages + .flatMap(({ files }) => files) + .reduce((total, file) => total + file.size, 0); + const manifest = { + schemaVersion: 1, + origin: 'pinned-linux-runtime-upstream-licenses', + platform: 'linux', + arch: 'x64', + noticeFile, + packages, + totalBytes: noticeFile.size + licenseTotalBytes, + }; + writeJson(path.join(temporaryRoot, NOTICE_MANIFEST), manifest); + const errors = validateLinuxRuntimeNotices( + temporaryRoot, + runtimeManifest + ); + if (errors.length > 0) { + throw new Error( + [ + 'Generated Linux runtime notices are invalid.', + ...errors.map((error) => `- ${error}`), + ].join('\n') + ); + } + return manifest; + }); +} + +function assertValidLinuxRuntimeNotices( + root, + runtimeManifest, + { allowUnrelatedFiles = false } = {} +) { + validateRuntimeManifestPackages(runtimeManifest); + assertDirectoryWithoutSymlinks(root, 'Linux runtime notices root'); + const manifest = readJsonFileInside( + root, + NOTICE_MANIFEST, + 'Linux runtime notices manifest' + ); + if ( + !hasExactFields(manifest, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'noticeFile', + 'packages', + 'totalBytes', + ]) || + manifest.schemaVersion !== 1 || + manifest.origin !== 'pinned-linux-runtime-upstream-licenses' || + manifest.platform !== 'linux' || + manifest.arch !== 'x64' || + !Array.isArray(manifest.packages) || + manifest.packages.length !== NOTICE_SOURCE_PACKAGES.length + ) { + throw new Error('Invalid Linux runtime notices manifest.'); + } + + const expectedFiles = new Set([NOTICE_MANIFEST, THIRD_PARTY_NOTICES]); + let licenseTotalBytes = 0; + for (const [index, sourcePackage] of NOTICE_SOURCE_PACKAGES.entries()) { + const packageRecord = manifest.packages[index]; + const expectedMetadata = sourcePackageMetadata(sourcePackage); + const expectedPaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].map( + (sourceRelativePath) => + expectedLicensePath(sourcePackage.id, sourceRelativePath) + ); + if ( + !hasExactFields(packageRecord, [ + 'id', + ...Object.keys(expectedMetadata), + 'files', + ]) || + packageRecord.id !== sourcePackage.id || + !Object.entries(expectedMetadata).every(([field, value]) => + isDeepStrictEqual(packageRecord[field], value) + ) || + !Array.isArray(packageRecord.files) || + !isDeepStrictEqual( + packageRecord.files.map(({ path: filePath }) => filePath), + expectedPaths + ) + ) { + throw new Error( + `Invalid packaged notice record for ${sourcePackage.id}.` + ); + } + for (const record of packageRecord.files) { + if ( + !hasExactFields(record, ['path', 'size', 'sha256']) || + !Number.isSafeInteger(record.size) || + record.size <= 0 || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error( + `Invalid packaged notice file for ${sourcePackage.id}.` + ); + } + const filePath = assertRegularFileInside( + root, + record.path, + `${sourcePackage.id} packaged license` + ); + const actual = fileRecord(filePath, record.path); + if (actual.size !== record.size) { + throw new Error( + `Size mismatch for packaged license ${record.path}.` + ); + } + if (actual.sha256 !== record.sha256) { + throw new Error( + `SHA-256 mismatch for packaged license ${record.path}.` + ); + } + licenseTotalBytes += record.size; + expectedFiles.add(record.path); + } + } + + if ( + !hasExactFields(manifest.noticeFile, ['path', 'size', 'sha256']) || + manifest.noticeFile.path !== THIRD_PARTY_NOTICES || + !Number.isSafeInteger(manifest.noticeFile.size) || + manifest.noticeFile.size <= 0 || + !SHA256_PATTERN.test(manifest.noticeFile.sha256) + ) { + throw new Error('Invalid aggregate third-party notice file record.'); + } + const noticePath = assertRegularFileInside( + root, + THIRD_PARTY_NOTICES, + 'aggregate third-party notices' + ); + const actualNotice = fileRecord(noticePath, THIRD_PARTY_NOTICES); + if ( + actualNotice.size !== manifest.noticeFile.size || + actualNotice.sha256 !== manifest.noticeFile.sha256 + ) { + throw new Error( + 'Aggregate THIRD_PARTY_NOTICES.txt size or SHA-256 mismatch.' + ); + } + const expectedNoticeContents = createThirdPartyNotices(manifest.packages); + if (fs.readFileSync(noticePath, 'utf8') !== expectedNoticeContents) { + throw new Error( + 'Aggregate THIRD_PARTY_NOTICES.txt does not match the exact notice index.' + ); + } + if (manifest.totalBytes !== actualNotice.size + licenseTotalBytes) { + throw new Error( + 'Linux runtime notices totalBytes does not match declared legal files.' + ); + } + const actualPaths = allowUnrelatedFiles + ? [ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + ...listFilesRecursively(path.join(root, 'licenses')).map( + (relativePath) => path.posix.join('licenses', relativePath) + ), + ] + : listFilesRecursively(root); + for (const actualPath of actualPaths) { + if (!expectedFiles.has(actualPath)) { + throw new Error( + `Found undeclared packaged legal file ${actualPath} in ${root}.` + ); + } + } + return manifest; +} + +function validateLinuxRuntimeNotices(root, runtimeManifest, options) { + try { + assertValidLinuxRuntimeNotices(root, runtimeManifest, options); + return []; + } catch (error) { + return [error instanceof Error ? error.message : String(error)]; + } +} + +function parseArguments(argv) { + const args = argv[0] === '--' ? argv.slice(1) : [...argv]; + const mode = args.shift(); + if (!['collect', 'generate'].includes(mode)) { + throw new Error( + 'Usage: generate-linux-runtime-notices.cjs --runtime-manifest --output-root [--source-root |--license-input-root ]' + ); + } + const values = {}; + while (args.length > 0) { + const key = args.shift(); + const value = args.shift(); + if (!key?.startsWith('--') || !value) { + throw new Error(`Invalid Linux runtime notices argument: ${key}`); + } + const name = key.slice(2); + if (Object.hasOwn(values, name)) { + throw new Error(`Duplicate Linux runtime notices argument: ${key}`); + } + values[name] = value; + } + const required = [ + 'runtime-manifest', + 'output-root', + mode === 'collect' ? 'source-root' : 'license-input-root', + ]; + for (const name of required) { + if (!values[name]) { + throw new Error(`Missing --${name}.`); + } + } + return { mode, values }; +} + +function main(argv = process.argv.slice(2)) { + const { mode, values } = parseArguments(argv); + const runtimeManifest = JSON.parse( + fs.readFileSync(path.resolve(values['runtime-manifest']), 'utf8') + ); + if (mode === 'collect') { + collectLinuxRuntimeLicenseInputs({ + sourceRoot: path.resolve(values['source-root']), + outputRoot: path.resolve(values['output-root']), + runtimeManifest, + }); + } else { + generateLinuxRuntimeNotices({ + licenseInputRoot: path.resolve(values['license-input-root']), + outputRoot: path.resolve(values['output-root']), + runtimeManifest, + }); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} + +module.exports = { + LICENSE_INPUT_MANIFEST, + LICENSE_PATHS_BY_PACKAGE, + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + collectLinuxRuntimeLicenseInputs, + generateLinuxRuntimeNotices, + validateLinuxRuntimeNotices, +}; diff --git a/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs b/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs new file mode 100644 index 000000000..7e9942342 --- /dev/null +++ b/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { SOURCE_PACKAGES } = require('./build-linux-runtime.cjs'); +const { + LICENSE_PATHS_BY_PACKAGE, + collectLinuxRuntimeLicenseInputs, + generateLinuxRuntimeNotices, + validateLinuxRuntimeNotices, +} = require('./generate-linux-runtime-notices.cjs'); +const generatorScript = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'generate-linux-runtime-notices.cjs' +); + +function runtimeManifest() { + return { + platform: 'linux', + arch: 'x64', + packages: Object.fromEntries( + SOURCE_PACKAGES.map((sourcePackage) => [ + sourcePackage.id, + { + version: sourcePackage.version, + sourceUrl: sourcePackage.sourceUrl, + ...(sourcePackage.sourceTag + ? { sourceTag: sourcePackage.sourceTag } + : {}), + ...(sourcePackage.sourceKind === 'archive' + ? { sourceSha256: sourcePackage.expectedSha256 } + : { + sourceGitCommit: sourcePackage.expectedGitCommit, + sourceSubmodules: [ + ...sourcePackage.expectedSubmodules, + ], + }), + license: sourcePackage.license, + ...(sourcePackage.buildInput + ? { buildInput: sourcePackage.buildInput } + : {}), + }, + ]) + ), + }; +} + +function createSourceFixture() { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-license-sources-') + ); + for (const sourcePackage of SOURCE_PACKAGES) { + const licensePaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id]; + assert.ok( + Array.isArray(licensePaths) && licensePaths.length > 0, + `missing test mapping for ${sourcePackage.id}` + ); + for (const relativePath of licensePaths) { + const filePath = path.join(root, sourcePackage.id, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + `verbatim upstream ${sourcePackage.id} ${relativePath}\n` + ); + } + } + return root; +} + +function fileTree(root) { + const files = []; + function visit(directoryPath) { + for (const entry of fs + .readdirSync(directoryPath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name))) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + } else { + files.push({ + path: path + .relative(root, entryPath) + .split(path.sep) + .join('/'), + contents: fs.readFileSync(entryPath), + }); + } + } + } + visit(root); + return files; +} + +test('collects verbatim pinned licenses and generates deterministic exact notices', (t) => { + const sourceRoot = createSourceFixture(); + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-notices-') + ); + const inputRoot = path.join(fixtureRoot, 'inputs'); + const firstOutput = path.join(fixtureRoot, 'first'); + const secondOutput = path.join(fixtureRoot, 'second'); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + const manifest = runtimeManifest(); + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: inputRoot, + runtimeManifest: manifest, + }); + const first = generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: firstOutput, + runtimeManifest: manifest, + }); + const second = generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: secondOutput, + runtimeManifest: manifest, + }); + + assert.deepEqual(fileTree(firstOutput), fileTree(secondOutput)); + assert.deepEqual(first, second); + assert.equal(first.schemaVersion, 1); + assert.equal(first.origin, 'pinned-linux-runtime-upstream-licenses'); + assert.deepEqual( + first.packages.map(({ id }) => id), + SOURCE_PACKAGES.map(({ id }) => id).sort() + ); + assert.ok(first.packages.every(({ files }) => files.length >= 1)); + assert.deepEqual(LICENSE_PATHS_BY_PACKAGE.libplacebo, [ + 'LICENSE', + '3rdparty/Vulkan-Headers/LICENSE.md', + '3rdparty/fast_float/LICENSE-APACHE', + '3rdparty/fast_float/LICENSE-BOOST', + '3rdparty/fast_float/LICENSE-MIT', + '3rdparty/glad/LICENSE', + '3rdparty/jinja/LICENSE.txt', + '3rdparty/markupsafe/LICENSE.txt', + 'demos/3rdparty/nuklear/LICENSE', + ]); + assert.deepEqual(validateLinuxRuntimeNotices(firstOutput, manifest), []); + + const noticeContents = fs.readFileSync( + path.join(firstOutput, 'THIRD_PARTY_NOTICES.txt') + ); + assert.equal(first.noticeFile.path, 'THIRD_PARTY_NOTICES.txt'); + assert.equal(first.noticeFile.size, noticeContents.length); + assert.equal( + first.noticeFile.sha256, + crypto.createHash('sha256').update(noticeContents).digest('hex') + ); + assert.match( + noticeContents.toString('utf8'), + /exact corresponding sources and build scripts are distributed alongside the binary release as linux-frame-copy-runtime-sources\.tar\.xz/ + ); + + for (const packageRecord of first.packages) { + for (const fileRecord of packageRecord.files) { + const outputContents = fs.readFileSync( + path.join(firstOutput, fileRecord.path) + ); + const sourcePath = fileRecord.path.slice( + `licenses/${packageRecord.id}/`.length + ); + assert.ok( + LICENSE_PATHS_BY_PACKAGE[packageRecord.id].includes(sourcePath) + ); + assert.deepEqual( + outputContents, + fs.readFileSync( + path.join(sourceRoot, packageRecord.id, sourcePath) + ) + ); + } + } +}); + +test('rejects symlinked license sources and path escapes', (t) => { + const sourceRoot = createSourceFixture(); + const outputRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-license-inputs-') + ); + const outsidePath = path.join(outputRoot, 'outside-license'); + fs.writeFileSync(outsidePath, 'outside\n'); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(outputRoot, { recursive: true, force: true }); + }); + + const freetypeLicense = path.join( + sourceRoot, + 'freetype', + LICENSE_PATHS_BY_PACKAGE.freetype[0] + ); + fs.rmSync(freetypeLicense); + fs.symlinkSync(outsidePath, freetypeLicense); + + assert.throws( + () => + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: path.join(outputRoot, 'collected'), + runtimeManifest: runtimeManifest(), + }), + /symbolic link|outside/i + ); +}); + +test('rejects missing, tampered, and undeclared cached license inputs', (t) => { + const sourceRoot = createSourceFixture(); + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-license-cache-') + ); + const inputRoot = path.join(fixtureRoot, 'inputs'); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + const manifest = runtimeManifest(); + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: inputRoot, + runtimeManifest: manifest, + }); + const collectedManifest = JSON.parse( + fs.readFileSync( + path.join(inputRoot, 'linux-runtime-license-inputs.json'), + 'utf8' + ) + ); + const firstLicensePath = path.join( + inputRoot, + collectedManifest.packages[0].files[0].path + ); + fs.appendFileSync(firstLicensePath, 'tampered\n'); + assert.throws( + () => + generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: path.join(fixtureRoot, 'tampered-output'), + runtimeManifest: manifest, + }), + /(?:Size|SHA-256) mismatch/ + ); + + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: inputRoot, + runtimeManifest: manifest, + }); + fs.writeFileSync(path.join(inputRoot, 'licenses', 'undeclared.txt'), 'x'); + assert.throws( + () => + generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: path.join(fixtureRoot, 'extra-output'), + runtimeManifest: manifest, + }), + /undeclared license input/ + ); + + fs.rmSync(path.join(inputRoot, 'licenses', 'undeclared.txt')); + fs.rmSync( + path.join(inputRoot, collectedManifest.packages[0].files[0].path) + ); + assert.throws( + () => + generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: path.join(fixtureRoot, 'missing-output'), + runtimeManifest: manifest, + }), + /Missing .*license input/ + ); +}); + +test('CLI collects immutable inputs and regenerates the packaged notice bundle', (t) => { + const sourceRoot = createSourceFixture(); + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-notices-cli-') + ); + const manifestPath = path.join(fixtureRoot, 'runtime-manifest.json'); + const inputRoot = path.join(fixtureRoot, 'inputs'); + const outputRoot = path.join(fixtureRoot, 'notices'); + fs.writeFileSync( + manifestPath, + `${JSON.stringify(runtimeManifest(), null, 2)}\n` + ); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + for (const args of [ + [ + 'collect', + '--runtime-manifest', + manifestPath, + '--source-root', + sourceRoot, + '--output-root', + inputRoot, + ], + [ + 'generate', + '--runtime-manifest', + manifestPath, + '--license-input-root', + inputRoot, + '--output-root', + outputRoot, + ], + ]) { + const result = spawnSync(process.execPath, [generatorScript, ...args], { + encoding: 'utf8', + }); + assert.equal( + result.status, + 0, + [result.stdout, result.stderr].filter(Boolean).join('\n') + ); + } + assert.deepEqual( + validateLinuxRuntimeNotices(outputRoot, runtimeManifest()), + [] + ); +}); diff --git a/tools/embedded-mpv/linux-runtime-manifest.cjs b/tools/embedded-mpv/linux-runtime-manifest.cjs new file mode 100644 index 000000000..4230ee129 --- /dev/null +++ b/tools/embedded-mpv/linux-runtime-manifest.cjs @@ -0,0 +1,999 @@ +'use strict'; + +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { + EXTERNAL_SYSTEM_LIBRARIES, + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, + GLIBC_TOOLCHAIN_ALLOWLIST, + MINIMUM_TOOL_VERSIONS, + PORTABLE_ABI_BASELINE, + REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, + SOURCE_PACKAGES, + compareVersions, + parseVersion, +} = require('./build-linux-runtime.cjs'); + +const LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION = 1; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +const SAFE_BASENAME_PATTERN = /^[A-Za-z0-9_+.-]+$/; +const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/; +const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; +const REQUIRED_SOURCE_PACKAGES = SOURCE_PACKAGES.map(({ id }) => id); +const SOURCE_PACKAGE_BY_ID = new Map( + SOURCE_PACKAGES.map((sourcePackage) => [sourcePackage.id, sourcePackage]) +); +const ALLOWED_EXTERNAL_LIBRARY_NAMES = new Set([ + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), +]); +const SUBMODULE_RECORD_PATTERN = /^[a-f0-9]{40,64}\s+([A-Za-z0-9_+./-]+)$/; +const LINUX_SYSTEM_BACKEND = 'process-isolated mpv --wid'; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function isSafeBasename(value) { + return ( + isNonEmptyString(value) && + path.basename(value) === value && + !value.includes('/') && + !value.includes('\\') && + value !== '.' && + value !== '..' && + SAFE_BASENAME_PATTERN.test(value) + ); +} + +function isValidSubmoduleRecord(value) { + if (typeof value !== 'string') { + return false; + } + const match = value.match(SUBMODULE_RECORD_PATTERN); + if (!match) { + return false; + } + const submodulePath = match[1]; + return ( + !path.posix.isAbsolute(submodulePath) && + !submodulePath + .split('/') + .some((segment) => segment === '.' || segment === '..') + ); +} + +function validateSourceMetadata(errors, value, label) { + if (!isNonEmptyString(value.version)) { + errors.push( + `Linux runtime manifest ${label}.version must be a non-empty string.` + ); + } + if (!isNonEmptyString(value.sourceUrl)) { + errors.push( + `Linux runtime manifest ${label}.sourceUrl must be a non-empty string.` + ); + } + + const hasSourceSha256 = value.sourceSha256 !== undefined; + const hasSourceGitCommit = value.sourceGitCommit !== undefined; + if (!hasSourceSha256 && !hasSourceGitCommit) { + errors.push( + `Linux runtime manifest ${label} must include sourceSha256 or sourceGitCommit.` + ); + return; + } + if ( + hasSourceSha256 && + (typeof value.sourceSha256 !== 'string' || + !SHA256_PATTERN.test(value.sourceSha256)) + ) { + errors.push( + `Linux runtime manifest ${label}.sourceSha256 must be a lowercase 64-character hexadecimal digest.` + ); + } + if ( + hasSourceGitCommit && + (typeof value.sourceGitCommit !== 'string' || + !GIT_COMMIT_PATTERN.test(value.sourceGitCommit)) + ) { + errors.push( + `Linux runtime manifest ${label}.sourceGitCommit must be a lowercase hexadecimal commit digest.` + ); + } +} + +function validatePackages(errors, packages) { + if (!isObject(packages)) { + errors.push( + 'Linux runtime manifest packages must contain source package metadata.' + ); + return; + } + + if (Object.keys(packages).length === 0) { + errors.push( + 'Linux runtime manifest packages must contain source package metadata.' + ); + } + + const invalidRequiredPackages = new Set(); + for (const packageName of REQUIRED_SOURCE_PACKAGES) { + if ( + !Object.prototype.hasOwnProperty.call(packages, packageName) || + !isObject(packages[packageName]) + ) { + errors.push( + `Linux runtime manifest packages.${packageName} must be an object.` + ); + invalidRequiredPackages.add(packageName); + } + } + + for (const packageName of Object.keys(packages).sort()) { + if (!SOURCE_PACKAGE_BY_ID.has(packageName)) { + errors.push( + `Linux runtime manifest packages contains unexpected source package "${packageName}".` + ); + } + } + + for (const packageName of Object.keys(packages).sort()) { + const packageMetadata = packages[packageName]; + const label = `packages.${packageName}`; + if (!isObject(packageMetadata)) { + if (!invalidRequiredPackages.has(packageName)) { + errors.push( + `Linux runtime manifest ${label} must be an object.` + ); + } + continue; + } + + validateSourceMetadata(errors, packageMetadata, label); + if (!isNonEmptyString(packageMetadata.license)) { + errors.push( + `Linux runtime manifest ${label}.license must be a non-empty string.` + ); + } + + const pinnedPackage = SOURCE_PACKAGE_BY_ID.get(packageName); + if (!pinnedPackage) { + continue; + } + + for (const field of ['version', 'sourceUrl', 'license']) { + if ( + isNonEmptyString(packageMetadata[field]) && + packageMetadata[field] !== pinnedPackage[field] + ) { + errors.push( + `Linux runtime manifest ${label}.${field} must equal the pinned value.` + ); + } + } + + if (pinnedPackage.sourceTag) { + if (packageMetadata.sourceTag !== pinnedPackage.sourceTag) { + errors.push( + `Linux runtime manifest ${label}.sourceTag must equal the pinned tag.` + ); + } + } else if (packageMetadata.sourceTag !== undefined) { + errors.push( + `Linux runtime manifest ${label} must not include sourceTag.` + ); + } + + if (pinnedPackage.buildInput) { + if ( + !isObject(packageMetadata.buildInput) || + !isDeepStrictEqual( + packageMetadata.buildInput, + pinnedPackage.buildInput + ) + ) { + errors.push( + `Linux runtime manifest ${label}.buildInput must equal the pinned build input.` + ); + } + } else if (packageMetadata.buildInput !== undefined) { + errors.push( + `Linux runtime manifest ${label} must not include buildInput.` + ); + } + + if (pinnedPackage.sourceKind === 'archive') { + if ( + typeof packageMetadata.sourceSha256 === 'string' && + SHA256_PATTERN.test(packageMetadata.sourceSha256) && + packageMetadata.sourceSha256 !== pinnedPackage.expectedSha256 + ) { + errors.push( + `Linux runtime manifest ${label}.sourceSha256 must equal the pinned digest.` + ); + } + if (packageMetadata.sourceGitCommit !== undefined) { + errors.push( + `Linux runtime manifest ${label} must not include sourceGitCommit.` + ); + } + if (packageMetadata.sourceSubmodules !== undefined) { + errors.push( + `Linux runtime manifest ${label} must not include sourceSubmodules.` + ); + } + continue; + } + + if ( + typeof packageMetadata.sourceGitCommit === 'string' && + GIT_COMMIT_PATTERN.test(packageMetadata.sourceGitCommit) && + packageMetadata.sourceGitCommit !== pinnedPackage.expectedGitCommit + ) { + errors.push( + `Linux runtime manifest ${label}.sourceGitCommit must equal the pinned commit.` + ); + } + if (packageMetadata.sourceSha256 !== undefined) { + errors.push( + `Linux runtime manifest ${label} must not include sourceSha256.` + ); + } + if ( + !Array.isArray(packageMetadata.sourceSubmodules) || + packageMetadata.sourceSubmodules.length === 0 || + packageMetadata.sourceSubmodules.some( + (record) => !isValidSubmoduleRecord(record) + ) || + new Set(packageMetadata.sourceSubmodules).size !== + packageMetadata.sourceSubmodules.length + ) { + errors.push( + `Linux runtime manifest ${label}.sourceSubmodules must be a non-empty array of commit-and-path records.` + ); + } else if ( + !isDeepStrictEqual( + packageMetadata.sourceSubmodules, + pinnedPackage.expectedSubmodules + ) + ) { + errors.push( + `Linux runtime manifest ${label}.sourceSubmodules must equal the pinned records.` + ); + } + } +} + +function validateFlags(errors, value, label, options) { + if ( + !Array.isArray(value) || + value.some((flag) => typeof flag !== 'string') + ) { + errors.push( + `Linux runtime manifest ${label} must be an array of strings.` + ); + return; + } + + const addForbiddenErrors = () => { + for (const forbiddenFlag of options.forbidden) { + if (value.includes(forbiddenFlag)) { + errors.push( + `Linux runtime manifest ${label} must not include "${forbiddenFlag}".` + ); + } + } + }; + const addRequiredErrors = () => { + for (const requiredFlag of options.required) { + if (!value.includes(requiredFlag)) { + errors.push( + `Linux runtime manifest ${label} must include "${requiredFlag}".` + ); + } + } + }; + + if (options.requiredFirst) { + addRequiredErrors(); + addForbiddenErrors(); + } else { + addForbiddenErrors(); + addRequiredErrors(); + } +} + +function validateFfmpeg(errors, ffmpeg) { + if (!isObject(ffmpeg)) { + errors.push('Linux runtime manifest ffmpeg must be an object.'); + return; + } + + validateFlags(errors, ffmpeg.configureFlags, 'ffmpeg.configureFlags', { + forbidden: ['--enable-gpl', '--enable-nonfree'], + required: ['--disable-gpl', '--disable-nonfree'], + }); +} + +function validateMpv(errors, mpv) { + if (!isObject(mpv)) { + errors.push('Linux runtime manifest mpv must be an object.'); + return; + } + + validateFlags(errors, mpv.mesonFlags, 'mpv.mesonFlags', { + forbidden: [], + required: ['-Dgpl=false', '-Dlibmpv=true'], + requiredFirst: true, + }); + + if (Array.isArray(mpv.mesonFlags)) { + const assignmentsByOption = new Map(); + for (const flag of mpv.mesonFlags) { + const match = + typeof flag === 'string' ? flag.match(/^(-D[^=]+)=/) : null; + if (!match) { + continue; + } + const option = match[1]; + const assignments = assignmentsByOption.get(option) ?? []; + assignments.push(flag); + assignmentsByOption.set(option, assignments); + } + for (const [option, assignments] of assignmentsByOption) { + if (assignments.length > 1) { + errors.push( + `Linux runtime manifest mpv.mesonFlags must assign "${option}" exactly once.` + ); + } + } + + for (const [option, requiredFlag] of [ + ['-Dgpl', '-Dgpl=false'], + ['-Dlibmpv', '-Dlibmpv=true'], + ]) { + const assignments = assignmentsByOption.get(option) ?? []; + for (const flag of assignments) { + if (flag !== requiredFlag) { + errors.push( + `Linux runtime manifest mpv.mesonFlags must not include "${flag}".` + ); + } + } + } + } +} + +function validateRuntimeFiles(errors, runtimeFiles) { + if (!Array.isArray(runtimeFiles) || runtimeFiles.length === 0) { + errors.push( + 'Linux runtime manifest runtimeFiles must be a non-empty array.' + ); + return; + } + + const names = new Set(); + let hasLibMpvLinkerAlias = false; + let hasVersionedLibMpv = false; + + for (const [index, runtimeFile] of runtimeFiles.entries()) { + const label = `runtimeFiles[${index}]`; + if (!isObject(runtimeFile)) { + errors.push(`Linux runtime manifest ${label} must be an object.`); + continue; + } + + const { name, sha256, size } = runtimeFile; + const safeName = isSafeBasename(name); + if (!safeName) { + errors.push( + `Linux runtime manifest ${label}.name must be a safe shared-library basename.` + ); + } + if ( + typeof name === 'string' && + safeName && + !SHARED_LIBRARY_PATTERN.test(name) + ) { + errors.push( + `Linux runtime manifest ${label}.name must end in ".so" or a numeric ".so.N" suffix.` + ); + } + if (!Number.isInteger(size) || size <= 0) { + errors.push( + `Linux runtime manifest ${label}.size must be a positive integer.` + ); + } + if (typeof sha256 !== 'string' || !SHA256_PATTERN.test(sha256)) { + errors.push( + `Linux runtime manifest ${label}.sha256 must be a lowercase 64-character hexadecimal digest.` + ); + } + + if (typeof name === 'string') { + if (names.has(name)) { + errors.push( + `Linux runtime manifest runtimeFiles contains duplicate name "${name}".` + ); + } else { + names.add(name); + } + if (name === 'libmpv.so') { + hasLibMpvLinkerAlias = true; + } + if (VERSIONED_LIBMPV_PATTERN.test(name)) { + hasVersionedLibMpv = true; + } + } + } + + if (!hasVersionedLibMpv) { + errors.push( + 'Linux runtime manifest runtimeFiles must include a versioned libmpv.so.N entry.' + ); + } + if (!hasLibMpvLinkerAlias) { + errors.push( + 'Linux runtime manifest runtimeFiles must include the libmpv.so linker alias.' + ); + } +} + +function validateRuntimeTotalBytes(errors, runtimeFiles, runtimeTotalBytes) { + const expectedTotal = Array.isArray(runtimeFiles) + ? runtimeFiles.reduce( + (total, runtimeFile) => + total + + (isObject(runtimeFile) && + Number.isInteger(runtimeFile.size) && + runtimeFile.size > 0 + ? runtimeFile.size + : 0), + 0 + ) + : 0; + if ( + !Number.isInteger(runtimeTotalBytes) || + runtimeTotalBytes !== expectedTotal + ) { + errors.push( + `Linux runtime manifest runtimeTotalBytes must equal the sum of runtimeFiles sizes (${expectedTotal}).` + ); + } +} + +function validateRuntimeAbi(errors, runtimeAbi, runtimeFiles) { + if (!isObject(runtimeAbi)) { + errors.push('Linux runtime manifest runtimeAbi must be an object.'); + return; + } + if ( + !isObject(runtimeAbi.baseline) || + !isDeepStrictEqual(runtimeAbi.baseline, PORTABLE_ABI_BASELINE) + ) { + errors.push( + 'Linux runtime manifest runtimeAbi.baseline must exactly match the portable ABI baseline.' + ); + } + + const runtimeNames = Array.isArray(runtimeFiles) + ? runtimeFiles + .filter((runtimeFile) => isObject(runtimeFile)) + .map(({ name }) => name) + .filter((name) => typeof name === 'string') + : []; + const runtimeNameSet = new Set(runtimeNames); + if (!Array.isArray(runtimeAbi.files)) { + errors.push( + 'Linux runtime manifest runtimeAbi.files must contain one record for every runtime file.' + ); + return; + } + + const abiNames = new Set(); + for (const [index, record] of runtimeAbi.files.entries()) { + const label = `runtimeAbi.files[${index}]`; + if (!isObject(record)) { + errors.push(`Linux runtime manifest ${label} must be an object.`); + continue; + } + if ( + !isSafeBasename(record.name) || + !SHARED_LIBRARY_PATTERN.test(record.name) + ) { + errors.push( + `Linux runtime manifest ${label}.name must be a safe shared-library basename.` + ); + } + if (abiNames.has(record.name)) { + errors.push( + `Linux runtime manifest runtimeAbi.files contains duplicate name "${String( + record.name + )}".` + ); + } else if (typeof record.name === 'string') { + abiNames.add(record.name); + } + + for (const [field, maximum] of [ + ['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum], + ['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum], + ]) { + const version = record[field]; + if ( + version !== null && + (typeof version !== 'string' || + !/^\d+(?:\.\d+)+$/.test(version)) + ) { + errors.push( + `Linux runtime manifest ${label}.${field} must be null or a dotted numeric symbol version.` + ); + continue; + } + if (version && compareVersions(version, maximum) > 0) { + errors.push( + `Linux runtime manifest ${label}.${field} must not exceed portable ABI maximum ${maximum}.` + ); + } + } + } + + if ( + runtimeAbi.files.length !== runtimeNames.length || + runtimeNames.some((name) => !abiNames.has(name)) || + [...abiNames].some((name) => !runtimeNameSet.has(name)) + ) { + errors.push( + 'Linux runtime manifest runtimeAbi.files must contain one record for every runtime file.' + ); + } +} + +function validateRuntimeExternalConfiguration( + errors, + runtimeExternalConfiguration +) { + if ( + !isObject(runtimeExternalConfiguration) || + !isDeepStrictEqual( + runtimeExternalConfiguration, + RUNTIME_EXTERNAL_CONFIGURATION + ) + ) { + errors.push( + 'Linux runtime manifest runtimeExternalConfiguration must exactly match the system-owned runtime paths.' + ); + } +} + +function validateRuntimeDependencyClosure( + errors, + runtimeDependencyClosure, + runtimeFiles +) { + if (!isObject(runtimeDependencyClosure)) { + errors.push( + 'Linux runtime manifest runtimeDependencyClosure must be an object.' + ); + return; + } + + const runtimeNames = Array.isArray(runtimeFiles) + ? runtimeFiles + .filter((runtimeFile) => isObject(runtimeFile)) + .map(({ name }) => name) + .filter((name) => typeof name === 'string') + : []; + const runtimeNameSet = new Set(runtimeNames); + const { entries, externalDependencies } = runtimeDependencyClosure; + + if (!Array.isArray(entries)) { + errors.push( + 'Linux runtime manifest runtimeDependencyClosure.entries must contain one record for every runtime file.' + ); + return; + } + + const entryNames = new Set(); + const computedExternalDependencies = new Set(); + for (const [index, entry] of entries.entries()) { + const label = `runtimeDependencyClosure.entries[${index}]`; + if (!isObject(entry)) { + errors.push(`Linux runtime manifest ${label} must be an object.`); + continue; + } + + if ( + !isSafeBasename(entry.name) || + !SHARED_LIBRARY_PATTERN.test(entry.name) + ) { + errors.push( + `Linux runtime manifest ${label}.name must be a safe shared-library basename.` + ); + } + if (entryNames.has(entry.name)) { + errors.push( + `Linux runtime manifest runtimeDependencyClosure.entries contains duplicate name "${String( + entry.name + )}".` + ); + } else if (typeof entry.name === 'string') { + entryNames.add(entry.name); + } + + if ( + entry.soname !== null && + (!isSafeBasename(entry.soname) || + !SHARED_LIBRARY_PATTERN.test(entry.soname)) + ) { + errors.push( + `Linux runtime manifest ${label}.soname must be null or a safe shared-library basename.` + ); + } + + if ( + !Array.isArray(entry.needed) || + entry.needed.some( + (dependencyName) => + !isSafeBasename(dependencyName) || + !SHARED_LIBRARY_PATTERN.test(dependencyName) + ) + ) { + errors.push( + `Linux runtime manifest ${label}.needed must be an array of safe shared-library names.` + ); + } else { + const neededNames = new Set(); + for (const dependencyName of entry.needed) { + if (neededNames.has(dependencyName)) { + errors.push( + `Linux runtime manifest ${label}.needed contains duplicate name "${dependencyName}".` + ); + continue; + } + neededNames.add(dependencyName); + if (runtimeNameSet.has(dependencyName)) { + continue; + } + if (!ALLOWED_EXTERNAL_LIBRARY_NAMES.has(dependencyName)) { + errors.push( + `Linux runtime manifest dependency ${dependencyName} is not in the deterministic system-library allowlist.` + ); + continue; + } + computedExternalDependencies.add(dependencyName); + } + } + + if (!Array.isArray(entry.rpath) || entry.rpath.length !== 0) { + errors.push( + `Linux runtime manifest ${label}.rpath must be an empty array.` + ); + } + if ( + !Array.isArray(entry.runpath) || + entry.runpath.length !== 1 || + entry.runpath[0] !== '$ORIGIN' + ) { + errors.push( + `Linux runtime manifest ${label}.runpath must contain only "$ORIGIN".` + ); + } + } + + if ( + entries.length !== runtimeNames.length || + runtimeNames.some((name) => !entryNames.has(name)) || + [...entryNames].some((name) => !runtimeNameSet.has(name)) + ) { + errors.push( + 'Linux runtime manifest runtimeDependencyClosure.entries must contain one record for every runtime file.' + ); + } + + const libMpvLinkerEntry = entries.find( + (entry) => isObject(entry) && entry.name === 'libmpv.so' + ); + if ( + !libMpvLinkerEntry || + typeof libMpvLinkerEntry.soname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(libMpvLinkerEntry.soname) || + !runtimeNameSet.has(libMpvLinkerEntry.soname) + ) { + errors.push( + 'Linux runtime manifest libmpv.so must declare a versioned SONAME present in runtimeFiles.' + ); + } + + const expectedExternalDependencies = [ + ...computedExternalDependencies, + ].sort(); + if ( + !Array.isArray(externalDependencies) || + externalDependencies.some( + (dependencyName) => + !isSafeBasename(dependencyName) || + !SHARED_LIBRARY_PATTERN.test(dependencyName) + ) || + JSON.stringify(externalDependencies) !== + JSON.stringify(expectedExternalDependencies) + ) { + errors.push( + 'Linux runtime manifest runtimeDependencyClosure.externalDependencies must exactly match the sorted allowlisted external dependency set.' + ); + } +} + +function validateExternalSystemLibraries(errors, externalSystemLibraries) { + const expectedKeys = ['interface', 'name', 'reason']; + const matchesDeterministicAllowlist = + Array.isArray(externalSystemLibraries) && + externalSystemLibraries.length === EXTERNAL_SYSTEM_LIBRARIES.length && + externalSystemLibraries.every((externalLibrary, index) => { + if (!isObject(externalLibrary)) { + return false; + } + const actualKeys = Object.keys(externalLibrary).sort(); + if ( + actualKeys.length !== expectedKeys.length || + actualKeys.some( + (key, keyIndex) => key !== expectedKeys[keyIndex] + ) + ) { + return false; + } + const expectedLibrary = EXTERNAL_SYSTEM_LIBRARIES[index]; + return expectedKeys.every( + (key) => externalLibrary[key] === expectedLibrary[key] + ); + }); + if (!matchesDeterministicAllowlist) { + errors.push( + 'Linux runtime manifest externalSystemLibraries must exactly match the deterministic allowlist.' + ); + } +} + +function validateBuildHost(errors, buildHost) { + if (!isObject(buildHost)) { + errors.push('Linux runtime manifest buildHost must be an object.'); + return; + } + if (buildHost.platform !== 'linux') { + errors.push( + 'Linux runtime manifest buildHost.platform must be "linux".' + ); + } + if (buildHost.arch !== 'x64') { + errors.push('Linux runtime manifest buildHost.arch must be "x64".'); + } + if (!isNonEmptyString(buildHost.release)) { + errors.push( + 'Linux runtime manifest buildHost.release must be a non-empty string.' + ); + } + if ( + typeof buildHost.glibcVersion !== 'string' || + !/^\d+(?:\.\d+)+$/.test(buildHost.glibcVersion) + ) { + errors.push( + 'Linux runtime manifest buildHost.glibcVersion must be a dotted numeric version.' + ); + } else if ( + compareVersions( + buildHost.glibcVersion, + PORTABLE_ABI_BASELINE.glibcMaximum + ) > 0 + ) { + errors.push( + `Linux runtime manifest buildHost.glibcVersion ${buildHost.glibcVersion} exceeds portable ABI baseline maximum ${PORTABLE_ABI_BASELINE.glibcMaximum}.` + ); + } + + if ( + !Array.isArray(buildHost.systemPkgConfigDirs) || + buildHost.systemPkgConfigDirs.length === 0 || + buildHost.systemPkgConfigDirs.some( + (directory) => + !isNonEmptyString(directory) || !path.isAbsolute(directory) + ) || + new Set(buildHost.systemPkgConfigDirs).size !== + buildHost.systemPkgConfigDirs.length + ) { + errors.push( + 'Linux runtime manifest buildHost.systemPkgConfigDirs must be a non-empty array of unique absolute paths.' + ); + } + + if (!isObject(buildHost.systemPkgConfigPackages)) { + errors.push( + 'Linux runtime manifest buildHost.systemPkgConfigPackages must be an object.' + ); + } else { + for (const packageName of EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES) { + if ( + !isNonEmptyString( + buildHost.systemPkgConfigPackages[packageName] + ) + ) { + errors.push( + `Linux runtime manifest buildHost.systemPkgConfigPackages.${packageName} must be a non-empty version string.` + ); + } + } + for (const packageName of Object.keys( + buildHost.systemPkgConfigPackages + ).sort()) { + if (!EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES.includes(packageName)) { + errors.push( + `Linux runtime manifest buildHost.systemPkgConfigPackages contains unexpected package "${packageName}".` + ); + } + } + } + + if (!isObject(buildHost.tools)) { + errors.push( + 'Linux runtime manifest buildHost.tools must be an object.' + ); + return; + } + for (const tool of REQUIRED_TOOLS) { + const declaredVersion = buildHost.tools[tool]; + if (!isNonEmptyString(declaredVersion)) { + errors.push( + `Linux runtime manifest buildHost.tools.${tool} must be a non-empty version string.` + ); + continue; + } + const actualVersion = parseVersion(declaredVersion); + if (!actualVersion) { + errors.push( + `Linux runtime manifest buildHost.tools.${tool} must contain a parseable dotted numeric version.` + ); + } else if ( + compareVersions(actualVersion, MINIMUM_TOOL_VERSIONS[tool]) < 0 + ) { + errors.push( + `Linux runtime manifest buildHost.tools.${tool} ${actualVersion} is unsupported; requires ${MINIMUM_TOOL_VERSIONS[tool]} or newer.` + ); + } + } + for (const tool of Object.keys(buildHost.tools).sort()) { + if (!REQUIRED_TOOLS.includes(tool)) { + errors.push( + `Linux runtime manifest buildHost.tools contains unexpected tool "${tool}".` + ); + } + } +} + +function validateLinuxRuntimeManifest(manifest) { + if (!isObject(manifest)) { + return ['Linux runtime manifest must be an object.']; + } + + const errors = []; + if (manifest.schemaVersion !== LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION) { + errors.push( + `Linux runtime manifest schemaVersion must be ${LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION}.` + ); + } + if (manifest.origin !== 'vendored-lgpl-source-build') { + errors.push( + 'Linux runtime manifest origin must be "vendored-lgpl-source-build".' + ); + } + if (manifest.platform !== 'linux') { + errors.push('Linux runtime manifest platform must be "linux".'); + } + if (manifest.arch !== 'x64') { + errors.push('Linux runtime manifest arch must be "x64".'); + } + + validatePackages(errors, manifest.packages); + validateFfmpeg(errors, manifest.ffmpeg); + validateMpv(errors, manifest.mpv); + + if (!isNonEmptyString(manifest.sourceDistribution)) { + errors.push( + 'Linux runtime manifest sourceDistribution must be a non-empty string.' + ); + } else { + for (const [sourceName, sourcePattern] of [ + ['hwdata', /\bhwdata\b/i], + ['pnp.ids', /\bpnp\.ids\b/i], + ['libdisplay-info', /\blibdisplay-info\b/i], + ]) { + if (!sourcePattern.test(manifest.sourceDistribution)) { + errors.push( + `Linux runtime manifest sourceDistribution must explicitly include ${sourceName}.` + ); + } + } + } + + validateRuntimeFiles(errors, manifest.runtimeFiles); + validateRuntimeTotalBytes( + errors, + manifest.runtimeFiles, + manifest.runtimeTotalBytes + ); + validateRuntimeAbi(errors, manifest.runtimeAbi, manifest.runtimeFiles); + validateRuntimeExternalConfiguration( + errors, + manifest.runtimeExternalConfiguration + ); + validateRuntimeDependencyClosure( + errors, + manifest.runtimeDependencyClosure, + manifest.runtimeFiles + ); + validateExternalSystemLibraries(errors, manifest.externalSystemLibraries); + validateBuildHost(errors, manifest.buildHost); + return errors; +} + +function isLinuxSystemBuildInputManifest(manifest) { + return isObject(manifest) && manifest.linuxBackend === LINUX_SYSTEM_BACKEND; +} + +function validateLinuxSystemBuildInputManifest(manifest) { + if (!isObject(manifest)) { + return ['Linux system build-input manifest must be an object.']; + } + + const errors = []; + if (manifest.linuxBackend !== LINUX_SYSTEM_BACKEND) { + errors.push( + `Linux system build-input manifest linuxBackend must be "${LINUX_SYSTEM_BACKEND}".` + ); + } + + if (!isObject(manifest.buildInputs)) { + errors.push( + 'Linux system build-input manifest buildInputs must be an object.' + ); + } else { + for (const packageName of ['libmpvDevPackage', 'mpvPackage']) { + if (!isNonEmptyString(manifest.buildInputs[packageName])) { + errors.push( + `Linux system build-input manifest buildInputs.${packageName} must be a non-empty string.` + ); + } + } + } + + if (!isNonEmptyString(manifest.sourceDistribution)) { + errors.push( + 'Linux system build-input manifest sourceDistribution must be a non-empty string.' + ); + } + if (Object.prototype.hasOwnProperty.call(manifest, 'origin')) { + errors.push( + 'Linux system build-input manifest must not include origin.' + ); + } + if (Object.prototype.hasOwnProperty.call(manifest, 'runtimeFiles')) { + errors.push( + 'Linux system build-input manifest must not include runtimeFiles.' + ); + } + + return errors; +} + +module.exports = { + LINUX_SYSTEM_BACKEND, + LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION, + isLinuxSystemBuildInputManifest, + validateLinuxRuntimeManifest, + validateLinuxSystemBuildInputManifest, +}; diff --git a/tools/embedded-mpv/linux-runtime-manifest.test.mjs b/tools/embedded-mpv/linux-runtime-manifest.test.mjs new file mode 100644 index 000000000..81ae64543 --- /dev/null +++ b/tools/embedded-mpv/linux-runtime-manifest.test.mjs @@ -0,0 +1,1185 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION, + validateLinuxRuntimeManifest, + validateLinuxSystemBuildInputManifest, +} = require('./linux-runtime-manifest.cjs'); +const { + DEFAULT_SYSTEM_PKG_CONFIG_DIRS, + EXTERNAL_SYSTEM_LIBRARIES, + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, + GLIBC_TOOLCHAIN_ALLOWLIST, + MINIMUM_TOOL_VERSIONS, + PORTABLE_ABI_BASELINE, + REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, + SOURCE_PACKAGES, +} = require('./build-linux-runtime.cjs'); + +const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); +const stageRuntimeScript = path.join( + workspaceRoot, + 'tools', + 'embedded-mpv', + 'stage-runtime.mjs' +); +const stageRuntimeSource = fs.readFileSync(stageRuntimeScript, 'utf8'); +const EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES = Object.freeze([ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear', +]); + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function runtimeFile(name, contents) { + return { + name, + size: Buffer.byteLength(contents), + sha256: sha256(contents), + }; +} + +function sourcePackageRecord(sourcePackage) { + return { + version: sourcePackage.version, + sourceUrl: sourcePackage.sourceUrl, + ...(sourcePackage.sourceTag + ? { sourceTag: sourcePackage.sourceTag } + : {}), + ...(sourcePackage.buildInput + ? { buildInput: structuredClone(sourcePackage.buildInput) } + : {}), + ...(sourcePackage.sourceKind === 'archive' + ? { sourceSha256: sourcePackage.expectedSha256 } + : { + sourceGitCommit: sourcePackage.expectedGitCommit, + sourceSubmodules: [...EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES], + }), + license: sourcePackage.license, + }; +} + +function createValidManifest( + runtimeFiles = [ + runtimeFile('libmpv.so.2', 'libmpv-runtime'), + runtimeFile('libavcodec.so.61', 'libavcodec-runtime'), + runtimeFile('libmpv.so', 'libmpv-runtime'), + ] +) { + const packages = Object.fromEntries( + SOURCE_PACKAGES.map((sourcePackage) => [ + sourcePackage.id, + sourcePackageRecord(sourcePackage), + ]) + ); + const runtimeNames = new Set( + runtimeFiles + .map((runtimeEntry) => runtimeEntry?.name) + .filter((name) => typeof name === 'string') + ); + const closureEntries = runtimeFiles + .filter( + (runtimeEntry) => + runtimeEntry && typeof runtimeEntry.name === 'string' + ) + .map((runtimeEntry) => ({ + name: runtimeEntry.name, + soname: runtimeEntry.name.startsWith('libmpv.so') + ? 'libmpv.so.2' + : runtimeEntry.name, + needed: runtimeEntry.name.startsWith('libmpv.so') + ? [ + ...(runtimeNames.has('libavcodec.so.61') + ? ['libavcodec.so.61'] + : []), + 'libEGL.so.1', + 'libc.so.6', + ] + : ['libm.so.6'], + rpath: [], + runpath: ['$ORIGIN'], + })); + const externalDependencies = [ + ...new Set( + closureEntries + .flatMap(({ needed }) => needed) + .filter((neededName) => !runtimeNames.has(neededName)) + ), + ].sort(); + + return { + schemaVersion: 1, + origin: 'vendored-lgpl-source-build', + platform: 'linux', + arch: 'x64', + packages, + ffmpeg: { + ...packages.ffmpeg, + configureFlags: [ + '--enable-shared', + '--disable-gpl', + '--disable-nonfree', + ], + }, + mpv: { + ...packages.mpv, + mesonFlags: ['-Dlibmpv=true', '-Dgpl=false'], + }, + sourceDistribution: + 'Publish the exact source archives, including pinned hwdata pnp.ids and the MIT-licensed libdisplay-info source archive.', + runtimeFiles, + runtimeTotalBytes: runtimeFiles.reduce( + (total, runtimeEntry) => total + (runtimeEntry?.size ?? 0), + 0 + ), + runtimeAbi: { + baseline: { ...PORTABLE_ABI_BASELINE }, + files: runtimeFiles + .filter( + (runtimeEntry) => + runtimeEntry && typeof runtimeEntry.name === 'string' + ) + .map(({ name }) => ({ + name, + requiredGlibc: '2.34', + requiredGlibcxx: null, + })), + }, + runtimeExternalConfiguration: structuredClone( + RUNTIME_EXTERNAL_CONFIGURATION + ), + runtimeDependencyClosure: { + entries: closureEntries, + externalDependencies, + }, + externalSystemLibraries: EXTERNAL_SYSTEM_LIBRARIES.map( + (externalLibrary) => ({ ...externalLibrary }) + ), + buildHost: { + platform: 'linux', + arch: 'x64', + release: 'fixture-kernel', + glibcVersion: '2.35', + systemPkgConfigDirs: [...DEFAULT_SYSTEM_PKG_CONFIG_DIRS], + systemPkgConfigPackages: Object.fromEntries( + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES.map((packageName) => [ + packageName, + `${packageName} fixture version`, + ]) + ), + tools: Object.fromEntries( + REQUIRED_TOOLS.map((tool) => [ + tool, + `${tool} ${MINIMUM_TOOL_VERSIONS[tool]}`, + ]) + ), + }, + }; +} + +function createSystemBuildInputManifest() { + return { + linuxBackend: 'process-isolated mpv --wid', + buildInputs: { + libmpvDevPackage: '2:0.40.0-3ubuntu2', + mpvPackage: '0.40.0-3ubuntu2', + }, + sourceDistribution: + 'Linux CI build inputs come from Ubuntu runner packages. Runtime playback uses the system mpv executable; IPTVnator does not bundle or load libmpv in the Electron process on Linux.', + }; +} + +function createFixture(t, options = {}) { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-runtime-test-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const prefix = path.join(root, 'prefix'); + const includeDir = path.join(prefix, 'include', 'mpv'); + const libDir = path.join(prefix, 'lib'); + fs.mkdirSync(includeDir, { recursive: true }); + fs.mkdirSync(libDir, { recursive: true }); + fs.writeFileSync( + path.join(includeDir, 'client.h'), + '/* libmpv header */\n' + ); + + const contentsByName = new Map([ + ['libmpv.so.2', 'libmpv-runtime'], + ['libavcodec.so.61', 'libavcodec-runtime'], + ['libmpv.so', 'libmpv-runtime'], + ]); + + for (const [name, contents] of contentsByName) { + fs.writeFileSync(path.join(libDir, name), contents); + } + + const manifest = + options.manifest ?? + createValidManifest( + [...contentsByName].map(([name, contents]) => + runtimeFile(name, contents) + ) + ); + options.mutateManifest?.(manifest); + + if (options.removeRuntimeFile) { + fs.rmSync(path.join(libDir, options.removeRuntimeFile), { + force: true, + }); + } + if (options.removeHeader) { + fs.rmSync(path.join(includeDir, 'client.h'), { force: true }); + } + if (!options.omitManifest) { + fs.writeFileSync( + path.join(prefix, 'runtime-manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + } + + return { libDir, manifest, prefix, root }; +} + +function destinationRootFor(fixture) { + return path.join(fixture.root, 'vendor', 'embedded-mpv', 'linux-x64'); +} + +function runStage(fixture) { + return spawnSync( + process.execPath, + [stageRuntimeScript, 'linux', 'x64', fixture.prefix], + { + cwd: fixture.root, + encoding: 'utf8', + } + ); +} + +function assertStageRejected(t, options, expectedError) { + const fixture = createFixture(t, options); + const result = runStage(fixture); + + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, expectedError); +} + +test('exports the Linux runtime manifest schema version', () => { + assert.equal(LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION, 1); +}); + +test('accepts a complete LGPL Linux x64 source-build manifest', () => { + assert.deepEqual(validateLinuxRuntimeManifest(createValidManifest()), []); +}); + +test('requires the exact pinned source package set and provenance', () => { + const expectedPackageNames = SOURCE_PACKAGES.map(({ id }) => id).sort(); + assert.deepEqual( + Object.keys(createValidManifest().packages).sort(), + expectedPackageNames + ); + + for (const packageName of expectedPackageNames) { + const manifest = createValidManifest(); + delete manifest.packages[packageName]; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + new RegExp(`packages\\.${packageName} must be an object`) + ); + } + + const unexpectedPackage = createValidManifest(); + unexpectedPackage.packages.unpinned = { + version: '1.0.0', + sourceUrl: 'https://example.test/unpinned.tar.xz', + sourceSha256: 'f'.repeat(64), + license: 'MIT', + }; + assert.match( + validateLinuxRuntimeManifest(unexpectedPackage).join('\n'), + /packages contains unexpected source package "unpinned"/ + ); +}); + +test('requires pinned archive hashes and exact libplacebo git provenance', () => { + const archiveMismatch = createValidManifest(); + archiveMismatch.packages.freetype.sourceSha256 = '0'.repeat(64); + assert.match( + validateLinuxRuntimeManifest(archiveMismatch).join('\n'), + /packages\.freetype\.sourceSha256 must equal the pinned digest/ + ); + + const commitMismatch = createValidManifest(); + commitMismatch.packages.libplacebo.sourceGitCommit = '0'.repeat(40); + assert.match( + validateLinuxRuntimeManifest(commitMismatch).join('\n'), + /packages\.libplacebo\.sourceGitCommit must equal the pinned commit/ + ); + + const submoduleCommitMismatch = createValidManifest(); + submoduleCommitMismatch.packages.libplacebo.sourceSubmodules[0] = `${'f'.repeat(40)} 3rdparty/Vulkan-Headers`; + assert.match( + validateLinuxRuntimeManifest(submoduleCommitMismatch).join('\n'), + /packages\.libplacebo\.sourceSubmodules must equal the pinned records/ + ); + + const hostHwdataInput = createValidManifest(); + hostHwdataInput.packages.hwdata.buildInput.relativePath = + '/usr/share/hwdata/pnp.ids'; + assert.match( + validateLinuxRuntimeManifest(hostHwdataInput).join('\n'), + /packages\.hwdata\.buildInput must equal the pinned build input/ + ); + + const validSubmoduleRecord = `${'a'.repeat(40)} 3rdparty/example`; + for (const sourceSubmodules of [ + [], + ['not-a-submodule-record'], + [`${'a'.repeat(40)} ../escape`], + [validSubmoduleRecord, validSubmoduleRecord], + ]) { + const invalidSubmodules = createValidManifest(); + invalidSubmodules.packages.libplacebo.sourceSubmodules = + sourceSubmodules; + assert.match( + validateLinuxRuntimeManifest(invalidSubmodules).join('\n'), + /packages\.libplacebo\.sourceSubmodules/ + ); + } +}); + +test('requires runtimeTotalBytes to equal the declared runtime file sizes', () => { + const missingTotal = createValidManifest(); + delete missingTotal.runtimeTotalBytes; + assert.match( + validateLinuxRuntimeManifest(missingTotal).join('\n'), + /runtimeTotalBytes must equal the sum/ + ); + + const wrongTotal = createValidManifest(); + wrongTotal.runtimeTotalBytes += 1; + assert.match( + validateLinuxRuntimeManifest(wrongTotal).join('\n'), + /runtimeTotalBytes must equal the sum/ + ); +}); + +test('requires a complete ORIGIN-only runtime dependency closure', () => { + const missingEntry = createValidManifest(); + missingEntry.runtimeDependencyClosure.entries.pop(); + assert.match( + validateLinuxRuntimeManifest(missingEntry).join('\n'), + /runtimeDependencyClosure\.entries must contain one record for every runtime file/ + ); + + const duplicateEntry = createValidManifest(); + duplicateEntry.runtimeDependencyClosure.entries[1].name = + duplicateEntry.runtimeDependencyClosure.entries[0].name; + assert.match( + validateLinuxRuntimeManifest(duplicateEntry).join('\n'), + /runtimeDependencyClosure\.entries contains duplicate name/ + ); + + const invalidNeeded = createValidManifest(); + invalidNeeded.runtimeDependencyClosure.entries[0].needed = 'libc.so.6'; + assert.match( + validateLinuxRuntimeManifest(invalidNeeded).join('\n'), + /needed must be an array of safe shared-library names/ + ); + + const absoluteRpath = createValidManifest(); + absoluteRpath.runtimeDependencyClosure.entries[0].rpath = ['/tmp/lib']; + assert.match( + validateLinuxRuntimeManifest(absoluteRpath).join('\n'), + /rpath must be an empty array/ + ); + + const wrongRunpath = createValidManifest(); + wrongRunpath.runtimeDependencyClosure.entries[0].runpath = ['/tmp/lib']; + assert.match( + validateLinuxRuntimeManifest(wrongRunpath).join('\n'), + /runpath must contain only "\$ORIGIN"/ + ); + + const unknownDependency = createValidManifest(); + unknownDependency.runtimeDependencyClosure.entries[0].needed.push( + 'libsurprise.so.1' + ); + unknownDependency.runtimeDependencyClosure.externalDependencies.push( + 'libsurprise.so.1' + ); + assert.match( + validateLinuxRuntimeManifest(unknownDependency).join('\n'), + /libsurprise\.so\.1.*not in the deterministic system-library allowlist/ + ); +}); + +test('requires safe SONAME metadata and a bundled versioned libmpv target', () => { + const missingLinkerAlias = createValidManifest(); + const aliasFile = missingLinkerAlias.runtimeFiles.find( + ({ name }) => name === 'libmpv.so' + ); + missingLinkerAlias.runtimeFiles = missingLinkerAlias.runtimeFiles.filter( + ({ name }) => name !== 'libmpv.so' + ); + missingLinkerAlias.runtimeTotalBytes -= aliasFile.size; + missingLinkerAlias.runtimeAbi.files = + missingLinkerAlias.runtimeAbi.files.filter( + ({ name }) => name !== 'libmpv.so' + ); + missingLinkerAlias.runtimeDependencyClosure.entries = + missingLinkerAlias.runtimeDependencyClosure.entries.filter( + ({ name }) => name !== 'libmpv.so' + ); + assert.match( + validateLinuxRuntimeManifest(missingLinkerAlias).join('\n'), + /runtimeFiles must include the libmpv\.so linker alias/ + ); + + const missingLibmpvSoname = createValidManifest(); + missingLibmpvSoname.runtimeDependencyClosure.entries.find( + ({ name }) => name === 'libmpv.so' + ).soname = null; + assert.match( + validateLinuxRuntimeManifest(missingLibmpvSoname).join('\n'), + /libmpv\.so must declare a versioned SONAME present in runtimeFiles/ + ); + + const unsafeSoname = createValidManifest(); + unsafeSoname.runtimeDependencyClosure.entries[0].soname = '../libmpv.so.2'; + assert.match( + validateLinuxRuntimeManifest(unsafeSoname).join('\n'), + /runtimeDependencyClosure\.entries\[0\]\.soname must be null or a safe shared-library basename/ + ); + + const absentSonameTarget = createValidManifest(); + absentSonameTarget.runtimeDependencyClosure.entries.find( + ({ name }) => name === 'libmpv.so' + ).soname = 'libmpv.so.99'; + assert.match( + validateLinuxRuntimeManifest(absentSonameTarget).join('\n'), + /libmpv\.so must declare a versioned SONAME present in runtimeFiles/ + ); +}); + +test('requires the deterministic external-system-library records', () => { + const manifest = createValidManifest(); + manifest.externalSystemLibraries.reverse(); + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /externalSystemLibraries must exactly match the deterministic allowlist/ + ); + + const allowedNames = new Set([ + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), + ]); + for (const dependencyName of createValidManifest().runtimeDependencyClosure + .externalDependencies) { + assert.equal(allowedNames.has(dependencyName), true, dependencyName); + } +}); + +test('requires a Linux x64 build host and every required tool version', () => { + const wrongPlatform = createValidManifest(); + wrongPlatform.buildHost.platform = 'darwin'; + assert.match( + validateLinuxRuntimeManifest(wrongPlatform).join('\n'), + /buildHost\.platform must be "linux"/ + ); + + const wrongArch = createValidManifest(); + wrongArch.buildHost.arch = 'arm64'; + assert.match( + validateLinuxRuntimeManifest(wrongArch).join('\n'), + /buildHost\.arch must be "x64"/ + ); + + for (const tool of REQUIRED_TOOLS) { + const missingTool = createValidManifest(); + delete missingTool.buildHost.tools[tool]; + assert.match( + validateLinuxRuntimeManifest(missingTool).join('\n'), + new RegExp(`buildHost\\.tools\\.${tool.replace('-', '\\-')}`) + ); + } + + const unexpectedTool = createValidManifest(); + unexpectedTool.buildHost.tools.unexpected = '1.0'; + assert.match( + validateLinuxRuntimeManifest(unexpectedTool).join('\n'), + /buildHost\.tools contains unexpected tool "unexpected"/ + ); + + const relativePkgConfigDir = createValidManifest(); + relativePkgConfigDir.buildHost.systemPkgConfigDirs = ['relative/pkgconfig']; + assert.match( + validateLinuxRuntimeManifest(relativePkgConfigDir).join('\n'), + /buildHost\.systemPkgConfigDirs must be a non-empty array of unique absolute paths/ + ); + + for (const packageName of EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES) { + const missingPackage = createValidManifest(); + delete missingPackage.buildHost.systemPkgConfigPackages[packageName]; + assert.match( + validateLinuxRuntimeManifest(missingPackage).join('\n'), + new RegExp( + `buildHost\\.systemPkgConfigPackages\\.${packageName.replace( + '-', + '\\-' + )}` + ) + ); + } + + const unexpectedPackage = createValidManifest(); + unexpectedPackage.buildHost.systemPkgConfigPackages.unexpected = '1.0'; + assert.match( + validateLinuxRuntimeManifest(unexpectedPackage).join('\n'), + /buildHost\.systemPkgConfigPackages contains unexpected package "unexpected"/ + ); + + const unsupportedGlibc = createValidManifest(); + unsupportedGlibc.buildHost.glibcVersion = '2.36'; + assert.match( + validateLinuxRuntimeManifest(unsupportedGlibc).join('\n'), + /buildHost\.glibcVersion.*portable ABI baseline.*2\.35/i + ); + + const unsupportedMeson = createValidManifest(); + unsupportedMeson.buildHost.tools.meson = 'meson 1.5.9'; + assert.match( + validateLinuxRuntimeManifest(unsupportedMeson).join('\n'), + /buildHost\.tools\.meson.*requires 1\.6\.0 or newer/i + ); +}); + +test('requires exact portable ABI and external configuration records', () => { + const missingAbiFile = createValidManifest(); + missingAbiFile.runtimeAbi.files.pop(); + assert.match( + validateLinuxRuntimeManifest(missingAbiFile).join('\n'), + /runtimeAbi\.files must contain one record for every runtime file/ + ); + + const newerGlibcSymbol = createValidManifest(); + newerGlibcSymbol.runtimeAbi.files[0].requiredGlibc = '2.36'; + assert.match( + validateLinuxRuntimeManifest(newerGlibcSymbol).join('\n'), + /runtimeAbi\.files\[0\]\.requiredGlibc.*maximum 2\.35/ + ); + + const newerGlibcxxSymbol = createValidManifest(); + newerGlibcxxSymbol.runtimeAbi.files[0].requiredGlibcxx = '3.4.31'; + assert.match( + validateLinuxRuntimeManifest(newerGlibcxxSymbol).join('\n'), + /runtimeAbi\.files\[0\]\.requiredGlibcxx.*maximum 3\.4\.30/ + ); + + const wrongBaseline = createValidManifest(); + wrongBaseline.runtimeAbi.baseline.distribution = 'current host'; + assert.match( + validateLinuxRuntimeManifest(wrongBaseline).join('\n'), + /runtimeAbi\.baseline must exactly match the portable ABI baseline/ + ); + + const buildPathConfiguration = createValidManifest(); + buildPathConfiguration.runtimeExternalConfiguration.fontconfig.configDirectory = + '/tmp/build-prefix/etc/fonts'; + assert.match( + validateLinuxRuntimeManifest(buildPathConfiguration).join('\n'), + /runtimeExternalConfiguration must exactly match the system-owned runtime paths/ + ); +}); + +test('requires the source-distribution statement to name pinned display data', () => { + const manifest = createValidManifest(); + manifest.sourceDistribution = + 'Publish all of the other source archives with the binary release.'; + const errors = validateLinuxRuntimeManifest(manifest).join('\n'); + assert.match(errors, /sourceDistribution must explicitly include hwdata/); + assert.match(errors, /sourceDistribution must explicitly include pnp\.ids/); + assert.match( + errors, + /sourceDistribution must explicitly include libdisplay-info/ + ); +}); + +test('accepts and stages the current Linux CI system build-input manifest', (t) => { + const systemManifest = createSystemBuildInputManifest(); + assert.deepEqual(validateLinuxSystemBuildInputManifest(systemManifest), []); + + const fixture = createFixture(t, { manifest: systemManifest }); + const result = runStage(fixture); + assert.equal(result.status, 0, result.stderr); + + const destinationRoot = destinationRootFor(fixture); + assert.equal( + fs.readFileSync( + path.join(destinationRoot, 'include', 'mpv', 'client.h'), + 'utf8' + ), + '/* libmpv header */\n' + ); + assert.equal(fs.existsSync(path.join(destinationRoot, 'lib')), false); + + const stagedManifest = JSON.parse( + fs.readFileSync( + path.join(destinationRoot, 'runtime-manifest.json'), + 'utf8' + ) + ); + assert.equal(stagedManifest.origin, 'vendored-lgpl'); + assert.equal(stagedManifest.platform, 'linux'); + assert.equal(stagedManifest.arch, 'x64'); + assert.deepEqual(stagedManifest.runtimeFiles, []); + assert.deepEqual(stagedManifest.buildInputs, systemManifest.buildInputs); + assert.equal(stagedManifest.linuxBackend, systemManifest.linuxBackend); + assert.equal('sourceBuildOrigin' in stagedManifest, false); +}); + +test('rejects malformed system build-input manifests without falling through', (t) => { + const malformedSystemManifest = createSystemBuildInputManifest(); + malformedSystemManifest.buildInputs.mpvPackage = ''; + malformedSystemManifest.runtimeFiles = []; + + assert.deepEqual( + validateLinuxSystemBuildInputManifest(malformedSystemManifest), + [ + 'Linux system build-input manifest buildInputs.mpvPackage must be a non-empty string.', + 'Linux system build-input manifest must not include runtimeFiles.', + ] + ); + + const fixture = createFixture(t, { manifest: malformedSystemManifest }); + const result = runStage(fixture); + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, /buildInputs\.mpvPackage/); + assert.match(result.stderr, /must not include runtimeFiles/); +}); + +test('returns deterministic validation errors for untrusted input', () => { + assert.deepEqual(validateLinuxRuntimeManifest(null), [ + 'Linux runtime manifest must be an object.', + ]); + assert.deepEqual(validateLinuxRuntimeManifest(null), [ + 'Linux runtime manifest must be an object.', + ]); + + const manifest = createValidManifest(); + manifest.runtimeFiles[0].sha256 = Symbol('not-a-digest'); + assert.doesNotThrow(() => validateLinuxRuntimeManifest(manifest)); + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /runtimeFiles\[0\]\.sha256/ + ); + + const hostileAllowlist = createValidManifest(); + hostileAllowlist.externalSystemLibraries = 1n; + assert.doesNotThrow(() => validateLinuxRuntimeManifest(hostileAllowlist)); + assert.match( + validateLinuxRuntimeManifest(hostileAllowlist).join('\n'), + /externalSystemLibraries/ + ); +}); + +test('requires schema, provenance, target, package, and source metadata', () => { + const manifest = createValidManifest(); + manifest.schemaVersion = 2; + manifest.origin = 'vendored-lgpl'; + manifest.platform = 'darwin'; + manifest.arch = 'arm64'; + manifest.packages.ffmpeg.sourceUrl = ''; + manifest.packages.mpv.version = ''; + manifest.sourceDistribution = ' '; + + assert.deepEqual(validateLinuxRuntimeManifest(manifest), [ + 'Linux runtime manifest schemaVersion must be 1.', + 'Linux runtime manifest origin must be "vendored-lgpl-source-build".', + 'Linux runtime manifest platform must be "linux".', + 'Linux runtime manifest arch must be "x64".', + 'Linux runtime manifest packages.ffmpeg.sourceUrl must be a non-empty string.', + 'Linux runtime manifest packages.mpv.version must be a non-empty string.', + 'Linux runtime manifest sourceDistribution must be a non-empty string.', + ]); + + manifest.packages = {}; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /packages must contain source package metadata/ + ); +}); + +test('requires exact FFmpeg and mpv source package records', () => { + const missingFfmpeg = createValidManifest(); + missingFfmpeg.packages.libavcodec = missingFfmpeg.packages.ffmpeg; + delete missingFfmpeg.packages.ffmpeg; + assert.match( + validateLinuxRuntimeManifest(missingFfmpeg).join('\n'), + /packages\.ffmpeg must be an object/ + ); + + const missingMpv = createValidManifest(); + missingMpv.packages.libmpv = missingMpv.packages.mpv; + delete missingMpv.packages.mpv; + assert.match( + validateLinuxRuntimeManifest(missingMpv).join('\n'), + /packages\.mpv must be an object/ + ); + + const substitutedPackages = createValidManifest(); + substitutedPackages.packages = { + multimediaRuntime: { + version: '1.0.0', + sourceUrl: 'https://example.test/multimedia-runtime.tar.xz', + sourceSha256: 'c'.repeat(64), + license: 'LGPL-2.1-or-later', + }, + }; + const substitutedErrors = + validateLinuxRuntimeManifest(substitutedPackages).join('\n'); + for (const packageName of SOURCE_PACKAGES.map(({ id }) => id)) { + assert.match( + substitutedErrors, + new RegExp(`packages\\.${packageName} must be an object`) + ); + } + assert.match( + substitutedErrors, + /packages contains unexpected source package "multimediaRuntime"/ + ); +}); + +test('rejects GPL and nonfree FFmpeg configurations', () => { + const cases = [ + { + flags: ['--disable-nonfree'], + expected: /must include "--disable-gpl"/, + }, + { + flags: ['--disable-gpl'], + expected: /must include "--disable-nonfree"/, + }, + { + flags: ['--disable-gpl', '--disable-nonfree', '--enable-gpl'], + expected: /must not include "--enable-gpl"/, + }, + { + flags: ['--disable-gpl', '--disable-nonfree', '--enable-nonfree'], + expected: /must not include "--enable-nonfree"/, + }, + ]; + + for (const { flags, expected } of cases) { + const manifest = createValidManifest(); + manifest.ffmpeg.configureFlags = flags; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + expected + ); + } + + const manifest = createValidManifest(); + manifest.ffmpeg = { configureFlags: ['--enable-gpl'] }; + assert.match(validateLinuxRuntimeManifest(manifest)[0], /--enable-gpl/); +}); + +test('requires libmpv and GPL-disabled mpv Meson flags', () => { + const cases = [ + { + flags: ['-Dgpl=false'], + expected: /must include "-Dlibmpv=true"/, + }, + { + flags: ['-Dlibmpv=true'], + expected: /must include "-Dgpl=false"/, + }, + { + flags: ['-Dlibmpv=true', '-Dgpl=false', '-Dgpl=true'], + expected: /must not include "-Dgpl=true"/, + }, + ]; + + for (const { flags, expected } of cases) { + const manifest = createValidManifest(); + manifest.mpv.mesonFlags = flags; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + expected + ); + } + + const manifest = createValidManifest(); + manifest.mpv = { mesonFlags: ['-Dgpl=true'] }; + assert.match(validateLinuxRuntimeManifest(manifest)[0], /-Dgpl=false/); +}); + +test('rejects duplicate or contradictory required mpv Meson assignments', () => { + const cases = [ + { + flags: ['-Dlibmpv=true', '-Dlibmpv=false', '-Dgpl=false'], + expected: /must assign "-Dlibmpv" exactly once/, + }, + { + flags: ['-Dlibmpv=true', '-Dgpl=false', '-Dgpl=true'], + expected: /must assign "-Dgpl" exactly once/, + }, + { + flags: ['-Dlibmpv=true', '-Dlibmpv=true', '-Dgpl=false'], + expected: /must assign "-Dlibmpv" exactly once/, + }, + ]; + + for (const { expected, flags } of cases) { + const manifest = createValidManifest(); + manifest.mpv.mesonFlags = flags; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + expected + ); + } +}); + +test('rejects duplicate assignments for every mpv Meson option', () => { + const manifest = createValidManifest(); + manifest.mpv.mesonFlags.push('-Ddrm=enabled', '-Ddrm=enabled'); + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /mpv\.mesonFlags must assign "-Ddrm" exactly once/ + ); +}); + +test('validates safe, unique shared-library metadata', () => { + const manifest = createValidManifest([ + { + name: '../libmpv.so.2', + size: 0, + sha256: 'ABC', + }, + runtimeFile('libcodec.a', 'archive'), + runtimeFile('libcodec.a', 'duplicate'), + ]); + + const errors = validateLinuxRuntimeManifest(manifest); + assert.deepEqual(errors.slice(0, 7), [ + 'Linux runtime manifest runtimeFiles[0].name must be a safe shared-library basename.', + 'Linux runtime manifest runtimeFiles[0].size must be a positive integer.', + 'Linux runtime manifest runtimeFiles[0].sha256 must be a lowercase 64-character hexadecimal digest.', + 'Linux runtime manifest runtimeFiles[1].name must end in ".so" or a numeric ".so.N" suffix.', + 'Linux runtime manifest runtimeFiles[2].name must end in ".so" or a numeric ".so.N" suffix.', + 'Linux runtime manifest runtimeFiles contains duplicate name "libcodec.a".', + 'Linux runtime manifest runtimeFiles must include a versioned libmpv.so.N entry.', + ]); + assert.match( + errors.join('\n'), + /runtimeDependencyClosure\.entries\[0\]\.name must be a safe shared-library basename/ + ); + assert.match( + errors.join('\n'), + /runtimeDependencyClosure\.entries contains duplicate name "libcodec\.a"/ + ); +}); + +test('rejects control characters in shared-library basenames', () => { + const manifest = createValidManifest(); + manifest.runtimeFiles.push( + runtimeFile('libinjected.so.1\n', 'unsafe-name') + ); + + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /runtimeFiles\[3\]\.name must be a safe shared-library basename/ + ); +}); + +test('stages only declared Linux libraries and materializes source symlinks', (t) => { + const fixture = createFixture(t); + fs.renameSync( + path.join(fixture.libDir, 'libmpv.so.2'), + path.join(fixture.libDir, 'libmpv.so.2.1.0') + ); + fs.rmSync(path.join(fixture.libDir, 'libmpv.so')); + fs.symlinkSync('libmpv.so.2.1.0', path.join(fixture.libDir, 'libmpv.so.2')); + fs.symlinkSync('libmpv.so.2', path.join(fixture.libDir, 'libmpv.so')); + fs.writeFileSync(path.join(fixture.libDir, 'libundeclared.so.1'), 'extra'); + fs.writeFileSync( + path.join(fixture.prefix, 'runtime-manifest.json'), + `${JSON.stringify(fixture.manifest, null, 2)}\n` + ); + + const result = runStage(fixture); + assert.equal(result.status, 0, result.stderr); + + const destinationRoot = destinationRootFor(fixture); + const destinationLibDir = path.join(destinationRoot, 'lib'); + assert.deepEqual(fs.readdirSync(destinationLibDir).sort(), [ + 'libavcodec.so.61', + 'libmpv.so', + 'libmpv.so.2', + ]); + for (const runtimeEntry of fixture.manifest.runtimeFiles) { + const destinationPath = path.join(destinationLibDir, runtimeEntry.name); + const stat = fs.lstatSync(destinationPath); + assert.equal(stat.isFile(), true); + assert.equal(stat.isSymbolicLink(), false); + assert.equal(stat.size, runtimeEntry.size); + assert.equal( + sha256(fs.readFileSync(destinationPath)), + runtimeEntry.sha256 + ); + } + assert.equal( + fs.readFileSync( + path.join(destinationRoot, 'include', 'mpv', 'client.h'), + 'utf8' + ), + '/* libmpv header */\n' + ); + + const stagedManifest = JSON.parse( + fs.readFileSync( + path.join(destinationRoot, 'runtime-manifest.json'), + 'utf8' + ) + ); + assert.equal(stagedManifest.origin, 'vendored-lgpl'); + assert.equal( + stagedManifest.sourceBuildOrigin, + 'vendored-lgpl-source-build' + ); + assert.equal(stagedManifest.platform, 'linux'); + assert.equal(stagedManifest.arch, 'x64'); + assert.deepEqual( + stagedManifest.runtimeFiles, + fixture.manifest.runtimeFiles + ); + for (const field of [ + 'packages', + 'runtimeTotalBytes', + 'runtimeAbi', + 'runtimeExternalConfiguration', + 'runtimeDependencyClosure', + 'externalSystemLibraries', + 'buildHost', + ]) { + assert.deepEqual(stagedManifest[field], fixture.manifest[field], field); + } +}); + +test('copies runtime libraries only from the verified byte snapshot', () => { + assert.match( + stageRuntimeSource, + /function readVerifiedLinuxRuntimeFiles\(manifest\)/ + ); + assert.match( + stageRuntimeSource, + /contents = fs\.readFileSync\(sourcePath\)/ + ); + assert.match( + stageRuntimeSource, + /fs\.writeFileSync\(destinationPath, verifiedRuntimeFile\.contents\)/ + ); + assert.doesNotMatch( + stageRuntimeSource, + /function copyLinuxRuntimeFiles\(manifest\)/ + ); +}); + +test('atomically replaces the complete Linux destination tree', (t) => { + const fixture = createFixture(t); + const destinationRoot = destinationRootFor(fixture); + fs.mkdirSync(destinationRoot, { recursive: true }); + fs.writeFileSync(path.join(destinationRoot, 'stale-runtime.txt'), 'stale'); + + const result = runStage(fixture); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.existsSync(path.join(destinationRoot, 'stale-runtime.txt')), + false + ); + assert.deepEqual( + fs + .readdirSync(path.dirname(destinationRoot)) + .filter((name) => name.startsWith('.linux-x64.')), + [] + ); +}); + +test('rejects a libmpv header symlink that escapes the source prefix', (t) => { + const fixture = createFixture(t); + const outsideHeader = path.join(fixture.root, 'outside-client.h'); + const clientHeader = path.join( + fixture.prefix, + 'include', + 'mpv', + 'client.h' + ); + fs.writeFileSync(outsideHeader, '/* untrusted external header */\n'); + fs.rmSync(clientHeader); + fs.symlinkSync(outsideHeader, clientHeader); + + const result = runStage(fixture); + assert.notEqual(result.status, 0, result.stdout); + assert.match( + result.stderr, + /Linux header resolves outside prefix\/include/ + ); +}); + +test('rejects destination root and ancestor symlink redirection', (t) => { + for (const symlinkLocation of ['destination', 'ancestor']) { + const fixture = createFixture(t); + const destinationRoot = destinationRootFor(fixture); + const outsideRoot = path.join( + fixture.root, + `outside-${symlinkLocation}` + ); + fs.mkdirSync(outsideRoot, { recursive: true }); + fs.writeFileSync(path.join(outsideRoot, 'untouched.txt'), 'untouched'); + + if (symlinkLocation === 'destination') { + fs.mkdirSync(path.dirname(destinationRoot), { recursive: true }); + fs.symlinkSync(outsideRoot, destinationRoot); + } else { + const embeddedMpvRoot = path.dirname(destinationRoot); + fs.mkdirSync(path.dirname(embeddedMpvRoot), { recursive: true }); + fs.symlinkSync(outsideRoot, embeddedMpvRoot); + } + + const result = runStage(fixture); + assert.notEqual(result.status, 0, result.stdout); + assert.match( + result.stderr, + /Linux runtime destination path contains a symbolic link/ + ); + assert.equal( + fs.readFileSync(path.join(outsideRoot, 'untouched.txt'), 'utf8'), + 'untouched' + ); + } +}); + +test('rejects missing Linux headers or manifests', (t) => { + assertStageRejected(t, { removeHeader: true }, /Missing libmpv header/); + assertStageRejected( + t, + { omitManifest: true }, + /Missing Linux runtime manifest/ + ); +}); + +test('rejects unsafe or duplicate declared library names', (t) => { + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles[0].name = '../libmpv.so.2'; + }, + }, + /safe shared-library basename/ + ); + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles.push({ ...manifest.runtimeFiles[0] }); + }, + }, + /duplicate name "libmpv.so.2"/ + ); +}); + +test('rejects missing files and mismatched size or hash metadata', (t) => { + assertStageRejected( + t, + { removeRuntimeFile: 'libavcodec.so.61' }, + /Missing declared Linux runtime file.*libavcodec\.so\.61/ + ); + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles[0].size += 1; + manifest.runtimeTotalBytes += 1; + }, + }, + /Size mismatch for Linux runtime file.*libmpv\.so\.2/ + ); + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles[0].sha256 = '0'.repeat(64); + }, + }, + /SHA-256 mismatch for Linux runtime file.*libmpv\.so\.2/ + ); +}); + +test('rejects forbidden build flags and a missing versioned libmpv entry', (t) => { + const invalidConfigurations = [ + { + mutateManifest(manifest) { + manifest.ffmpeg.configureFlags.push('--enable-gpl'); + }, + expected: /must not include "--enable-gpl"/, + }, + { + mutateManifest(manifest) { + manifest.ffmpeg.configureFlags.push('--enable-nonfree'); + }, + expected: /must not include "--enable-nonfree"/, + }, + { + mutateManifest(manifest) { + manifest.mpv.mesonFlags = ['-Dlibmpv=true', '-Dgpl=true']; + }, + expected: /must include "-Dgpl=false"/, + }, + { + mutateManifest(manifest) { + manifest.mpv.mesonFlags = ['-Dgpl=false']; + }, + expected: /must include "-Dlibmpv=true"/, + }, + { + mutateManifest(manifest) { + manifest.mpv.mesonFlags = [ + '-Dlibmpv=true', + '-Dlibmpv=false', + '-Dgpl=false', + ]; + }, + expected: /must assign "-Dlibmpv" exactly once/, + }, + { + mutateManifest(manifest) { + manifest.runtimeFiles = manifest.runtimeFiles.filter( + ({ name }) => name !== 'libmpv.so.2' + ); + }, + expected: /must include a versioned libmpv\.so\.N entry/, + }, + ]; + + for (const { expected, mutateManifest } of invalidConfigurations) { + assertStageRejected(t, { mutateManifest }, expected); + } +}); diff --git a/tools/embedded-mpv/linux-source-archive-contract.cjs b/tools/embedded-mpv/linux-source-archive-contract.cjs new file mode 100644 index 000000000..e6e892bfd --- /dev/null +++ b/tools/embedded-mpv/linux-source-archive-contract.cjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const { isDeepStrictEqual } = require('node:util'); + +const SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION = 1; +const SOURCE_ARCHIVE_NAME = 'linux-frame-copy-runtime-sources.tar.xz'; +const SOURCE_ARCHIVE_BINDING_NAME = 'source-archive-binding.json'; +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; + +function validateLinuxSourceArchiveBinding( + binding, + { expectedRepositoryRevision, expectedSha256 } = {} +) { + const errors = []; + if ( + binding === null || + typeof binding !== 'object' || + Array.isArray(binding) + ) { + return ['Linux source archive binding must be an object.']; + } + if ( + !isDeepStrictEqual(Object.keys(binding).sort(), [ + 'name', + 'repositoryRevision', + 'schemaVersion', + 'sha256', + ]) + ) { + errors.push( + 'Linux source archive binding must contain only schemaVersion, name, sha256, and repositoryRevision.' + ); + } + if (binding.schemaVersion !== SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION) { + errors.push( + `Linux source archive binding schemaVersion must equal ${SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION}.` + ); + } + if (binding.name !== SOURCE_ARCHIVE_NAME) { + errors.push( + `Linux source archive binding name must equal ${SOURCE_ARCHIVE_NAME}.` + ); + } + if ( + typeof binding.sha256 !== 'string' || + !SHA256_PATTERN.test(binding.sha256) + ) { + errors.push( + 'Linux source archive binding sha256 must be a lowercase SHA-256 digest.' + ); + } + if ( + typeof binding.repositoryRevision !== 'string' || + !GIT_COMMIT_PATTERN.test(binding.repositoryRevision) + ) { + errors.push( + 'Linux source archive binding repositoryRevision must be a full Git commit.' + ); + } + if ( + expectedRepositoryRevision !== undefined && + binding.repositoryRevision !== expectedRepositoryRevision + ) { + errors.push( + 'Linux source archive binding repositoryRevision does not match the expected release commit.' + ); + } + if (expectedSha256 !== undefined && binding.sha256 !== expectedSha256) { + errors.push( + 'Linux source archive binding sha256 does not match the source archive bytes.' + ); + } + return errors; +} + +function sha256File(filePath) { + const descriptor = fs.openSync(filePath, 'r'); + const hash = crypto.createHash('sha256'); + const buffer = Buffer.alloc(1024 * 1024); + try { + let bytesRead; + do { + bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (bytesRead > 0) { + hash.update(buffer.subarray(0, bytesRead)); + } + } while (bytesRead > 0); + } finally { + fs.closeSync(descriptor); + } + return hash.digest('hex'); +} + +function createLinuxSourceArchiveBinding({ archivePath, repositoryRevision }) { + const stat = fs.lstatSync(archivePath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) { + throw new Error( + 'Linux source archive must be a non-empty regular file.' + ); + } + const binding = { + schemaVersion: SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + name: SOURCE_ARCHIVE_NAME, + sha256: sha256File(archivePath), + repositoryRevision, + }; + const errors = validateLinuxSourceArchiveBinding(binding); + if (errors.length > 0) { + throw new Error(errors.join('\n')); + } + return binding; +} + +function parseArguments(argv) { + const [command, ...tokens] = argv; + if (tokens.length % 2 !== 0) { + throw new Error('Linux source archive binding arguments are invalid.'); + } + const options = {}; + for (let index = 0; index < tokens.length; index += 2) { + const token = tokens[index]; + const value = tokens[index + 1]; + if ( + !token.startsWith('--') || + value === undefined || + Object.hasOwn(options, token.slice(2)) + ) { + throw new Error( + 'Linux source archive binding arguments are invalid.' + ); + } + options[token.slice(2)] = value; + } + return { command, options }; +} + +function main(argv = process.argv.slice(2)) { + const { command, options } = parseArguments(argv); + if ( + command !== 'create' || + !options.archive || + !options['repository-revision'] || + !options.output + ) { + throw new Error( + 'Usage: linux-source-archive-contract.cjs create --archive --repository-revision --output ' + ); + } + const binding = createLinuxSourceArchiveBinding({ + archivePath: options.archive, + repositoryRevision: options['repository-revision'], + }); + fs.writeFileSync(options.output, `${JSON.stringify(binding, null, 2)}\n`, { + mode: 0o644, + }); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} + +module.exports = { + SOURCE_ARCHIVE_BINDING_NAME, + SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + SOURCE_ARCHIVE_NAME, + createLinuxSourceArchiveBinding, + sha256File, + validateLinuxSourceArchiveBinding, +}; diff --git a/tools/embedded-mpv/linux-source-archive-contract.d.cts b/tools/embedded-mpv/linux-source-archive-contract.d.cts new file mode 100644 index 000000000..0ecb0bce8 --- /dev/null +++ b/tools/embedded-mpv/linux-source-archive-contract.d.cts @@ -0,0 +1,26 @@ +interface LinuxSourceArchiveBinding { + schemaVersion: 1; + name: 'linux-frame-copy-runtime-sources.tar.xz'; + sha256: string; + repositoryRevision: string; +} + +declare const LINUX_SOURCE_ARCHIVE_CONTRACT: { + readonly SOURCE_ARCHIVE_BINDING_NAME: 'source-archive-binding.json'; + readonly SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION: 1; + readonly SOURCE_ARCHIVE_NAME: 'linux-frame-copy-runtime-sources.tar.xz'; + createLinuxSourceArchiveBinding(options: { + archivePath: string; + repositoryRevision: string; + }): LinuxSourceArchiveBinding; + sha256File(filePath: string): string; + validateLinuxSourceArchiveBinding( + binding: unknown, + options?: { + expectedRepositoryRevision?: string; + expectedSha256?: string; + } + ): string[]; +}; + +export = LINUX_SOURCE_ARCHIVE_CONTRACT; diff --git a/tools/embedded-mpv/runtime-probe-contract.cjs b/tools/embedded-mpv/runtime-probe-contract.cjs new file mode 100644 index 000000000..28e8bde7d --- /dev/null +++ b/tools/embedded-mpv/runtime-probe-contract.cjs @@ -0,0 +1,6 @@ +const RUNTIME_PROBE_CONTRACT = Object.freeze({ + RUNTIME_PROBE_MAX_BUFFER_BYTES: 16 * 1024 * 1024, + RUNTIME_PROBE_TIMEOUT_MS: 3000, +}); + +module.exports = RUNTIME_PROBE_CONTRACT; diff --git a/tools/embedded-mpv/runtime-probe-contract.d.cts b/tools/embedded-mpv/runtime-probe-contract.d.cts new file mode 100644 index 000000000..545231e21 --- /dev/null +++ b/tools/embedded-mpv/runtime-probe-contract.d.cts @@ -0,0 +1,6 @@ +declare const RUNTIME_PROBE_CONTRACT: Readonly<{ + readonly RUNTIME_PROBE_MAX_BUFFER_BYTES: 16777216; + readonly RUNTIME_PROBE_TIMEOUT_MS: 3000; +}>; + +export = RUNTIME_PROBE_CONTRACT; diff --git a/tools/embedded-mpv/stage-runtime.mjs b/tools/embedded-mpv/stage-runtime.mjs index 81b06e3fa..1b758034c 100644 --- a/tools/embedded-mpv/stage-runtime.mjs +++ b/tools/embedded-mpv/stage-runtime.mjs @@ -1,5 +1,14 @@ +import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { + isLinuxSystemBuildInputManifest, + validateLinuxRuntimeManifest, + validateLinuxSystemBuildInputManifest, +} = require('./linux-runtime-manifest.cjs'); const rawArgs = process.argv.slice(2); const args = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; @@ -161,48 +170,191 @@ function readJsonIfExists(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } -try { - assertExists( - path.join(sourceIncludeDir, 'mpv', 'client.h'), - 'Missing libmpv header' - ); - if (platform !== 'linux' && !findRuntimeFile(sourceLibDir)) { - throw new Error( - `Missing libmpv runtime for ${platform} in ${sourceLibDir}` - ); +function readLinuxRuntimeManifest() { + const manifestPath = path.join(normalizedPrefix, 'runtime-manifest.json'); + if (!fs.existsSync(manifestPath)) { + throw new Error(`Missing Linux runtime manifest: ${manifestPath}`); } - if (platform === 'win32' && !hasWindowsImportLibrary(sourceLibDir)) { + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (error) { throw new Error( - `Missing Windows libmpv import library in ${sourceLibDir}` + `Invalid JSON in Linux runtime manifest ${manifestPath}: ${ + error instanceof Error ? error.message : String(error) + }` ); } - fs.rmSync(destinationIncludeDir, { recursive: true, force: true }); - fs.rmSync(destinationLibDir, { recursive: true, force: true }); - fs.mkdirSync(destinationRoot, { recursive: true }); + const mode = isLinuxSystemBuildInputManifest(manifest) + ? 'system-build-inputs' + : 'bundled-runtime'; + const errors = + mode === 'system-build-inputs' + ? validateLinuxSystemBuildInputManifest(manifest) + : validateLinuxRuntimeManifest(manifest); + if (errors.length > 0) { + throw new Error( + ['Invalid Linux runtime manifest.', ...errors].join('\n') + ); + } - copyDirectory( - path.join(sourceIncludeDir, 'mpv'), - path.join(destinationIncludeDir, 'mpv') + return { manifest, mode }; +} + +function sha256Contents(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +function resolvePathInsideDirectory(filePath, directory, message) { + const resolvedDirectory = fs.realpathSync(directory); + const resolvedFilePath = fs.realpathSync(filePath); + const relativePath = path.relative(resolvedDirectory, resolvedFilePath); + if ( + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + throw new Error(`${message}: ${filePath}`); + } + + return resolvedFilePath; +} + +function readVerifiedLinuxRuntimeFiles(manifest) { + return manifest.runtimeFiles.map((runtimeFile) => { + const declaredSourcePath = path.join(sourceLibDir, runtimeFile.name); + if (!fs.existsSync(declaredSourcePath)) { + throw new Error( + `Missing declared Linux runtime file: ${declaredSourcePath}` + ); + } + + const sourcePath = resolvePathInsideDirectory( + declaredSourcePath, + sourceLibDir, + 'Declared Linux runtime file resolves outside prefix/lib' + ); + const sourceStat = fs.statSync(sourcePath); + if (!sourceStat.isFile()) { + throw new Error( + `Declared Linux runtime path is not a regular file: ${declaredSourcePath}` + ); + } + + const contents = fs.readFileSync(sourcePath); + if (contents.byteLength !== runtimeFile.size) { + throw new Error( + `Size mismatch for Linux runtime file ${declaredSourcePath}: expected ${runtimeFile.size}, received ${contents.byteLength}` + ); + } + + const actualSha256 = sha256Contents(contents); + if (actualSha256 !== runtimeFile.sha256) { + throw new Error( + `SHA-256 mismatch for Linux runtime file ${declaredSourcePath}: expected ${runtimeFile.sha256}, received ${actualSha256}` + ); + } + + return { contents, runtimeFile }; + }); +} + +function readLinuxHeaderFiles() { + resolvePathInsideDirectory( + sourceIncludeDir, + normalizedPrefix, + 'Linux include directory resolves outside source prefix' ); - if (platform !== 'linux') { - copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter); - } - if (platform === 'win32') { - copyDirectory(sourceBinDir, destinationLibDir, runtimeFileFilter); + + const headerFiles = []; + function visitDirectory(sourceDirectory, relativeDirectory) { + for (const entry of fs.readdirSync(sourceDirectory, { + withFileTypes: true, + })) { + const sourcePath = path.join(sourceDirectory, entry.name); + const relativePath = path.join(relativeDirectory, entry.name); + if (entry.isDirectory()) { + visitDirectory(sourcePath, relativePath); + continue; + } + if (!entry.isFile() && !entry.isSymbolicLink()) { + continue; + } + + const resolvedSourcePath = resolvePathInsideDirectory( + sourcePath, + sourceIncludeDir, + 'Linux header resolves outside prefix/include' + ); + if (!fs.statSync(resolvedSourcePath).isFile()) { + throw new Error( + `Linux header is not a regular file: ${sourcePath}` + ); + } + headerFiles.push({ + contents: fs.readFileSync(resolvedSourcePath), + relativePath, + }); + } } - const externalManifest = - readJsonIfExists( - path.join(normalizedPrefix, 'runtime-manifest.json') - ) ?? {}; - const manifest = { + visitDirectory(path.join(sourceIncludeDir, 'mpv'), 'mpv'); + return headerFiles; +} + +function lstatIfExists(filePath) { + try { + return fs.lstatSync(filePath); + } catch (error) { + if (error?.code === 'ENOENT') { + return null; + } + throw error; + } +} + +function assertSafeLinuxDestinationPath() { + const relativeDestination = path.relative(workspaceRoot, destinationRoot); + if ( + relativeDestination === '..' || + relativeDestination.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeDestination) + ) { + throw new Error( + `Linux runtime destination escapes the workspace: ${destinationRoot}` + ); + } + + let currentPath = workspaceRoot; + for (const segment of relativeDestination.split(path.sep)) { + currentPath = path.join(currentPath, segment); + const stat = lstatIfExists(currentPath); + if (stat?.isSymbolicLink()) { + throw new Error( + `Linux runtime destination path contains a symbolic link: ${currentPath}` + ); + } + } +} + +function createLinuxStagedManifest(externalManifest, mode) { + return { ...externalManifest, origin: 'vendored-lgpl', + ...(mode === 'bundled-runtime' + ? { sourceBuildOrigin: externalManifest.origin } + : {}), platform, arch, stagedAt: new Date().toISOString(), - runtimeFiles: listRuntimeFiles(destinationLibDir), + runtimeFiles: + mode === 'bundled-runtime' + ? externalManifest.runtimeFiles.map((runtimeFile) => ({ + ...runtimeFile, + })) + : [], ffmpeg: { licensePolicy: 'LGPL, built without --enable-gpl and --enable-nonfree', @@ -219,15 +371,193 @@ try { externalManifest.mpv?.mesonFlags ?? 'Record the exact mpv Meson flags used to build this runtime.', }, - sourceDistribution: - externalManifest.sourceDistribution ?? - `Publish exact source archives and local patches with the ${platform}-${arch} binary release.`, + sourceDistribution: externalManifest.sourceDistribution, }; +} + +function writeLinuxStagingTree( + stagingRoot, + headerFiles, + verifiedRuntimeFiles, + stagedManifest +) { + for (const headerFile of headerFiles) { + const destinationPath = path.join( + stagingRoot, + 'include', + headerFile.relativePath + ); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.writeFileSync(destinationPath, headerFile.contents); + fs.chmodSync(destinationPath, 0o644); + } + + for (const verifiedRuntimeFile of verifiedRuntimeFiles) { + const destinationPath = path.join( + stagingRoot, + 'lib', + verifiedRuntimeFile.runtimeFile.name + ); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.writeFileSync(destinationPath, verifiedRuntimeFile.contents); + fs.chmodSync(destinationPath, 0o755); + } fs.writeFileSync( - path.join(destinationRoot, 'runtime-manifest.json'), - `${JSON.stringify(manifest, null, 2)}\n` + path.join(stagingRoot, 'runtime-manifest.json'), + `${JSON.stringify(stagedManifest, null, 2)}\n` ); +} + +function publishLinuxStagingTree(stagingRoot) { + const token = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`; + const backupRoot = path.join( + path.dirname(destinationRoot), + `.linux-x64.backup-${token}` + ); + let movedPreviousDestination = false; + + try { + assertSafeLinuxDestinationPath(); + const destinationStat = lstatIfExists(destinationRoot); + if (destinationStat && !destinationStat.isDirectory()) { + throw new Error( + `Linux runtime destination is not a directory: ${destinationRoot}` + ); + } + if (destinationStat) { + fs.renameSync(destinationRoot, backupRoot); + movedPreviousDestination = true; + } + + fs.renameSync(stagingRoot, destinationRoot); + if (movedPreviousDestination) { + fs.rmSync(backupRoot, { recursive: true, force: true }); + } + } catch (error) { + if ( + movedPreviousDestination && + !lstatIfExists(destinationRoot) && + lstatIfExists(backupRoot) + ) { + fs.renameSync(backupRoot, destinationRoot); + } + throw error; + } finally { + fs.rmSync(stagingRoot, { recursive: true, force: true }); + if (lstatIfExists(destinationRoot)) { + fs.rmSync(backupRoot, { recursive: true, force: true }); + } + } +} + +function stageLinuxRuntime(headerFiles, verifiedRuntimeFiles, stagedManifest) { + assertSafeLinuxDestinationPath(); + const destinationParent = path.dirname(destinationRoot); + fs.mkdirSync(destinationParent, { recursive: true }); + assertSafeLinuxDestinationPath(); + + const stagingRoot = path.join( + destinationParent, + `.linux-x64.stage-${process.pid}-${crypto + .randomBytes(8) + .toString('hex')}` + ); + fs.mkdirSync(stagingRoot); + try { + writeLinuxStagingTree( + stagingRoot, + headerFiles, + verifiedRuntimeFiles, + stagedManifest + ); + publishLinuxStagingTree(stagingRoot); + } catch (error) { + fs.rmSync(stagingRoot, { recursive: true, force: true }); + throw error; + } +} + +try { + assertExists( + path.join(sourceIncludeDir, 'mpv', 'client.h'), + 'Missing libmpv header' + ); + + if (platform === 'linux') { + const { manifest: externalManifest, mode } = readLinuxRuntimeManifest(); + const headerFiles = readLinuxHeaderFiles(); + const verifiedRuntimeFiles = + mode === 'bundled-runtime' + ? readVerifiedLinuxRuntimeFiles(externalManifest) + : []; + stageLinuxRuntime( + headerFiles, + verifiedRuntimeFiles, + createLinuxStagedManifest(externalManifest, mode) + ); + } else { + const externalManifest = + readJsonIfExists( + path.join(normalizedPrefix, 'runtime-manifest.json') + ) ?? {}; + if (!findRuntimeFile(sourceLibDir)) { + throw new Error( + `Missing libmpv runtime for ${platform} in ${sourceLibDir}` + ); + } + if (platform === 'win32' && !hasWindowsImportLibrary(sourceLibDir)) { + throw new Error( + `Missing Windows libmpv import library in ${sourceLibDir}` + ); + } + + fs.rmSync(destinationIncludeDir, { recursive: true, force: true }); + fs.rmSync(destinationLibDir, { recursive: true, force: true }); + fs.mkdirSync(destinationRoot, { recursive: true }); + + copyDirectory( + path.join(sourceIncludeDir, 'mpv'), + path.join(destinationIncludeDir, 'mpv') + ); + copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter); + if (platform === 'win32') { + copyDirectory(sourceBinDir, destinationLibDir, runtimeFileFilter); + } + + const manifest = { + ...externalManifest, + origin: 'vendored-lgpl', + platform, + arch, + stagedAt: new Date().toISOString(), + runtimeFiles: listRuntimeFiles(destinationLibDir), + ffmpeg: { + licensePolicy: + 'LGPL, built without --enable-gpl and --enable-nonfree', + ...externalManifest.ffmpeg, + configureFlags: + externalManifest.ffmpeg?.configureFlags ?? + 'Record the exact FFmpeg configure flags used to build this runtime.', + }, + mpv: { + licensePolicy: + 'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false', + ...externalManifest.mpv, + mesonFlags: + externalManifest.mpv?.mesonFlags ?? + 'Record the exact mpv Meson flags used to build this runtime.', + }, + sourceDistribution: + externalManifest.sourceDistribution ?? + `Publish exact source archives and local patches with the ${platform}-${arch} binary release.`, + }; + + fs.writeFileSync( + path.join(destinationRoot, 'runtime-manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + } console.log( `Staged embedded MPV runtime for ${platform}-${arch} at ${path.relative( diff --git a/tools/packaging/asar-dependency-closure.mjs b/tools/packaging/asar-dependency-closure.mjs index 7f7768a0f..6fd4d775c 100644 --- a/tools/packaging/asar-dependency-closure.mjs +++ b/tools/packaging/asar-dependency-closure.mjs @@ -15,10 +15,226 @@ * so the core logic stays pure and unit-testable without a real archive. */ +import fs from 'node:fs'; import path from 'node:path'; +import { TextDecoder } from 'node:util'; const PACKAGE_MANIFEST = 'package.json'; const NODE_MODULES_SEGMENT = '/node_modules/'; +const EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT = '/electron-backend/native'; +const ASAR_SIZE_PREFIX_BYTES = 8; +const ASAR_HEADER_MAX_BYTES = 32 * 1024 * 1024; +const ASAR_ENTRY_LIMIT = 250_000; +const ASAR_PATH_MAX_BYTES = 32 * 1024; +const ASAR_LISTED_PATH_MAX_BYTES = 32 * 1024 * 1024; + +function isRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function* ownRecordEntries(record) { + for (const name in record) { + if (Object.hasOwn(record, name)) { + yield [name, record[name]]; + } + } +} + +function readExactly(descriptor, buffer, position) { + let offset = 0; + while (offset < buffer.length) { + const bytesRead = fs.readSync( + descriptor, + buffer, + offset, + buffer.length - offset, + position + offset + ); + if (bytesRead === 0) { + throw new Error('ASAR archive ended before its header was read.'); + } + offset += bytesRead; + } +} + +/** + * Lists an ASAR from its bounded Chromium-Pickle JSON header using only Node + * built-ins. Public-release verification runs from a clean tag checkout, so it + * cannot rely on the workspace-only `@electron/asar` development dependency. + */ +export function listAsarPackageEntries( + archivePath, + { maxListedPathBytes = ASAR_LISTED_PATH_MAX_BYTES } = {} +) { + if ( + !Number.isSafeInteger(maxListedPathBytes) || + maxListedPathBytes <= 0 || + maxListedPathBytes > ASAR_LISTED_PATH_MAX_BYTES + ) { + throw new Error('ASAR listed-path byte limit is invalid.'); + } + const noFollow = fs.constants.O_NOFOLLOW ?? 0; + const descriptor = fs.openSync( + archivePath, + fs.constants.O_RDONLY | noFollow + ); + let header; + try { + const archiveStat = fs.fstatSync(descriptor); + if (!archiveStat.isFile()) { + throw new Error('ASAR archive must be a regular file.'); + } + const sizePrefix = Buffer.alloc(ASAR_SIZE_PREFIX_BYTES); + readExactly(descriptor, sizePrefix, 0); + if (sizePrefix.readUInt32LE(0) !== 4) { + throw new Error('ASAR size pickle is malformed.'); + } + const headerSize = sizePrefix.readUInt32LE(4); + if (headerSize > ASAR_HEADER_MAX_BYTES) { + throw new Error( + `ASAR header exceeds the ${String( + ASAR_HEADER_MAX_BYTES + )}-byte limit.` + ); + } + if ( + headerSize < 8 || + headerSize + ASAR_SIZE_PREFIX_BYTES > archiveStat.size + ) { + throw new Error('ASAR header size is invalid.'); + } + header = Buffer.alloc(headerSize); + readExactly(descriptor, header, ASAR_SIZE_PREFIX_BYTES); + } finally { + fs.closeSync(descriptor); + } + + const payloadSize = header.readUInt32LE(0); + if (payloadSize + 4 !== header.length || payloadSize < 4) { + throw new Error('ASAR header pickle is malformed.'); + } + const jsonLength = header.readInt32LE(4); + const alignedJsonLength = + jsonLength >= 0 ? Math.ceil(jsonLength / 4) * 4 : -1; + if ( + jsonLength <= 0 || + alignedJsonLength + 4 !== payloadSize || + header + .subarray(8 + jsonLength, 8 + alignedJsonLength) + .some((byte) => byte !== 0) + ) { + throw new Error('ASAR header JSON framing is malformed.'); + } + + let parsedHeader; + try { + const headerJson = new TextDecoder('utf-8', { fatal: true }).decode( + header.subarray(8, 8 + jsonLength) + ); + parsedHeader = JSON.parse(headerJson); + } catch (error) { + throw new Error( + `ASAR header JSON is invalid: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if (!isRecord(parsedHeader) || !isRecord(parsedHeader.files)) { + throw new Error('ASAR header must contain a files mapping.'); + } + + const entries = []; + let listedPathBytes = 0; + const pendingDirectories = [ + { + entries: ownRecordEntries(parsedHeader.files), + parentPath: '', + }, + ]; + while (pendingDirectories.length > 0) { + const directory = pendingDirectories.at(-1); + const nextEntry = directory.entries.next(); + if (nextEntry.done) { + pendingDirectories.pop(); + continue; + } + const [name, node] = nextEntry.value; + const { parentPath } = directory; + if ( + !name || + name === '.' || + name === '..' || + name.includes('/') || + name.includes('\0') + ) { + throw new Error('ASAR header contains an invalid path segment.'); + } + if (!isRecord(node)) { + throw new Error('ASAR header contains an invalid filesystem node.'); + } + const entryPath = `${parentPath}/${name}`; + const entryPathBytes = Buffer.byteLength(entryPath, 'utf8'); + if (entryPathBytes > ASAR_PATH_MAX_BYTES) { + throw new Error('ASAR header contains an overlong entry path.'); + } + if (entryPathBytes > maxListedPathBytes - listedPathBytes) { + throw new Error( + `Cumulative ASAR entry paths exceed the ${String( + maxListedPathBytes + )}-byte limit.` + ); + } + listedPathBytes += entryPathBytes; + entries.push(entryPath); + if (entries.length > ASAR_ENTRY_LIMIT) { + throw new Error( + `ASAR header exceeds the ${String(ASAR_ENTRY_LIMIT)}-entry limit.` + ); + } + + if (Object.hasOwn(node, 'files')) { + if (!isRecord(node.files)) { + throw new Error( + 'ASAR header contains an invalid directory mapping.' + ); + } + pendingDirectories.push({ + entries: ownRecordEntries(node.files), + parentPath: entryPath, + }); + } else if ( + !( + (Number.isSafeInteger(node.size) && node.size >= 0) || + (typeof node.link === 'string' && node.link.length > 0) + ) + ) { + throw new Error('ASAR header contains an invalid file node.'); + } + } + return entries; +} + +/** + * Embedded MPV's native payload is profile-specific and is written only by + * afterPack. Any copy retained in app.asar predates that mutation and can leak + * x64 helpers, runtimes, manifests, or notices into marker-only/system builds. + */ +export function collectEmbeddedMpvNativeArchiveEntries( + asarEntries, + pathSep = path.sep +) { + const toPosix = (value) => + pathSep === '\\' ? value.replaceAll('\\', '/') : value; + + return asarEntries + .map(toPosix) + .map((entry) => (entry.startsWith('/') ? entry : `/${entry}`)) + .filter( + (entry) => + entry === EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT || + entry.startsWith(`${EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT}/`) + ); +} /** * A genuine installed package lives directly under a node_modules directory as @@ -70,7 +286,11 @@ export function collectAsarPackageDirs(asarEntries) { * as `/electron-backend/node_modules/foo`. Returns the resolved package * directory or null when the dependency is absent. */ -export function resolvePackagedDependency(fromDir, dependencyName, packageDirs) { +export function resolvePackagedDependency( + fromDir, + dependencyName, + packageDirs +) { let current = fromDir; while (true) { diff --git a/tools/packaging/asar-dependency-closure.test.mjs b/tools/packaging/asar-dependency-closure.test.mjs index 0c5fc81fe..d1737371a 100644 --- a/tools/packaging/asar-dependency-closure.test.mjs +++ b/tools/packaging/asar-dependency-closure.test.mjs @@ -1,13 +1,40 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import { + collectEmbeddedMpvNativeArchiveEntries, collectAsarPackageDirs, findMissingPackagedDependencies, inspectPackagedDependencyClosure, + listAsarPackageEntries, resolvePackagedDependency, } from './asar-dependency-closure.mjs'; +const require = createRequire(import.meta.url); +const { + createPackage: createAsarPackage, + listPackage: listAsarPackageWithDependency, +} = require('@electron/asar'); + +function writeSyntheticAsarHeader(archivePath, files) { + const json = Buffer.from(JSON.stringify({ files }), 'utf8'); + const alignedJsonLength = Math.ceil(json.length / 4) * 4; + const headerPayloadSize = 4 + alignedJsonLength; + const headerSize = 4 + headerPayloadSize; + const sizePrefix = Buffer.alloc(8); + sizePrefix.writeUInt32LE(4, 0); + sizePrefix.writeUInt32LE(headerSize, 4); + const header = Buffer.alloc(headerSize); + header.writeUInt32LE(headerPayloadSize, 0); + header.writeInt32LE(json.length, 4); + json.copy(header, 8); + fs.writeFileSync(archivePath, Buffer.concat([sizePrefix, header])); +} + /** * Builds a `readManifest(dir)` backed by an in-memory map of * `{ packageDir: manifest }`, mirroring how manifests are read from an asar. @@ -16,6 +43,202 @@ function manifestReader(manifests) { return (packageDir) => manifests[packageDir] ?? null; } +test('collectEmbeddedMpvNativeArchiveEntries finds stale native payloads on every host separator', () => { + assert.deepEqual( + collectEmbeddedMpvNativeArchiveEntries( + [ + '/electron-backend/main.js', + '/electron-backend/native/iptvnator_mpv_helper', + '/electron-backend/native/lib/libmpv.so.2', + '/web/index.html', + ], + '/' + ), + [ + '/electron-backend/native/iptvnator_mpv_helper', + '/electron-backend/native/lib/libmpv.so.2', + ] + ); + assert.deepEqual( + collectEmbeddedMpvNativeArchiveEntries( + [ + '\\electron-backend\\native\\embedded-mpv-unavailable.txt', + '\\electron-backend\\node_modules\\package.json', + ], + '\\' + ), + ['/electron-backend/native/embedded-mpv-unavailable.txt'] + ); +}); + +test('listAsarPackageEntries matches Electron ASAR listings from a bounded header', async (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const sourceRoot = path.join(temporaryRoot, 'source'); + const archivePath = path.join(temporaryRoot, 'app.asar'); + fs.mkdirSync(path.join(sourceRoot, 'electron-backend', 'native'), { + recursive: true, + }); + fs.writeFileSync(path.join(sourceRoot, 'main.js'), 'main'); + fs.writeFileSync( + path.join( + sourceRoot, + 'electron-backend', + 'native', + 'embedded-mpv-runtime.json' + ), + '{}\n' + ); + await createAsarPackage(sourceRoot, archivePath); + + assert.deepEqual( + listAsarPackageEntries(archivePath), + listAsarPackageWithDependency(archivePath) + ); +}); + +test('listAsarPackageEntries rejects an unbounded declared header before allocation', (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const archivePath = path.join(temporaryRoot, 'oversized.asar'); + const prefix = Buffer.alloc(8); + prefix.writeUInt32LE(4, 0); + prefix.writeUInt32LE(0xffffffff, 4); + fs.writeFileSync(archivePath, prefix); + + assert.throws( + () => listAsarPackageEntries(archivePath), + /ASAR header exceeds.*limit/i + ); +}); + +test('listAsarPackageEntries traverses untrusted directory mappings without bulk Object.entries allocation', (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const archivePath = path.join(temporaryRoot, 'wide.asar'); + const files = Object.fromEntries( + Array.from({ length: 4096 }, (_, index) => [ + `entry-${String(index).padStart(4, '0')}`, + { size: 0, offset: '0' }, + ]) + ); + writeSyntheticAsarHeader(archivePath, files); + + const originalObjectEntries = Object.entries; + Object.entries = () => { + throw new Error( + 'untrusted ASAR mappings must not be materialized in bulk' + ); + }; + try { + assert.equal(listAsarPackageEntries(archivePath).length, 4096); + } finally { + Object.entries = originalObjectEntries; + } +}); + +test('listAsarPackageEntries bounds cumulative paths under a wide long prefix', (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const archivePath = path.join(temporaryRoot, 'wide-prefix.asar'); + const children = Object.fromEntries( + Array.from({ length: 64 }, (_, index) => [ + `leaf-${String(index).padStart(2, '0')}`, + { size: 0, offset: '0' }, + ]) + ); + writeSyntheticAsarHeader(archivePath, { + ['prefix-'.repeat(32)]: { files: children }, + }); + + assert.throws( + () => + listAsarPackageEntries(archivePath, { + maxListedPathBytes: 1024, + }), + /cumulative ASAR entry paths exceed.*limit/i + ); +}); + +test('listAsarPackageEntries fails closed on malformed pickle, padding, and UTF-8', (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const validArchivePath = path.join(temporaryRoot, 'valid.asar'); + writeSyntheticAsarHeader(validArchivePath, { + a: { size: 0, offset: '0' }, + }); + const validArchive = fs.readFileSync(validArchivePath); + + const malformedPickle = Buffer.from(validArchive); + malformedPickle.writeUInt32LE(malformedPickle.readUInt32LE(8) - 4, 8); + const malformedPicklePath = path.join( + temporaryRoot, + 'malformed-pickle.asar' + ); + fs.writeFileSync(malformedPicklePath, malformedPickle); + assert.throws( + () => listAsarPackageEntries(malformedPicklePath), + /header pickle is malformed/i + ); + + const malformedPadding = Buffer.from(validArchive); + malformedPadding[malformedPadding.length - 1] = 0xff; + const malformedPaddingPath = path.join( + temporaryRoot, + 'malformed-padding.asar' + ); + fs.writeFileSync(malformedPaddingPath, malformedPadding); + assert.throws( + () => listAsarPackageEntries(malformedPaddingPath), + /JSON framing is malformed/i + ); + + const malformedUtf8 = Buffer.from(validArchive); + malformedUtf8[16] = 0xff; + const malformedUtf8Path = path.join(temporaryRoot, 'malformed-utf8.asar'); + fs.writeFileSync(malformedUtf8Path, malformedUtf8); + assert.throws( + () => listAsarPackageEntries(malformedUtf8Path), + /header JSON is invalid/i + ); +}); + +test('listAsarPackageEntries rejects unsafe archive path segments', (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + + for (const [index, unsafeSegment] of [ + '.', + '..', + 'nested/name', + 'nul\0name', + ].entries()) { + const archivePath = path.join( + temporaryRoot, + `unsafe-${String(index)}.asar` + ); + writeSyntheticAsarHeader(archivePath, { + [unsafeSegment]: { size: 0, offset: '0' }, + }); + assert.throws( + () => listAsarPackageEntries(archivePath), + /invalid path segment/i + ); + } +}); + test('collectAsarPackageDirs keeps only genuine package roots', () => { const dirs = collectAsarPackageDirs([ '/package.json', diff --git a/tools/packaging/configure-linux-frame-copy-build.mjs b/tools/packaging/configure-linux-frame-copy-build.mjs new file mode 100644 index 000000000..6cd570418 --- /dev/null +++ b/tools/packaging/configure-linux-frame-copy-build.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, +} = require('./linux-frame-copy-profile.cjs'); +const scriptPath = fileURLToPath(import.meta.url); + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +function targetName(target) { + const value = + typeof target === 'string' + ? target + : target && typeof target === 'object' + ? target.target + : null; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error( + 'Electron Builder Linux targets must have a non-empty target name.' + ); + } + return value.trim().toLowerCase(); +} + +function targetObject(target) { + return typeof target === 'string' ? { target } : { ...target }; +} + +function fpmDependencyName(option) { + return String(option).match( + /^--depends(?:=|\s+)([A-Za-z0-9+_.-]+)(?:$|\s|[<>=])/ + )?.[1]; +} + +function configureSystemDependencies(config) { + for (const [format, dependencies] of Object.entries( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )) { + const frameCopyDependencies = new Set(dependencies); + const formatConfig = { ...(config[format] ?? {}) }; + const otherFpmOptions = (formatConfig.fpm ?? []).filter( + (option) => !frameCopyDependencies.has(fpmDependencyName(option)) + ); + formatConfig.fpm = [ + ...otherFpmOptions, + ...dependencies.map((dependency) => `--depends=${dependency}`), + ]; + config[format] = formatConfig; + } +} + +function removeForeignFrameCopyDependency(config, format) { + const dependencies = new Set( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format] ?? [] + ); + const formatConfig = { ...(config[format] ?? {}) }; + const retainedFpmOptions = (formatConfig.fpm ?? []).filter( + (option) => !dependencies.has(fpmDependencyName(option)) + ); + if (retainedFpmOptions.length > 0) { + formatConfig.fpm = retainedFpmOptions; + } else { + delete formatConfig.fpm; + } + config[format] = formatConfig; +} + +export function configureLinuxFrameCopyBuild( + electronBuilderConfig, + { profileName, foreignDeb = false, foreignArch } = {} +) { + const hasForeignArch = foreignArch !== undefined; + if ( + !electronBuilderConfig || + typeof electronBuilderConfig !== 'object' || + Array.isArray(electronBuilderConfig) + ) { + throw new TypeError( + 'Electron Builder configuration must be an object.' + ); + } + if (foreignDeb && profileName) { + throw new Error( + 'The marker-only foreign DEB pass must not select a frame-copy profile.' + ); + } + if (hasForeignArch && !foreignDeb) { + throw new Error( + 'A marker-only foreign architecture requires --foreign-deb.' + ); + } + const configured = cloneJson(electronBuilderConfig); + const targets = configured.linux?.target; + if (!Array.isArray(targets)) { + throw new Error('Electron Builder linux.target must be an array.'); + } + + if (foreignDeb) { + const debTarget = targets.find( + (target) => targetName(target) === 'deb' + ); + if (!debTarget) { + throw new Error( + 'Electron Builder has no DEB target for the foreign-architecture pass.' + ); + } + const configuredDebTarget = targetObject(debTarget); + const configuredArches = Array.isArray(configuredDebTarget.arch) + ? configuredDebTarget.arch + : [configuredDebTarget.arch].filter(Boolean); + const foreignArches = configuredArches.filter( + (architecture) => architecture !== 'x64' + ); + if (foreignArches.length === 0) { + throw new Error( + 'Electron Builder DEB target has no foreign architectures.' + ); + } + if ( + hasForeignArch && + (typeof foreignArch !== 'string' || + !foreignArches.includes(foreignArch)) + ) { + throw new Error( + `Unsupported marker-only foreign DEB architecture "${foreignArch}". Expected one of: ${foreignArches.join( + ', ' + )}.` + ); + } + configuredDebTarget.arch = hasForeignArch + ? [foreignArch] + : foreignArches; + configured.linux.target = [configuredDebTarget]; + configured.directories = { + ...(configured.directories ?? {}), + output: 'dist/executables-linux-foreign', + }; + removeForeignFrameCopyDependency(configured, 'deb'); + return configured; + } + + const profile = resolveLinuxFrameCopyProfile(profileName); + const allowedTargets = new Set(profile.targets); + const targetsByName = new Map( + profile.targets.map((profileTarget) => [profileTarget, []]) + ); + for (const target of targets) { + const name = targetName(target); + if (allowedTargets.has(name)) { + targetsByName.get(name).push(target); + } + } + const duplicateTargets = profile.targets.filter( + (profileTarget) => targetsByName.get(profileTarget).length > 1 + ); + const missingTargets = profile.targets.filter( + (profileTarget) => targetsByName.get(profileTarget).length === 0 + ); + if (duplicateTargets.length > 0 || missingTargets.length > 0) { + throw new Error( + [ + `Invalid Electron Builder targets for Linux profile "${profile.name}".`, + ...(duplicateTargets.length > 0 + ? [ + `Found duplicate target "${duplicateTargets.join( + '", "' + )}".`, + ] + : []), + ...(missingTargets.length > 0 + ? [`Missing targets: ${missingTargets.join(', ')}.`] + : []), + ].join(' ') + ); + } + configured.linux.target = profile.targets.map((profileTarget) => { + const target = targetsByName.get(profileTarget)[0]; + const configuredTarget = targetObject(target); + if (profile.name === 'system') { + configuredTarget.arch = ['x64']; + } + return configuredTarget; + }); + if (profile.name === 'system') { + configureSystemDependencies(configured); + } + return configured; +} + +function parseArguments(argv) { + const normalized = argv[0] === '--' ? argv.slice(1) : argv; + let configPath = 'electron-builder.json'; + let profileName; + let foreignDeb = false; + let foreignArch; + const nextValue = (flag, index) => { + const value = normalized[index + 1]; + if ( + typeof value !== 'string' || + value.trim() === '' || + value.startsWith('--') + ) { + throw new Error(`${flag} requires a value.`); + } + return value; + }; + for (let index = 0; index < normalized.length; index += 1) { + const argument = normalized[index]; + if (argument === '--config') { + configPath = nextValue(argument, index); + index += 1; + } else if (argument === '--profile') { + profileName = nextValue(argument, index); + index += 1; + } else if (argument === '--foreign-deb') { + foreignDeb = true; + } else if (argument === '--foreign-arch') { + foreignArch = nextValue(argument, index); + index += 1; + } else { + throw new Error(`Unsupported configurator argument: ${argument}`); + } + } + if (!foreignDeb && !profileName) { + throw new Error('--profile or --foreign-deb is required.'); + } + return { + configPath: path.resolve(configPath), + profileName, + foreignDeb, + foreignArch, + }; +} + +function main() { + const options = parseArguments(process.argv.slice(2)); + const config = JSON.parse(fs.readFileSync(options.configPath, 'utf8')); + const configured = configureLinuxFrameCopyBuild(config, options); + fs.writeFileSync( + options.configPath, + `${JSON.stringify(configured, null, 4)}\n` + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs new file mode 100644 index 000000000..4f3ec6b81 --- /dev/null +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -0,0 +1,866 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { parse as parseYaml } from 'yaml'; + +import { configureLinuxFrameCopyBuild } from './configure-linux-frame-copy-build.mjs'; + +const workspaceRoot = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + '..', + '..' +); +const electronBuilderConfig = JSON.parse( + fs.readFileSync(path.join(workspaceRoot, 'electron-builder.json'), 'utf8') +); +const buildWorkflow = fs.readFileSync( + path.join(workspaceRoot, '.github', 'workflows', 'build-and-make.yaml'), + 'utf8' +); +const buildWorkflowConfig = parseYaml(buildWorkflow); + +function workflowStep(name) { + const marker = ` - name: ${name}\n`; + const start = buildWorkflow.indexOf(marker); + assert.notEqual(start, -1, `Missing workflow step: ${name}`); + const nextStep = buildWorkflow.indexOf('\n - name:', start + 1); + return buildWorkflow.slice( + start, + nextStep === -1 ? buildWorkflow.length : nextStep + ); +} + +function configuredTargets(config) { + return config.linux.target.map(({ target, arch }) => ({ + target: target.toLowerCase(), + arch, + })); +} + +test('configures an x64-only system pass with exact package dependencies', () => { + const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'system', + }); + + assert.deepEqual(configuredTargets(configured), [ + { target: 'deb', arch: ['x64'] }, + { target: 'rpm', arch: ['x64'] }, + { target: 'pacman', arch: ['x64'] }, + ]); + assert.deepEqual(configured.deb.fpm, [ + '--depends=libmpv2', + '--depends=libegl1', + '--depends=libgl1', + '--depends=libgbm1', + ]); + assert.deepEqual(configured.rpm.fpm, [ + '--depends=mpv-libs', + '--depends=libglvnd-egl', + '--depends=libglvnd-glx', + '--depends=mesa-libgbm', + ]); + assert.deepEqual(configured.pacman.fpm, [ + '--depends=mpv', + '--depends=libglvnd', + '--depends=mesa', + ]); +}); + +test('configures portable and flatpak passes without mixing targets', () => { + const portable = configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'portable', + }); + assert.deepEqual(configuredTargets(portable), [ + { target: 'appimage', arch: ['x64', 'armv7l', 'arm64'] }, + { target: 'snap', arch: ['x64', 'armv7l'] }, + ]); + + const flatpak = configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'flatpak', + }); + assert.deepEqual(configuredTargets(flatpak), [ + { target: 'flatpak', arch: ['x64'] }, + ]); + assert.equal(portable.snap.base, 'core22'); + assert.equal(portable.snap.confinement, 'strict'); + assert.deepEqual(portable.snap.layout, { + '/usr/share/libdrm': { + bind: '$SNAP/graphics/libdrm', + }, + '/usr/share/drirc.d': { + symlink: '$SNAP/graphics/drirc.d', + }, + }); + assert.deepEqual(portable.snap.plugs, [ + 'default', + { + 'graphics-core22': { + interface: 'content', + target: '$SNAP/graphics', + 'default-provider': 'mesa-core22', + }, + }, + { + 'shared-memory': { + interface: 'shared-memory', + private: true, + }, + }, + ]); +}); + +test('configures a separate marker-only foreign DEB pass without libmpv metadata', () => { + const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + }); + + assert.deepEqual(configuredTargets(configured), [ + { target: 'deb', arch: ['armv7l', 'arm64'] }, + ]); + assert.equal( + configured.deb.fpm?.some((entry) => + entry.includes('--depends=libmpv2') + ) ?? false, + false + ); + assert.equal( + configured.directories.output, + 'dist/executables-linux-foreign' + ); +}); + +test('configures exactly one requested marker-only foreign DEB architecture', () => { + for (const foreignArch of ['armv7l', 'arm64']) { + const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + foreignArch, + }); + + assert.deepEqual(configuredTargets(configured), [ + { target: 'deb', arch: [foreignArch] }, + ]); + } +}); + +test('CLI writes an exact marker-only foreign DEB architecture', (t) => { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-build-config-') + ); + t.after(() => + fs.rmSync(temporaryDirectory, { recursive: true, force: true }) + ); + const configPath = path.join(temporaryDirectory, 'electron-builder.json'); + fs.writeFileSync( + configPath, + `${JSON.stringify(electronBuilderConfig, null, 4)}\n` + ); + + const result = spawnSync( + process.execPath, + [ + path.join( + workspaceRoot, + 'tools', + 'packaging', + 'configure-linux-frame-copy-build.mjs' + ), + '--config', + configPath, + '--foreign-deb', + '--foreign-arch', + 'arm64', + ], + { encoding: 'utf8' } + ); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + configuredTargets(JSON.parse(fs.readFileSync(configPath, 'utf8'))), + [{ target: 'deb', arch: ['arm64'] }] + ); + + const missingValue = spawnSync( + process.execPath, + [ + path.join( + workspaceRoot, + 'tools', + 'packaging', + 'configure-linux-frame-copy-build.mjs' + ), + '--config', + configPath, + '--foreign-deb', + '--foreign-arch', + ], + { encoding: 'utf8' } + ); + assert.notEqual(missingValue.status, 0); + assert.match(missingValue.stderr, /--foreign-arch requires a value/); +}); + +test('does not mutate the shared electron-builder configuration', () => { + const before = JSON.stringify(electronBuilderConfig); + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'system', + }); + assert.equal(JSON.stringify(electronBuilderConfig), before); +}); + +test('rejects an unknown profile and conflicting foreign/profile modes', () => { + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'standard', + }), + /Unsupported Linux frame-copy profile/ + ); + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'system', + foreignDeb: true, + }), + /must not select a frame-copy profile/ + ); + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignArch: 'arm64', + }), + /foreign architecture requires --foreign-deb/ + ); + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + foreignArch: 'x64', + }), + /Unsupported marker-only foreign DEB architecture/ + ); + for (const foreignArch of ['', 64]) { + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + foreignArch, + }), + /Unsupported marker-only foreign DEB architecture/ + ); + } +}); + +test('rejects duplicate profile targets even when target count looks complete', () => { + const malformed = structuredClone(electronBuilderConfig); + malformed.linux.target = malformed.linux.target.map((target) => + target.target === 'Snap' ? { ...target, target: 'AppImage' } : target + ); + assert.throws( + () => + configureLinuxFrameCopyBuild(malformed, { + profileName: 'portable', + }), + /duplicate target "appimage".*missing.*snap/is + ); +}); + +test('preserves unrelated fpm dependencies and normalizes only frame-copy dependencies', () => { + const customized = structuredClone(electronBuilderConfig); + customized.deb = { + fpm: [ + '--depends=unrelated-runtime', + '--depends=mesa', + '--depends=libmpv2 >= 2', + '--depends=libgl1', + ], + }; + const system = configureLinuxFrameCopyBuild(customized, { + profileName: 'system', + }); + assert.deepEqual(system.deb.fpm, [ + '--depends=unrelated-runtime', + '--depends=mesa', + '--depends=libmpv2', + '--depends=libegl1', + '--depends=libgl1', + '--depends=libgbm1', + ]); + + const foreign = configureLinuxFrameCopyBuild(customized, { + foreignDeb: true, + }); + assert.deepEqual(foreign.deb.fpm, [ + '--depends=unrelated-runtime', + '--depends=mesa', + ]); +}); + +test('Linux CI builds one cached source runtime and packages three isolated profiles', () => { + assert.match(buildWorkflow, /^ {4}linux-embedded-mpv-runtime:$/m); + for (const profile of ['system', 'portable', 'flatpak']) { + assert.match( + buildWorkflow, + new RegExp(`linux_profile: ${profile}(?:\\s|$)`) + ); + } + assert.doesNotMatch(buildWorkflow, /linux_profile: standard/); + assert.match( + buildWorkflow, + /configure-linux-frame-copy-build\.mjs --profile/ + ); + assert.match( + buildWorkflow, + /configure-linux-frame-copy-build\.mjs[\s\S]*--foreign-deb/ + ); + assert.match( + buildWorkflow, + /apt-cache policy[\s\S]*meson=1\.7\.2[\s\S]*toolchain-sha256/ + ); + assert.match( + buildWorkflow, + /key: \$\{\{ steps\.linux-runtime-cache-key\.outputs\.key \}\}/ + ); + const cacheStep = workflowStep( + 'Restore pinned Linux runtime and immutable source inputs' + ); + assert.match(cacheStep, /dist\/linux-frame-copy-runtime-source-inputs/); + assert.doesNotMatch(cacheStep, /linux-frame-copy-runtime-sources\.tar\.xz/); + assert.doesNotMatch(cacheStep, /THIRD_PARTY_NOTICES/); + + const complianceStep = workflowStep( + 'Generate Linux runtime notices and assemble source compliance' + ); + assert.doesNotMatch(complianceStep, /^\s+if:/m); + assert.match( + complianceStep, + /generate-linux-runtime-notices\.cjs generate/ + ); + assert.match(complianceStep, /git rev-parse HEAD/); + assert.match(complianceStep, /git diff --binary HEAD/); + assert.match( + complianceStep, + /tar[\s\S]*linux-frame-copy-runtime-sources\.tar\.xz/ + ); + assert.match( + complianceStep, + /linux-source-archive-contract\.cjs create[\s\S]*source-archive-binding\.json/ + ); + assert.ok( + complianceStep.indexOf( + '--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz' + ) < complianceStep.indexOf('linux-source-archive-contract.cjs create') + ); + assert.match(complianceStep, /source-index\.json/); + assert.match(complianceStep, /THIRD_PARTY_NOTICES\.txt/); + assert.match(complianceStep, /embedded-mpv-notices\.json/); + assert.match( + complianceStep, + /SOURCE_INPUT_ROOT.*license-inputs[\s\S]*SOURCE_BUNDLE_ROOT.*license-inputs/ + ); + assert.match( + complianceStep, + /new Set\(expectedArchiveHashes\)\.size[\s\S]*archives\.length !== expectedArchiveHashes\.length/ + ); + assert.match(complianceStep, /prepare-linux-runtime-source-snapshot\.cjs/); + assert.doesNotMatch( + complianceStep, + /cp -a "\$\{SOURCE_INPUT_ROOT\}\/git\/\."/ + ); + assert.ok( + complianceStep.indexOf('prepare-linux-runtime-source-snapshot.cjs') < + complianceStep.indexOf( + '--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz' + ) + ); + assert.match( + complianceStep, + /libplacebo-source-record\.json[\s\S]*sourceGitCommit[\s\S]*sourceSubmodules/ + ); + assert.match( + complianceStep, + /prepare-linux-runtime-source-snapshot\.cjs assert-vcs-free/ + ); + assert.ok( + complianceStep.indexOf( + 'prepare-linux-runtime-source-snapshot.cjs assert-vcs-free' + ) < + complianceStep.indexOf( + '--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz' + ) + ); + assert.match( + buildWorkflow, + /hashFiles\([^\n]*prepare-linux-runtime-source-snapshot\.cjs/ + ); + assert.match( + buildWorkflow, + /hashFiles\([^\n]*linux-source-archive-contract\.cjs/ + ); + + const buildStep = workflowStep('Build and stage pinned LGPL Linux runtime'); + assert.match(buildStep, /generate-linux-runtime-notices\.cjs collect/); + assert.match( + buildStep, + /linux-frame-copy-runtime-source-inputs[\s\S]*archives[\s\S]*git\/libplacebo/ + ); + assert.ok( + buildStep.indexOf('git clean -ffdqx') < + buildStep.indexOf( + 'cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo"' + ) + ); + assert.doesNotMatch(buildStep, /linux-frame-copy-runtime-sources\.tar\.xz/); + assert.match( + buildWorkflow, + /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: \$\{\{ matrix\.linux_profile/ + ); + const makeStep = workflowStep('Make Electron app'); + assert.match( + makeStep, + /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: \$\{\{ matrix\.linux_profile/ + ); + assert.match(buildWorkflow, /linux-frame-copy-runtime-sources\.tar\.xz/); + assert.match(buildWorkflow, /name: linux-frame-copy-runtime-sources/); + assert.match(buildWorkflow, /sourceSha256[\s\S]*source-index\.json/); + assert.match( + buildWorkflow, + /sourceArchive\?\.name[\s\S]*sourceArchive\?\.sha256/ + ); + assert.match(buildWorkflow, /sourceArchive\?\.schemaVersion/); + assert.match(buildWorkflow, /sourceArchive\?\.repositoryRevision/); + assert.match(buildWorkflow, /execFileSync\('git', \['rev-parse', 'HEAD'\]/); +}); + +test('keeps non-Linux builds independent from the Linux runtime prerequisite', () => { + const crossPlatformJob = buildWorkflowConfig.jobs?.['build-cross-platform']; + const linuxJob = buildWorkflowConfig.jobs?.['build-linux']; + + assert.ok(crossPlatformJob); + assert.ok(linuxJob); + assert.equal(crossPlatformJob.needs, undefined); + assert.deepEqual( + crossPlatformJob.strategy.matrix.include.map(({ os, arch }) => ({ + os, + arch, + })), + [ + { os: 'macos', arch: 'x64' }, + { os: 'macos', arch: 'arm64' }, + { os: 'windows', arch: 'x64' }, + ] + ); + assert.equal(crossPlatformJob.strategy['fail-fast'], false); + assert.equal(linuxJob.needs, 'linux-embedded-mpv-runtime'); + assert.deepEqual( + linuxJob.strategy.matrix.include.map( + ({ os, arch, linux_profile: profile }) => ({ + os, + arch, + profile, + }) + ), + [ + { os: 'linux', arch: 'x64', profile: 'system' }, + { os: 'linux', arch: 'x64', profile: 'portable' }, + { os: 'linux', arch: 'x64', profile: 'flatpak' }, + ] + ); + assert.equal( + linuxJob.name, + 'Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }})' + ); + assert.equal(linuxJob.strategy['fail-fast'], false); + assert.deepEqual(linuxJob.steps, crossPlatformJob.steps); + assert.deepEqual(buildWorkflowConfig.jobs['create-release'].needs, [ + 'build-cross-platform', + 'build-linux', + ]); + assert.match(buildWorkflow, /steps:\s+&electron-build-steps/); + assert.match(buildWorkflow, /steps:\s+\*electron-build-steps/); +}); + +test('Linux runtime toolchain installs fontconfig generators without network wraps', () => { + const cacheKeyStep = workflowStep( + 'Resolve Linux runtime toolchain cache key' + ); + const installStep = workflowStep( + 'Install pinned Linux runtime build dependencies' + ); + + assert.match(cacheKeyStep, /\bapt-cache policy[\s\S]*\bgperf\b/); + assert.match(installStep, /^\s+gperf\s+\\$/m); +}); + +test('Linux CI verifies every package family and exercises intended environments', () => { + const flatpakVerificationStep = workflowStep( + 'Verify Flatpak payload, launcher, and sandboxed runtime' + ); + + for (const suffix of [ + 'AppImage', + 'deb', + 'rpm', + 'pacman', + 'snap', + 'flatpak', + ]) { + assert.match( + buildWorkflow, + new RegExp(`\\.${suffix.replace('.', '\\.')}(?:['"*:/\\s]|$)`) + ); + } + assert.ok( + buildWorkflow.match(/verify-linux-frame-copy-runtime\.mjs/g).length >= 6 + ); + assert.match(buildWorkflow, /ubuntu:24\.04/); + assert.match(buildWorkflow, /fedora:latest/); + assert.match(buildWorkflow, /archlinux:latest/); + assert.match( + buildWorkflow, + /snap run iptvnator --embedded-mpv-runtime-probe/ + ); + assert.doesNotMatch(buildWorkflow, /snap run --shell iptvnator/); + assert.match( + buildWorkflow, + /flatpak run --command=sh com\.fourgray\.iptvnator/ + ); + assert.match( + flatpakVerificationStep, + /flatpak run\s+\\\s+--env=LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+com\.fourgray\.iptvnator\s+\\\s+--embedded-mpv-runtime-probe/ + ); + assert.doesNotMatch( + flatpakVerificationStep, + /HELPER_PATH=.*iptvnator_mpv_helper/ + ); + assert.doesNotMatch(buildWorkflow, /\bldd\b/); +}); + +test('Flatpak application runtime probe runs under an isolated D-Bus session', () => { + const installStep = workflowStep('Install Linux system dependencies'); + const flatpakVerificationStep = workflowStep( + 'Verify Flatpak payload, launcher, and sandboxed runtime' + ); + + assert.match( + flatpakVerificationStep, + /xvfb-run -a dbus-run-session -- flatpak run\s+\\\s+--env=LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+com\.fourgray\.iptvnator\s+\\\s+--embedded-mpv-runtime-probe/ + ); + assert.match(installStep, /^\s+dbus-daemon\s+\\$/m); + assert.match( + flatpakVerificationStep, + /xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+flatpak run --command=sh com\.fourgray\.iptvnator/ + ); +}); + +test('foreign DEB CI explicitly selects both marker-only ARM architectures', () => { + const foreignDebStep = workflowStep( + 'Make marker-only foreign-architecture DEB packages' + ); + + assert.match(foreignDebStep, /for foreign_arch in armv7l arm64; do/); + assert.match( + foreignDebStep, + /--foreign-deb\s+\\\s+--foreign-arch "\$\{foreign_arch\}"/ + ); + assert.match(foreignDebStep, /--arch="\$\{foreign_arch\}"/); + assert.match( + foreignDebStep, + /for foreign_arch[\s\S]*cp "\$\{RUNNER_TEMP\}\/electron-builder\.base\.json" electron-builder\.json[\s\S]*configure-linux-frame-copy-build\.mjs/ + ); + assert.match(foreignDebStep, /rm -rf dist\/executables-linux-foreign/); + assert.match( + foreignDebStep, + /mapfile -t foreign_debs < <\(\s*find dist\/executables-linux-foreign[\s\S]*-name '\*\.deb' -print\s*\)/ + ); + assert.match(foreignDebStep, /test "\$\{#foreign_debs\[@\]\}" -eq 1/); + assert.match(foreignDebStep, /armv7l\) expected_deb_arch=armhf/); + assert.match(foreignDebStep, /arm64\) expected_deb_arch=arm64/); + assert.match( + foreignDebStep, + /actual_deb_arch="\$\(dpkg-deb --field "\$\{foreign_debs\[0\]\}" Architecture\)"[\s\S]*test "\$\{actual_deb_arch\}" = "\$\{expected_deb_arch\}"/ + ); + assert.match( + foreignDebStep, + /mv "\$\{foreign_debs\[0\]\}" dist\/executables\// + ); + assert.match(foreignDebStep, /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ''/); + assert.match(foreignDebStep, /IPTVNATOR_REQUIRE_EMBEDDED_MPV: '1'/); + assert.doesNotMatch(foreignDebStep, /IPTVNATOR_REQUIRE_EMBEDDED_MPV: '0'/); +}); + +test('system package smoke environments install every direct helper runtime dependency', () => { + const debStep = workflowStep('Verify DEB payloads and x64 system runtime'); + for (const dependency of ['libmpv2', 'libegl1', 'libgl1', 'libgbm1']) { + assert.match(debStep, new RegExp(`\\b${dependency}\\b`)); + } + + const rpmStep = workflowStep('Verify RPM payload and x64 system runtime'); + for (const dependency of [ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-glx', + 'mesa-libgbm', + ]) { + assert.match(rpmStep, new RegExp(`\\b${dependency}\\b`)); + } + + const pacmanStep = workflowStep( + 'Verify Pacman payload and x64 system runtime' + ); + for (const dependency of ['mpv', 'libglvnd', 'mesa']) { + assert.match(pacmanStep, new RegExp(`\\b${dependency}\\b`)); + } +}); + +test('Snap verifier preserves fail-closed status while exposing captured diagnostics', () => { + const snapStep = workflowStep( + 'Verify Snap payloads and strict-confinement runtime' + ); + + assert.match(snapStep, /set -euo pipefail/); + assert.match(snapStep, /2>&1 \| tee \/dev\/stderr/); + assert.doesNotMatch( + snapStep, + /^\s+printf '%s\\n' "\$\{verification\}"\s*$/m + ); + assert.ok( + snapStep.indexOf('verification="$(') < + snapStep.indexOf("grep -Fq 'Verified snap x64 Linux'") + ); + assert.ok( + snapStep.indexOf("grep -Fq 'Verified snap x64 Linux'") < + snapStep.indexOf('sudo snap install --dangerous') + ); + assert.match( + snapStep, + /snap list mesa-core22 >\/dev\/null 2>&1 \|\| sudo snap install mesa-core22/ + ); + assert.match( + snapStep, + /snap list gnome-3-28-1804 >\/dev\/null 2>&1 \|\| sudo snap install gnome-3-28-1804/ + ); + assert.ok( + snapStep.indexOf('sudo snap install mesa-core22') < + snapStep.indexOf('sudo snap install --dangerous') + ); + assert.ok( + snapStep.indexOf('sudo snap install gnome-3-28-1804') < + snapStep.indexOf('sudo snap install --dangerous') + ); + assert.ok( + snapStep.indexOf('sudo snap install --dangerous') < + snapStep.indexOf('installed_x64=true') + ); + assert.match( + snapStep, + /sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22/ + ); + assert.match( + snapStep, + /sudo snap connect iptvnator:gnome-3-28-1804 gnome-3-28-1804:gnome-3-28-1804/ + ); + assert.match( + snapStep, + /\$2 == "iptvnator:graphics-core22" && \$3 == "mesa-core22:graphics-core22"/ + ); + assert.match( + snapStep, + /\$2 == "iptvnator:gnome-3-28-1804" && \$3 == "gnome-3-28-1804:gnome-3-28-1804"/ + ); + assert.match( + snapStep, + /\$1 == "shared-memory" && \$2 == "iptvnator:shared-memory" && \$3 == ":shared-memory"/ + ); + assert.match( + snapStep, + /sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22/ + ); + assert.match( + snapStep, + /\$2 == "iptvnator:graphics-core22" && \$3 == "-" \{ found=1 \} END \{ exit !found \}/ + ); + assert.match(snapStep, /disconnected_probe="\$\(/); + assert.match(snapStep, /disconnected_status=\$\?/); + assert.match(snapStep, /test "\$\{disconnected_status\}" -eq 1/); + assert.ok( + snapStep.includes( + `grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}'` + ) + ); + const firstGraphicsConnect = snapStep.indexOf( + 'sudo snap connect iptvnator:graphics-core22' + ); + const graphicsDisconnect = snapStep.indexOf( + 'sudo snap disconnect iptvnator:graphics-core22' + ); + const secondGraphicsConnect = snapStep.indexOf( + 'sudo snap connect iptvnator:graphics-core22', + firstGraphicsConnect + 1 + ); + assert.ok(firstGraphicsConnect < graphicsDisconnect); + assert.ok(graphicsDisconnect < snapStep.indexOf('disconnected_probe="$(')); + assert.ok( + snapStep.indexOf('test "${disconnected_status}" -eq 1') < + secondGraphicsConnect + ); + const firstRuntimeProbe = snapStep.indexOf( + 'snap run iptvnator --embedded-mpv-runtime-probe' + ); + const successfulRuntimeProbe = snapStep.lastIndexOf( + 'snap run iptvnator --embedded-mpv-runtime-probe' + ); + const graphicsConnectionAssertion = snapStep.indexOf( + '$2 == "iptvnator:graphics-core22" && $3 == "mesa-core22:graphics-core22"' + ); + const gnomeConnectionAssertion = snapStep.indexOf( + '$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804"' + ); + const sharedMemoryConnectionAssertion = snapStep.indexOf( + '$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory"' + ); + assert.ok(firstRuntimeProbe < secondGraphicsConnect); + assert.ok(firstRuntimeProbe < successfulRuntimeProbe); + assert.ok(secondGraphicsConnect < successfulRuntimeProbe); + assert.ok(secondGraphicsConnect < graphicsConnectionAssertion); + assert.ok(graphicsConnectionAssertion < gnomeConnectionAssertion); + assert.ok(gnomeConnectionAssertion < sharedMemoryConnectionAssertion); + assert.ok(sharedMemoryConnectionAssertion < successfulRuntimeProbe); + assert.match(snapStep, /snap run iptvnator --embedded-mpv-runtime-probe/); + for (const traceSelector of [ + 'IPTVNATOR_TRACE_PLAYER=1', + 'EGL_LOG_LEVEL=debug', + 'LIBGL_DEBUG=verbose', + ]) { + assert.match( + snapStep.slice(secondGraphicsConnect), + new RegExp(traceSelector) + ); + } + for (const hostileOverride of [ + '__EGL_VENDOR_LIBRARY_FILENAMES', + 'GBM_BACKEND', + 'MESA_LOADER_DRIVER_OVERRIDE', + 'LIBVA_DRIVER_NAME', + 'VDPAU_DRIVER_PATH', + 'VK_DRIVER_FILES', + 'VK_ICD_FILENAMES', + 'VK_ADD_DRIVER_FILES', + 'VK_ADD_LAYER_PATH', + 'VK_IMPLICIT_LAYER_PATH', + 'VK_ADD_IMPLICIT_LAYER_PATH', + 'XDG_CONFIG_HOME', + 'XDG_CONFIG_DIRS', + 'XDG_DATA_HOME', + 'XDG_DATA_DIRS', + ]) { + assert.match( + snapStep.slice(secondGraphicsConnect), + new RegExp(`${hostileOverride}=/tmp/hostile`) + ); + } + assert.doesNotMatch(snapStep, /snap run --shell/); + assert.doesNotMatch(snapStep, /iptvnator_mpv_helper/); + assert.doesNotMatch(snapStep, /LD_LIBRARY_PATH/); +}); + +test('dedicated packaged x64 smoke cannot silently skip', () => { + const e2eProject = JSON.parse( + fs.readFileSync( + path.join( + workspaceRoot, + 'apps', + 'electron-backend-e2e', + 'project.json' + ), + 'utf8' + ) + ); + assert.deepEqual( + e2eProject.targets?.['packaged-frame-copy-smoke']?.dependsOn, + ['test-packaged-frame-copy-fixtures'] + ); + const linuxDependencies = workflowStep('Install Linux system dependencies'); + assert.match(linuxDependencies, /--no-install-recommends/); + assert.match(linuxDependencies, /^\s+libgl-dev\s*\\?$/m); + assert.doesNotMatch(linuxDependencies, /\blibopengl-dev\b/); + assert.match(linuxDependencies, /^\s+xauth\s*\\?$/m); + assert.match(linuxDependencies, /^\s+xvfb\s*\\?$/m); + const packagedSmoke = workflowStep( + 'Run packaged x64 frame-copy and fallback smoke' + ); + assert.match( + packagedSmoke, + /IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1'/ + ); + assert.match( + packagedSmoke, + /IPTVNATOR_E2E_PACKAGED_EXECUTABLE: \$\{\{ github\.workspace \}\}\/dist\/executables\/linux-unpacked\/iptvnator/ + ); + assert.match( + packagedSmoke, + /electron-backend-e2e:packaged-frame-copy-smoke/ + ); + const hardwareDiagnostic = workflowStep( + 'Diagnose packaged x64 frame-copy hardware path' + ); + assert.match(hardwareDiagnostic, /continue-on-error: true/); + assert.match(hardwareDiagnostic, /\/dev\/dri\/renderD128/); + assert.match( + hardwareDiagnostic, + /IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1'/ + ); + assert.doesNotMatch(hardwareDiagnostic, /LIBGL_ALWAYS_SOFTWARE/); +}); + +test('draft release consumes split Linux artifacts and source compliance', () => { + for (const artifactName of [ + 'linux-system-artifacts', + 'linux-portable-artifacts', + 'linux-flatpak-artifacts', + 'linux-frame-copy-runtime-sources', + ]) { + assert.match(buildWorkflow, new RegExp(artifactName)); + } + const systemUpload = workflowStep('Upload system-runtime Linux artifacts'); + for (const artifactGlob of [ + 'dist/executables/*.deb', + 'dist/executables/*.rpm', + 'dist/executables/*.pacman', + 'dist/executables/*.pkg.tar.*', + ]) { + assert.ok(systemUpload.includes(artifactGlob)); + } + const portableUpload = workflowStep( + 'Upload portable-runtime Linux artifacts' + ); + for (const artifactGlob of [ + 'dist/executables/*.AppImage', + 'dist/executables/*.snap', + 'dist/executables/**/latest-linux*.yml', + 'dist/executables/**/*.blockmap', + ]) { + assert.ok(portableUpload.includes(artifactGlob)); + } + assert.ok( + workflowStep('Upload Flatpak-runtime Linux artifacts').includes( + 'dist/executables/**/*.flatpak' + ) + ); + const release = workflowStep('Create Draft Release'); + for (const releasePath of [ + 'artifacts/linux-system-artifacts/*.deb', + 'artifacts/linux-system-artifacts/*.rpm', + 'artifacts/linux-system-artifacts/*.pacman', + 'artifacts/linux-system-artifacts/*.pkg.tar.*', + 'artifacts/linux-portable-artifacts/*.AppImage', + 'artifacts/linux-portable-artifacts/*.snap', + 'artifacts/linux-flatpak-artifacts/*.flatpak', + 'artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz', + ]) { + assert.ok(release.includes(releasePath)); + } + assert.doesNotMatch(buildWorkflow, /^ {4}publish-snap:/m); +}); diff --git a/tools/packaging/electron-after-pack.cjs b/tools/packaging/electron-after-pack.cjs index e86f23667..ee6f9d640 100644 --- a/tools/packaging/electron-after-pack.cjs +++ b/tools/packaging/electron-after-pack.cjs @@ -4,6 +4,10 @@ const { resolveElectronBuilderArchName, validatePackagedEmbeddedMpv, } = require('./embedded-mpv-packaging.cjs'); +const { + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +} = require('./linux-frame-copy-profile.cjs'); const fs = require('fs'); const path = require('path'); const { @@ -22,7 +26,12 @@ function isTruthy(value) { ); } -function copyEmbeddedMpvNativeOutput(resourceDir, projectDir, platform) { +function copyEmbeddedMpvNativeOutput( + resourceDir, + projectDir, + platform, + preparationOptions +) { const sourceDir = path.join( projectDir, 'dist', @@ -45,7 +54,24 @@ function copyEmbeddedMpvNativeOutput(resourceDir, projectDir, platform) { fs.rmSync(destinationDir, { recursive: true, force: true }); fs.cpSync(sourceDir, destinationDir, { recursive: true }); - preparePackagedFrameCopyArtifacts(destinationDir, platform); + const resolvedPreparationOptions = + platform === 'linux' && preparationOptions + ? { + ...preparationOptions, + noticeSourceDir: path.join( + projectDir, + 'vendor', + 'embedded-mpv', + 'linux-x64', + 'notices' + ), + } + : preparationOptions; + return preparePackagedFrameCopyArtifacts( + destinationDir, + platform, + resolvedPreparationOptions + ); } function writeEmbeddedMpvUnavailableMarker(resourceDir, targetArch) { @@ -64,12 +90,127 @@ function writeEmbeddedMpvUnavailableMarker(resourceDir, targetArch) { ); } -async function afterPackHook(params) { - await linuxAfterPack(params); +function resolveLinuxFrameCopyPackagingContext( + params, + { + required = isTruthy(process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV), + environment = process.env, + } = {} +) { + if (params.electronPlatformName !== 'linux') { + return null; + } + const targetArch = resolveElectronBuilderArchName(params.arch); + if (!targetArch) { + throw new Error( + `Unknown Electron Builder architecture: ${String(params.arch)}.` + ); + } + const targetNames = []; + for (const target of params.targets ?? []) { + const targetName = String(target?.name ?? '') + .trim() + .toLowerCase(); + if (!targetName) { + throw new Error( + 'Linux Electron Builder targets must expose a non-empty name.' + ); + } + if (targetNames.includes(targetName)) { + throw new Error( + `Linux Electron Builder target "${targetName}" is duplicated.` + ); + } + targetNames.push(targetName); + } + if (targetNames.length === 0) { + throw new Error( + 'Linux Electron Builder must provide at least one packaging target.' + ); + } + + // Official Linux frame-copy artifacts are intentionally x64-only. Do not + // let a caller-provided build-arch environment value promote an ARM + // package to a supported layout: every non-x64 target must remain the + // marker-only native-view fallback. + const foreignArch = targetArch !== 'x64'; + const profileValue = + environment.IPTVNATOR_LINUX_FRAME_COPY_PROFILE?.trim() ?? ''; + if (!profileValue) { + if (required && targetArch === 'x64') { + resolveLinuxFrameCopyProfile(profileValue); + } + return { + targetArch, + foreignArch, + targetNames, + profile: null, + }; + } + + const profile = resolveLinuxFrameCopyProfile(profileValue); + const targetErrors = validateLinuxProfileTargets(profile.name, targetNames); + if (targetErrors.length > 0) { + throw new Error(targetErrors.join('\n')); + } + return { + targetArch, + foreignArch, + targetNames, + profile, + }; +} + +function ensureSnapGraphicsContentMount(appOutDir, targetNames) { + if (!targetNames.includes('snap')) { + return null; + } + + const mountPath = path.join(appOutDir, 'graphics'); + if (!fs.existsSync(mountPath)) { + fs.mkdirSync(mountPath, { mode: 0o755 }); + } + const stat = fs.lstatSync(mountPath); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error( + `Snap graphics content mount must be a real empty directory: ${mountPath}` + ); + } + if (fs.readdirSync(mountPath).length > 0) { + throw new Error( + `Snap graphics content mount directory must be empty: ${mountPath}` + ); + } + fs.chmodSync(mountPath, 0o755); + return mountPath; +} + +async function afterPackHook(params) { const requireEmbeddedMpv = isTruthy( process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV ); + const linuxPackagingContext = resolveLinuxFrameCopyPackagingContext( + params, + { + required: requireEmbeddedMpv, + environment: process.env, + } + ); + + await linuxAfterPack(params); + if (linuxPackagingContext) { + const graphicsMountPath = ensureSnapGraphicsContentMount( + params.appOutDir, + linuxPackagingContext.targetNames + ); + if (graphicsMountPath) { + log( + `prepared empty Snap graphics content mount at ${graphicsMountPath}` + ); + } + } + log( requireEmbeddedMpv ? `validating required embedded MPV ${params.electronPlatformName} runtime` @@ -77,12 +218,11 @@ async function afterPackHook(params) { ); const resourceDir = getResourceDir(params); - const foreignArch = isForeignLinuxEmbeddedMpvArch( - params.electronPlatformName, - params.arch - ); + const foreignArch = + linuxPackagingContext?.foreignArch ?? + isForeignLinuxEmbeddedMpvArch(params.electronPlatformName, params.arch); if (foreignArch) { - const targetArch = resolveElectronBuilderArchName(params.arch); + const targetArch = linuxPackagingContext.targetArch; log( `embedded MPV addon is not built for ${targetArch}; packaging an unavailable marker instead` ); @@ -91,7 +231,13 @@ async function afterPackHook(params) { copyEmbeddedMpvNativeOutput( resourceDir, params.packager.projectDir ?? process.cwd(), - params.electronPlatformName + params.electronPlatformName, + linuxPackagingContext + ? { + profile: linuxPackagingContext.profile?.name, + targetNames: linuxPackagingContext.targetNames, + } + : undefined ); } @@ -99,6 +245,10 @@ async function afterPackHook(params) { platform: params.electronPlatformName, required: requireEmbeddedMpv, foreignArch, + targetArch: linuxPackagingContext?.targetArch, + profile: linuxPackagingContext?.profile?.name, + targetNames: linuxPackagingContext?.targetNames, + executableName: params.packager.executableName, }); if (errors.length > 0) { @@ -136,3 +286,8 @@ function getResourceDir(params) { } module.exports = afterPackHook; +module.exports.ensureSnapGraphicsContentMount = ensureSnapGraphicsContentMount; +module.exports.resolveLinuxFrameCopyPackagingContext = + resolveLinuxFrameCopyPackagingContext; +module.exports.writeEmbeddedMpvUnavailableMarker = + writeEmbeddedMpvUnavailableMarker; diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index a1b2a7a13..64d934b8d 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -196,7 +196,7 @@ test('GitHub Releases auto-update metadata is generated and uploaded', () => { ); assert.match( buildAndMakeWorkflow, - /artifacts\/linux-artifacts\/latest-linux\*\.yml/ + /artifacts\/linux-portable-artifacts\/latest-linux\*\.yml/ ); assert.match( buildAndMakeWorkflow, @@ -298,6 +298,16 @@ test('package layout verifier uses canonical helpers and direct dependencies', ( packageLayoutVerifier, /builderEffectiveConfigPath && fileExists\(builderEffectiveConfigPath\)/ ); + assert.match(packageLayoutVerifier, /IPTVNATOR_LINUX_FRAME_COPY_PROFILE/); + assert.match(packageLayoutVerifier, /profile:\s*linuxFrameCopyProfile/); + assert.match(packageLayoutVerifier, /targetNames:\s*linuxTargetNames/); + assert.match( + packageLayoutVerifier, + /validateLinuxProfileTargets\(\s*linuxFrameCopyProfile,\s*linuxTargetNames\s*\)/s + ); + assert.match(packageLayoutVerifier, /dirArch !== 'x64'/); + assert.doesNotMatch(packageLayoutVerifier, /getEmbeddedMpvAddonArch/); + assert.match(electronAfterPackSource, /targetArch !== 'x64'/); }); test('nx-electron packaging does not copy duplicate root package metadata', () => { @@ -330,6 +340,19 @@ test('embedded MPV runtime binaries are unpacked on every supported desktop plat } }); +test('embedded MPV native payload is owned exclusively by afterPack outside app.asar', () => { + assert.ok( + electronBuilderConfig.files.includes( + '!electron-backend/native{,/**/*}' + ), + 'electron-builder files must exclude the entire pre-afterPack native payload from app.asar' + ); + assert.match( + packageLayoutVerifier, + /collectEmbeddedMpvNativeArchiveEntries/ + ); +}); + test('embedded MPV package validation accepts Windows runtime files and Linux process isolation', () => { const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-')); @@ -458,13 +481,24 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr fs.writeFileSync(join(linuxNativeDir, 'embedded_mpv.node'), ''); fs.writeFileSync( join(linuxNativeDir, 'embedded-mpv-runtime.json'), - JSON.stringify({ origin: 'external-mpv-process' }) + JSON.stringify({ + schemaVersion: 1, + origin: 'external-mpv-process', + platform: 'linux', + arch: 'x64', + runtimeMode: 'native-view-only', + frameCopyAvailable: false, + artifacts: { + addon: 'embedded_mpv.node', + }, + nativeViewFallback: 'process-isolated mpv --wid', + }) ); assert.deepEqual( validatePackagedEmbeddedMpv(linuxResourceDir, { platform: 'linux', - required: true, + required: false, }), [] ); @@ -711,6 +745,16 @@ test('Windows CI packages embedded MPV from a staged x64 runtime', () => { const requireEmbeddedMpvLines = buildAndMakeWorkflow .split(/\r?\n/) .filter((line) => line.includes('IPTVNATOR_REQUIRE_EMBEDDED_MPV:')); + const defaultRuntimeUrls = [ + ...buildAndMakeWorkflow.matchAll( + /IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL:\s+(\S+)/g + ), + ].map((match) => match[1]); + const defaultRuntimeSha256s = [ + ...buildAndMakeWorkflow.matchAll( + /IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256:\s+([a-f0-9]{64})/g + ), + ].map((match) => match[1]); assert.equal( packageMetadata.scripts?.['embedded-mpv:stage-runtime:windows-archive'], @@ -733,6 +777,16 @@ test('Windows CI packages embedded MPV from a staged x64 runtime', () => { buildAndMakeWorkflow, /IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: https:\/\/github\.com\/zhongfly\/mpv-winbuild\/releases\/download\// ); + assert.deepEqual( + [...new Set(defaultRuntimeUrls)], + [ + 'https://github.com/zhongfly/mpv-winbuild/releases/download/2026-07-17-94335ab87a/mpv-dev-lgpl-x86_64-20260717-git-94335ab87a.7z', + ] + ); + assert.deepEqual( + [...new Set(defaultRuntimeSha256s)], + ['6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0'] + ); assert.match(buildAndMakeWorkflow, /refs\/tags\/v\*/); assert.match( buildAndMakeWorkflow, diff --git a/tools/packaging/embedded-mpv-arch.test.mjs b/tools/packaging/embedded-mpv-arch.test.mjs index 863f7a3af..ca37b0d02 100644 --- a/tools/packaging/embedded-mpv-arch.test.mjs +++ b/tools/packaging/embedded-mpv-arch.test.mjs @@ -1,19 +1,1316 @@ import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import { createRequire } from 'node:module'; import os from 'node:os'; -import { join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import test from 'node:test'; const require = createRequire(import.meta.url); +const { + DEFAULT_SYSTEM_PKG_CONFIG_DIRS, + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, + MINIMUM_TOOL_VERSIONS, + REQUIRED_TOOLS, + SOURCE_PACKAGES, + createLinuxRuntimeManifest, +} = require('../embedded-mpv/build-linux-runtime.cjs'); const { isForeignLinuxEmbeddedMpvArch, linuxUnpackedDirArch, + resolveConfiguredLinuxTargetNames, resolveElectronBuilderArchName, validatePackagedEmbeddedMpv, } = require('./embedded-mpv-packaging.cjs'); +const { + preparePackagedFrameCopyArtifacts, +} = require('./embedded-mpv-frame-copy-files.cjs'); +const { + LICENSE_PATHS_BY_PACKAGE, + collectLinuxRuntimeLicenseInputs, + generateLinuxRuntimeNotices, +} = require('../embedded-mpv/generate-linux-runtime-notices.cjs'); +const { + ensureSnapGraphicsContentMount, + resolveLinuxFrameCopyPackagingContext, +} = require('./electron-after-pack.cjs'); const X64_ADDON_ENV = { IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64' }; +const SYSTEM_PACKAGE_DEPENDENCIES = { + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-glx', 'mesa-libgbm'], + pacman: ['mpv', 'libglvnd', 'mesa'], +}; +const FRAME_COPY_ARTIFACTS = { + addon: 'embedded_mpv.node', + frameReader: 'embedded_mpv_frame_reader.node', + helper: 'iptvnator_mpv_helper', +}; + +function sha256(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +function sourcePackageRecord(sourcePackage) { + return { + version: sourcePackage.version, + sourceUrl: sourcePackage.sourceUrl, + ...(sourcePackage.sourceTag + ? { sourceTag: sourcePackage.sourceTag } + : {}), + ...(sourcePackage.sourceKind === 'archive' + ? { sourceSha256: sourcePackage.expectedSha256 } + : { + sourceGitCommit: sourcePackage.expectedGitCommit, + sourceSubmodules: [...sourcePackage.expectedSubmodules], + }), + license: sourcePackage.license, + }; +} + +function createSourceRuntime(runtimeContents) { + const runtimeFiles = Object.entries(runtimeContents) + .map(([name, contents]) => ({ + name, + size: Buffer.byteLength(contents), + sha256: sha256(contents), + })) + .sort((left, right) => left.name.localeCompare(right.name)); + const runtimeNames = new Set(runtimeFiles.map(({ name }) => name)); + const entries = runtimeFiles.map(({ name }) => { + const needed = name.startsWith('libmpv.so') + ? [ + ...(runtimeNames.has('libavcodec.so.61') + ? ['libavcodec.so.61'] + : []), + 'libEGL.so.1', + 'libc.so.6', + ] + : ['libm.so.6']; + return { + name, + soname: name === 'libmpv.so' ? 'libmpv.so.2' : null, + needed: needed.sort(), + rpath: [], + runpath: ['$ORIGIN'], + }; + }); + const externalDependencies = [ + ...new Set( + entries + .flatMap(({ needed }) => needed) + .filter((name) => !runtimeNames.has(name)) + ), + ].sort(); + const sourceRecords = Object.fromEntries( + SOURCE_PACKAGES.map((sourcePackage) => [ + sourcePackage.id, + { + ...sourcePackage, + ...sourcePackageRecord(sourcePackage), + }, + ]) + ); + + return createLinuxRuntimeManifest({ + sourceRecords, + runtimeFiles, + abiRecords: runtimeFiles.map(({ name }) => ({ + name, + requiredGlibc: '2.34', + requiredGlibcxx: null, + })), + dependencyClosure: { + entries, + externalDependencies, + }, + buildHost: { + platform: 'linux', + arch: 'x64', + release: 'fixture-kernel', + glibcVersion: '2.35', + systemPkgConfigDirs: [...DEFAULT_SYSTEM_PKG_CONFIG_DIRS], + systemPkgConfigPackages: Object.fromEntries( + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES.map((name) => [ + name, + `${name}-fixture`, + ]) + ), + tools: Object.fromEntries( + REQUIRED_TOOLS.map((name) => [ + name, + `${name} ${MINIMUM_TOOL_VERSIONS[name]}`, + ]) + ), + }, + generatedAt: '2026-07-17T00:00:00.000Z', + }); +} + +function createBuildManifest(runtimeContents) { + const sourceRuntime = createSourceRuntime(runtimeContents); + return { + schemaVersion: 1, + origin: 'linux-frame-copy-build', + generatedAt: '2026-07-17T00:00:00.000Z', + platform: 'linux', + arch: 'x64', + buildInputMode: 'bundled-runtime', + sourceRuntimeValidated: true, + allowedPackageRuntimeModes: ['system', 'bundled'], + packageRuntimeAvailability: { + system: true, + bundled: true, + }, + artifacts: { ...FRAME_COPY_ARTIFACTS }, + processIsolation: { + addonLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + libmpvSoname: 'libmpv.so.2', + runtimeFiles: sourceRuntime.runtimeFiles.map((entry) => ({ ...entry })), + runtimeTotalBytes: sourceRuntime.runtimeTotalBytes, + sourceArchive: { + schemaVersion: 1, + name: 'linux-frame-copy-runtime-sources.tar.xz', + sha256: '7'.repeat(64), + repositoryRevision: '8'.repeat(40), + }, + sourceRuntime, + }; +} + +function createNoticeFixture(fixtureRoot, sourceRuntime) { + const sourceRoot = join(fixtureRoot, 'upstream-license-sources'); + for (const sourcePackage of SOURCE_PACKAGES) { + for (const relativePath of LICENSE_PATHS_BY_PACKAGE[sourcePackage.id]) { + const sourcePath = join(sourceRoot, sourcePackage.id, relativePath); + fs.mkdirSync(dirname(sourcePath), { recursive: true }); + fs.writeFileSync( + sourcePath, + `verbatim ${sourcePackage.id} ${relativePath}\n` + ); + } + } + const licenseInputRoot = join(fixtureRoot, 'license-inputs'); + const noticeSourceDir = join(fixtureRoot, 'generated-notices'); + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: licenseInputRoot, + runtimeManifest: sourceRuntime, + }); + generateLinuxRuntimeNotices({ + licenseInputRoot, + outputRoot: noticeSourceDir, + runtimeManifest: sourceRuntime, + }); + return noticeSourceDir; +} + +function createNativeFixture({ + runtimeContents = { + 'libavcodec.so.61': 'libavcodec-runtime', + 'libmpv.so': 'libmpv-runtime', + 'libmpv.so.2': 'libmpv-runtime', + }, + buildManifest = createBuildManifest(runtimeContents), +} = {}) { + const fixtureRoot = fs.mkdtempSync( + join(os.tmpdir(), 'iptvnator-embedded-mpv-layout-') + ); + const appOutDir = join(fixtureRoot, 'linux-unpacked'); + const resourceDir = join(appOutDir, 'resources'); + const nativeDir = join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(join(nativeDir, 'lib'), { recursive: true }); + fs.writeFileSync(join(nativeDir, FRAME_COPY_ARTIFACTS.addon), 'addon'); + fs.writeFileSync( + join(nativeDir, FRAME_COPY_ARTIFACTS.frameReader), + 'reader' + ); + fs.writeFileSync(join(nativeDir, FRAME_COPY_ARTIFACTS.helper), 'helper', { + mode: 0o644, + }); + fs.writeFileSync( + join(nativeDir, 'embedded-mpv-runtime.json'), + `${JSON.stringify(buildManifest, null, 2)}\n` + ); + for (const [name, contents] of Object.entries(runtimeContents)) { + fs.writeFileSync(join(nativeDir, 'lib', name), contents); + } + const noticeSourceDir = createNoticeFixture( + fixtureRoot, + buildManifest.sourceRuntime + ); + fs.cpSync(noticeSourceDir, nativeDir, { recursive: true }); + fs.writeFileSync(join(appOutDir, 'iptvnator.bin'), 'electron'); + return { + buildManifest, + fixtureRoot, + resourceDir, + appOutDir, + nativeDir, + noticeSourceDir, + }; +} + +function readManifest(nativeDir) { + return JSON.parse( + fs.readFileSync(join(nativeDir, 'embedded-mpv-runtime.json'), 'utf8') + ); +} + +function pureValidationOptions(options = {}) { + return { + platform: 'linux', + required: true, + foreignArch: false, + hostPlatform: 'darwin', + ...options, + }; +} + +function validElfInspector(nativeDir, manifest, overrides = {}) { + const libDir = join(nativeDir, 'lib'); + const records = new Map([ + ['iptvnator.bin', { needed: ['libc.so.6'], rpath: [], runpath: [] }], + [ + FRAME_COPY_ARTIFACTS.addon, + { needed: ['libX11.so.6'], rpath: [], runpath: [] }, + ], + [ + FRAME_COPY_ARTIFACTS.frameReader, + { needed: ['libc.so.6'], rpath: [], runpath: [] }, + ], + [ + FRAME_COPY_ARTIFACTS.helper, + { + needed: [manifest.libmpvSoname, 'libEGL.so.1', 'libc.so.6'], + rpath: [], + runpath: ['$ORIGIN/lib'], + }, + ], + ]); + for (const entry of manifest.runtimeDependencyClosure?.entries ?? []) { + records.set(entry.name, { + soname: entry.soname ?? null, + needed: [...entry.needed], + rpath: [...entry.rpath], + runpath: [...entry.runpath], + }); + } + for (const [name, value] of Object.entries(overrides)) { + records.set(name, value); + } + + return (binaryPath) => { + const name = + dirname(binaryPath) === libDir + ? basename(binaryPath) + : basename(binaryPath); + const record = records.get(name); + if (!record) { + throw new Error(`Missing ELF fixture for ${binaryPath}`); + } + return record; + }; +} + +test('resolves the required Linux profile from afterPack targets before mutation', () => { + assert.deepEqual( + resolveLinuxFrameCopyPackagingContext( + { + electronPlatformName: 'linux', + arch: 1, + targets: [{ name: 'DEB' }, { name: 'rpm' }], + }, + { + required: true, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system', + IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64', + }, + } + ), + { + targetArch: 'x64', + foreignArch: false, + targetNames: ['deb', 'rpm'], + profile: { + name: 'system', + runtimeMode: 'system', + targets: ['deb', 'rpm', 'pacman'], + manifestOrigin: 'system-libmpv-frame-copy', + }, + } + ); +}); + +test('keeps every non-x64 Linux target marker-only even when the configured addon arch matches it', () => { + const context = resolveLinuxFrameCopyPackagingContext( + { + electronPlatformName: 'linux', + arch: 3, + targets: [{ name: 'deb' }], + }, + { + required: true, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system', + IPTVNATOR_EMBEDDED_MPV_ARCH: 'arm64', + }, + } + ); + + assert.equal(context.targetArch, 'arm64'); + assert.equal(context.foreignArch, true); +}); + +test('rejects missing, mixed, and unknown required Linux packaging context', () => { + const baseParams = { + electronPlatformName: 'linux', + arch: 1, + targets: [{ name: 'deb' }], + }; + + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext(baseParams, { + required: true, + environment: { IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64' }, + }), + /Linux frame-copy profile is required/ + ); + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext( + { + ...baseParams, + targets: [{ name: 'deb' }, { name: 'AppImage' }], + }, + { + required: true, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system', + IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64', + }, + } + ), + /cannot build target "appimage"/ + ); + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext( + { ...baseParams, arch: 99 }, + { + required: true, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system', + }, + } + ), + /Unknown Electron Builder architecture/ + ); + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext( + { ...baseParams, arch: 'mips64' }, + { + required: true, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system', + }, + } + ), + /Unknown Electron Builder architecture/ + ); + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext(baseParams, { + required: true, + environment: { + IPTVNATOR_EMBEDDED_MPV_ARCH: 'arm64', + }, + }), + /Linux frame-copy profile is required/ + ); +}); + +test('validates a provided Linux profile even when embedded MPV is optional', () => { + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext( + { + electronPlatformName: 'linux', + arch: 1, + targets: [{ name: 'snap' }, { name: 'deb' }], + }, + { + required: false, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'portable', + IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64', + }, + } + ), + /cannot build target "deb"/ + ); +}); + +test('creates an exact empty Snap graphics content mount directory', (t) => { + const fixtureRoot = fs.mkdtempSync( + join(os.tmpdir(), 'iptvnator-snap-graphics-mount-') + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + + assert.equal( + ensureSnapGraphicsContentMount(fixtureRoot, ['appimage']), + null + ); + assert.equal(fs.existsSync(join(fixtureRoot, 'graphics')), false); + + const mountPath = ensureSnapGraphicsContentMount(fixtureRoot, [ + 'appimage', + 'snap', + ]); + assert.equal(mountPath, join(fixtureRoot, 'graphics')); + const mountStat = fs.lstatSync(mountPath); + assert.equal(mountStat.isDirectory(), true); + assert.equal(mountStat.isSymbolicLink(), false); + assert.equal(mountStat.mode & 0o777, 0o755); + assert.deepEqual(fs.readdirSync(mountPath), []); + + fs.chmodSync(mountPath, 0o700); + assert.equal( + ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + mountPath + ); + assert.equal(fs.lstatSync(mountPath).mode & 0o777, 0o755); +}); + +test('rejects a non-empty or redirected Snap graphics content mount', (t) => { + const fixtureRoot = fs.mkdtempSync( + join(os.tmpdir(), 'iptvnator-snap-graphics-invalid-') + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + const mountPath = join(fixtureRoot, 'graphics'); + + fs.writeFileSync(mountPath, 'not a directory'); + assert.throws( + () => ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + /real empty directory/ + ); + + fs.rmSync(mountPath); + fs.mkdirSync(mountPath); + fs.writeFileSync(join(mountPath, 'unexpected'), 'not empty'); + assert.throws( + () => ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + /must be empty/ + ); + + fs.rmSync(mountPath, { recursive: true }); + const outsidePath = join(fixtureRoot, 'outside'); + fs.mkdirSync(outsidePath); + fs.symlinkSync(outsidePath, mountPath, 'dir'); + assert.throws( + () => ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + /real empty directory/ + ); +}); + +test('prepares a normalized system profile with no private runtime', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + fs.writeFileSync( + join(fixture.nativeDir, 'iptvnator_mpv_helper.exe'), + 'stale' + ); + fs.writeFileSync( + join(fixture.nativeDir, 'embedded-mpv-unavailable.txt'), + 'stale' + ); + + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'system', + targetNames: ['deb', 'rpm', 'pacman'], + } + ); + + assert.equal(manifest.profile, 'system'); + assert.equal(manifest.runtimeMode, 'system'); + assert.equal(manifest.origin, 'system-libmpv-frame-copy'); + assert.deepEqual(manifest.targets, ['deb', 'pacman', 'rpm']); + assert.deepEqual(manifest.packageDependencies, SYSTEM_PACKAGE_DEPENDENCIES); + assert.equal(manifest.libmpvSoname, 'libmpv.so.2'); + assert.deepEqual(manifest.runtimeFiles, []); + assert.equal(manifest.runtimeTotalBytes, 0); + assert.equal(Object.hasOwn(manifest, 'sourceArchive'), false); + assert.equal(fs.existsSync(join(fixture.nativeDir, 'lib')), false); + assert.equal( + fs.statSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.helper)).mode & + 0o777, + 0o755 + ); + assert.equal( + fs.statSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.frameReader)) + .mode & 0o777, + 0o644 + ); + assert.equal( + fs.existsSync(join(fixture.nativeDir, 'iptvnator_mpv_helper.exe')), + false + ); + assert.equal( + fs.existsSync(join(fixture.nativeDir, 'embedded-mpv-unavailable.txt')), + false + ); + for (const legalPath of [ + 'embedded-mpv-notices.json', + 'THIRD_PARTY_NOTICES.txt', + 'licenses', + ]) { + assert.equal(fs.existsSync(join(fixture.nativeDir, legalPath)), false); + } + assert.deepEqual( + validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'system', + targetNames: ['deb', 'rpm', 'pacman'], + }) + ), + [] + ); +}); + +test('prepares portable and Flatpak manifests with the exact bundled closure', (t) => { + const portable = createNativeFixture(); + const flatpak = createNativeFixture(); + t.after(() => { + for (const fixture of [portable, flatpak]) { + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }); + } + }); + for (const legalPath of [ + 'embedded-mpv-notices.json', + 'THIRD_PARTY_NOTICES.txt', + 'licenses', + ]) { + fs.rmSync(join(portable.nativeDir, legalPath), { + recursive: true, + force: true, + }); + } + + const portableManifest = preparePackagedFrameCopyArtifacts( + portable.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['AppImage', 'SNAP'], + noticeSourceDir: portable.noticeSourceDir, + } + ); + const flatpakManifest = preparePackagedFrameCopyArtifacts( + flatpak.nativeDir, + 'linux', + { + profile: 'flatpak', + targetNames: ['flatpak'], + } + ); + + assert.equal(portableManifest.profile, 'portable'); + assert.equal(portableManifest.runtimeMode, 'bundled'); + assert.equal(portableManifest.origin, 'bundled-lgpl-frame-copy'); + assert.deepEqual(portableManifest.targets, ['appimage', 'snap']); + assert.deepEqual(portableManifest.packageDependencies, {}); + assert.deepEqual( + portableManifest.runtimeFiles.map(({ name }) => name).sort(), + ['libavcodec.so.61', 'libmpv.so', 'libmpv.so.2'] + ); + assert.equal( + portableManifest.runtimeTotalBytes, + portableManifest.runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ) + ); + assert.equal( + portableManifest.sourceRuntime.origin, + 'vendored-lgpl-source-build' + ); + assert.ok(portableManifest.sourceRuntime.packages.ffmpeg.sourceSha256); + assert.deepEqual( + portableManifest.sourceArchive, + portable.buildManifest.sourceArchive + ); + assert.ok( + portableManifest.sourceRuntime.ffmpeg.configureFlags.includes( + '--disable-gpl' + ) + ); + const notices = JSON.parse( + fs.readFileSync( + join(portable.nativeDir, 'embedded-mpv-notices.json'), + 'utf8' + ) + ); + assert.equal(notices.schemaVersion, 1); + assert.equal(notices.origin, 'pinned-linux-runtime-upstream-licenses'); + assert.equal(notices.noticeFile.path, 'THIRD_PARTY_NOTICES.txt'); + assert.deepEqual( + notices.packages.map(({ id }) => id), + SOURCE_PACKAGES.map(({ id }) => id).sort() + ); + assert.ok(notices.packages.every(({ files }) => files.length >= 1)); + assert.equal(flatpakManifest.profile, 'flatpak'); + assert.equal(flatpakManifest.runtimeMode, 'bundled'); + assert.equal(flatpakManifest.origin, 'bundled-lgpl-frame-copy'); + assert.deepEqual( + flatpakManifest.sourceArchive, + flatpak.buildManifest.sourceArchive + ); + assert.deepEqual( + validatePackagedEmbeddedMpv( + portable.resourceDir, + pureValidationOptions({ + profile: 'portable', + targetNames: ['appimage', 'snap'], + }) + ), + [] + ); + assert.deepEqual( + validatePackagedEmbeddedMpv( + flatpak.resourceDir, + pureValidationOptions({ + profile: 'flatpak', + targetNames: ['flatpak'], + }) + ), + [] + ); +}); + +test('rejects missing, tampered, undeclared, and symlinked bundled legal files', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['appimage'], + } + ); + const options = pureValidationOptions({ + profile: 'portable', + targetNames: ['appimage'], + hostPlatform: 'linux', + elfInspector: validElfInspector(fixture.nativeDir, manifest), + }); + const notices = JSON.parse( + fs.readFileSync( + join(fixture.nativeDir, 'embedded-mpv-notices.json'), + 'utf8' + ) + ); + const licensePath = join( + fixture.nativeDir, + notices.packages[0].files[0].path + ); + const originalContents = fs.readFileSync(licensePath); + + fs.appendFileSync(licensePath, 'tampered\n'); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /(?:Size|SHA-256) mismatch.*packaged license/i + ); + + fs.writeFileSync(licensePath, originalContents); + fs.rmSync(licensePath); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /Missing .*packaged license/i + ); + + fs.writeFileSync(licensePath, originalContents); + const undeclaredPath = join(fixture.nativeDir, 'licenses', 'stale.txt'); + fs.writeFileSync(undeclaredPath, 'stale\n'); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /undeclared packaged legal file.*stale\.txt/i + ); + + fs.rmSync(undeclaredPath); + fs.rmSync(licensePath); + fs.symlinkSync( + join(fixture.nativeDir, 'THIRD_PARTY_NOTICES.txt'), + licensePath + ); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /packaged license.*symbolic link/i + ); +}); + +test('rejects invalid build provenance and incomplete bundled runtime input', (t) => { + const extra = createNativeFixture(); + const missing = createNativeFixture(); + const invalidSource = createBuildManifest({ + 'libavcodec.so.61': 'libavcodec-runtime', + 'libmpv.so': 'libmpv-runtime', + 'libmpv.so.2': 'libmpv-runtime', + }); + invalidSource.sourceRuntime.ffmpeg.configureFlags.push('--enable-gpl'); + const invalid = createNativeFixture({ buildManifest: invalidSource }); + const unexpectedModeManifest = createBuildManifest({ + 'libavcodec.so.61': 'libavcodec-runtime', + 'libmpv.so': 'libmpv-runtime', + 'libmpv.so.2': 'libmpv-runtime', + }); + unexpectedModeManifest.allowedPackageRuntimeModes.push('development'); + const unexpectedMode = createNativeFixture({ + buildManifest: unexpectedModeManifest, + }); + const wrongSonameManifest = createBuildManifest({ + 'libavcodec.so.61': 'libavcodec-runtime', + 'libmpv.so': 'libmpv-runtime', + 'libmpv.so.2': 'libmpv-runtime', + }); + wrongSonameManifest.libmpvSoname = 'libmpv.so.9'; + const wrongSoname = createNativeFixture({ + buildManifest: wrongSonameManifest, + }); + const wrongSourceArchiveManifest = createBuildManifest({ + 'libavcodec.so.61': 'libavcodec-runtime', + 'libmpv.so': 'libmpv-runtime', + 'libmpv.so.2': 'libmpv-runtime', + }); + wrongSourceArchiveManifest.sourceArchive.sha256 = 'not-a-digest'; + const wrongSourceArchive = createNativeFixture({ + buildManifest: wrongSourceArchiveManifest, + }); + t.after(() => { + for (const fixture of [ + extra, + missing, + invalid, + unexpectedMode, + wrongSoname, + wrongSourceArchive, + ]) { + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }); + } + }); + fs.writeFileSync(join(extra.nativeDir, 'lib', 'libstale.so.1'), 'stale'); + fs.rmSync(join(missing.nativeDir, 'lib', 'libavcodec.so.61')); + + assert.throws( + () => + preparePackagedFrameCopyArtifacts(extra.nativeDir, 'linux', { + profile: 'portable', + targetNames: ['appimage'], + }), + /undeclared bundled runtime file.*libstale\.so\.1/i + ); + assert.throws( + () => + preparePackagedFrameCopyArtifacts(missing.nativeDir, 'linux', { + profile: 'flatpak', + targetNames: ['flatpak'], + }), + /Missing bundled runtime file.*libavcodec\.so\.61/ + ); + assert.throws( + () => + preparePackagedFrameCopyArtifacts(invalid.nativeDir, 'linux', { + profile: 'portable', + targetNames: ['snap'], + }), + /--enable-gpl/ + ); + assert.throws( + () => + preparePackagedFrameCopyArtifacts( + unexpectedMode.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['appimage'], + } + ), + /allowedPackageRuntimeModes.*exactly/i + ); + assert.throws( + () => + preparePackagedFrameCopyArtifacts(wrongSoname.nativeDir, 'linux', { + profile: 'portable', + targetNames: ['appimage'], + }), + /derived from.*SONAME/i + ); + assert.throws( + () => + preparePackagedFrameCopyArtifacts( + wrongSourceArchive.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['snap'], + } + ), + /source archive binding.*sha256/i + ); +}); + +test('rejects profiled preparation without a concrete package target', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + + assert.throws( + () => + preparePackagedFrameCopyArtifacts(fixture.nativeDir, 'linux', { + profile: 'portable', + targetNames: [], + }), + /at least one target/ + ); +}); + +test( + 'rejects symlinked frame-copy artifacts before chmod or packaging', + { skip: process.platform === 'win32' }, + (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const readerPath = join( + fixture.nativeDir, + FRAME_COPY_ARTIFACTS.frameReader + ); + fs.rmSync(readerPath); + fs.symlinkSync(FRAME_COPY_ARTIFACTS.addon, readerPath); + + assert.throws( + () => + preparePackagedFrameCopyArtifacts(fixture.nativeDir, 'linux', { + profile: 'system', + targetNames: ['deb'], + }), + /frame reader.*regular file/i + ); + assert.equal(fs.lstatSync(readerPath).isSymbolicLink(), true); + } +); + +test('keeps optional unprofiled Linux packages explicitly native-view-only', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux' + ); + + assert.equal(manifest.origin, 'external-mpv-process'); + assert.equal(manifest.runtimeMode, 'native-view-only'); + assert.equal(manifest.frameCopyAvailable, false); + assert.equal( + fs.existsSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.addon)), + true + ); + assert.equal( + fs.existsSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.helper)), + false + ); + assert.equal( + fs.existsSync( + join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.frameReader) + ), + false + ); + assert.equal(fs.existsSync(join(fixture.nativeDir, 'lib')), false); + assert.notEqual( + readManifest(fixture.nativeDir).origin, + 'linux-frame-copy-build' + ); +}); + +test('rejects incorrect artifact modes and profile mismatches deterministically', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + preparePackagedFrameCopyArtifacts(fixture.nativeDir, 'linux', { + profile: 'system', + targetNames: ['deb'], + }); + fs.chmodSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.helper), 0o644); + + const modeErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'system', + targetNames: ['deb'], + }) + ); + assert.match(modeErrors.join('\n'), /mode 0755/); + + fs.chmodSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.helper), 0o4755); + assert.match( + validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'system', + targetNames: ['deb'], + }) + ).join('\n'), + /mode 0755/ + ); + + fs.chmodSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.helper), 0o755); + const profileErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'portable', + targetNames: ['appimage'], + }) + ); + assert.match(profileErrors.join('\n'), /profile.*portable.*system/i); +}); + +test('turns malformed packaged manifest JSON into a deterministic error', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + fs.writeFileSync( + join(fixture.nativeDir, 'embedded-mpv-runtime.json'), + '{not-json' + ); + + assert.doesNotThrow(() => + validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'system', + targetNames: ['deb'], + }) + ) + ); + assert.match( + validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'system', + targetNames: ['deb'], + }) + ).join('\n'), + /Invalid JSON in embedded MPV runtime manifest/ + ); +}); + +test('validates Linux ELF process isolation, helper linkage, and bundled closure', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['appimage'], + } + ); + const options = { + platform: 'linux', + required: true, + foreignArch: false, + profile: 'portable', + targetNames: ['appimage'], + hostPlatform: 'linux', + elfInspector: validElfInspector(fixture.nativeDir, manifest), + }; + + assert.deepEqual( + validatePackagedEmbeddedMpv(fixture.resourceDir, options), + [] + ); + + for (const artifactName of [ + FRAME_COPY_ARTIFACTS.addon, + FRAME_COPY_ARTIFACTS.frameReader, + ]) { + const isolationErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [artifactName]: { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + isolationErrors.join('\n'), + /must not link libmpv/, + `${artifactName} must remain process-isolated from libmpv` + ); + } + + const pathBearingAddonLinkErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [FRAME_COPY_ARTIFACTS.addon]: { + needed: ['/tmp/build/libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + pathBearingAddonLinkErrors.join('\n'), + /must not link libmpv.*\/tmp\/build\/libmpv\.so\.2/ + ); + + const helperLinkErrors = validatePackagedEmbeddedMpv(fixture.resourceDir, { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [FRAME_COPY_ARTIFACTS.helper]: { + needed: ['libmpv.so.1'], + rpath: [], + runpath: ['$ORIGIN/lib'], + }, + }), + }); + assert.match( + helperLinkErrors.join('\n'), + /must directly need libmpv\.so\.2/ + ); + + const unexpectedHelperLinkErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [FRAME_COPY_ARTIFACTS.helper]: { + needed: [ + manifest.libmpvSoname, + 'libEGL.so.1', + 'libc.so.6', + 'libsurprise.so.1', + ], + rpath: [], + runpath: ['$ORIGIN/lib'], + }, + }), + } + ); + assert.match( + unexpectedHelperLinkErrors.join('\n'), + /helper dependency is not bundled or allowlisted.*libsurprise\.so\.1/ + ); + + const closureErrors = validatePackagedEmbeddedMpv(fixture.resourceDir, { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + 'libavcodec.so.61': { + needed: ['libsurprise.so.1'], + rpath: [], + runpath: ['/tmp/runtime-prefix'], + }, + }), + }); + assert.match(closureErrors.join('\n'), /RUNPATH must be exactly \$ORIGIN/); + assert.match( + closureErrors.join('\n'), + /not bundled or allowlisted.*libsurprise\.so\.1/ + ); + + fs.writeFileSync( + join(fixture.appOutDir, 'libelectron-extra.so'), + 'electron library' + ); + const electronLibraryErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + 'libelectron-extra.so': { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + electronLibraryErrors.join('\n'), + /Linux Electron library must not link libmpv.*libelectron-extra\.so/ + ); +}); + +test('recursively validates pristine Electron libraries before target packaging', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['snap'], + } + ); + const nestedElectronLibrary = join( + fixture.appOutDir, + 'future-electron-runtime', + 'libfuture-electron.so' + ); + fs.mkdirSync(dirname(nestedElectronLibrary), { recursive: true }); + fs.writeFileSync(nestedElectronLibrary, 'future electron library'); + + const validationOptions = { + platform: 'linux', + required: true, + foreignArch: false, + profile: 'portable', + targetNames: ['snap'], + hostPlatform: 'linux', + }; + const errors = validatePackagedEmbeddedMpv(fixture.resourceDir, { + ...validationOptions, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + 'libfuture-electron.so': { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + }); + + assert.match( + errors.join('\n'), + /Linux Electron library must not link libmpv.*libfuture-electron\.so/ + ); + + fs.rmSync(dirname(nestedElectronLibrary), { + recursive: true, + force: true, + }); + for (const packageLibraryDir of [ + join(fixture.appOutDir, 'lib', 'x86_64-linux-gnu'), + join(fixture.appOutDir, 'usr', 'lib', 'x86_64-linux-gnu'), + ]) { + const targetPath = join(packageLibraryDir, 'libpackage.so.1.0'); + fs.mkdirSync(packageLibraryDir, { recursive: true }); + fs.writeFileSync(targetPath, 'Snap template library'); + fs.symlinkSync( + 'libpackage.so.1.0', + join(packageLibraryDir, 'libpackage.so.1') + ); + } + + assert.deepEqual( + validatePackagedEmbeddedMpv(fixture.resourceDir, { + ...validationOptions, + artifactFormat: 'snap', + elfInspector: validElfInspector(fixture.nativeDir, manifest), + }), + [] + ); + + fs.mkdirSync(dirname(nestedElectronLibrary), { recursive: true }); + fs.writeFileSync(nestedElectronLibrary, 'future electron library'); + const snapIsolationErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...validationOptions, + artifactFormat: 'snap', + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + 'libfuture-electron.so': { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + snapIsolationErrors.join('\n'), + /Linux Electron library must not link libmpv.*libfuture-electron\.so/ + ); +}); + +test('rejects undeclared helper dependencies in system-runtime packages', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'system', + targetNames: ['deb'], + } + ); + + const errors = validatePackagedEmbeddedMpv(fixture.resourceDir, { + platform: 'linux', + required: true, + foreignArch: false, + profile: 'system', + targetNames: ['deb'], + hostPlatform: 'linux', + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [FRAME_COPY_ARTIFACTS.helper]: { + needed: [ + manifest.libmpvSoname, + 'libEGL.so.1', + 'libc.so.6', + 'libsurprise.so.1', + ], + rpath: [], + runpath: ['$ORIGIN/lib'], + }, + }), + }); + + assert.match( + errors.join('\n'), + /helper dependency is not bundled or allowlisted.*libsurprise\.so\.1/ + ); +}); test('resolveElectronBuilderArchName maps builder-util Arch enum values', () => { assert.equal(resolveElectronBuilderArchName(0), 'ia32'); @@ -24,8 +1321,11 @@ test('resolveElectronBuilderArchName maps builder-util Arch enum values', () => assert.equal(resolveElectronBuilderArchName(99), null); }); -test('flags Linux packages whose arch differs from the built addon', () => { - assert.equal(isForeignLinuxEmbeddedMpvArch('linux', 3, X64_ADDON_ENV), true); +test('flags every non-x64 Linux package regardless of configured build arch', () => { + assert.equal( + isForeignLinuxEmbeddedMpvArch('linux', 3, X64_ADDON_ENV), + true + ); assert.equal( isForeignLinuxEmbeddedMpvArch('linux', 'armv7l', X64_ADDON_ENV), true @@ -34,6 +1334,12 @@ test('flags Linux packages whose arch differs from the built addon', () => { isForeignLinuxEmbeddedMpvArch('linux', 'x64', X64_ADDON_ENV), false ); + assert.equal( + isForeignLinuxEmbeddedMpvArch('linux', 'arm64', { + IPTVNATOR_EMBEDDED_MPV_ARCH: 'arm64', + }), + true + ); // Only Linux fans out foreign arches from one dist tree. assert.equal( isForeignLinuxEmbeddedMpvArch('darwin', 'arm64', X64_ADDON_ENV), @@ -53,6 +1359,33 @@ test('derives package arch from electron-builder Linux output directory names', assert.equal(linuxUnpackedDirArch('win-unpacked'), null); }); +test('derives the exact selected Linux targets for each configured architecture', () => { + const configuredTargets = [ + { + target: 'AppImage', + arch: ['x64', 'arm64'], + }, + { + target: 'deb', + arch: ['x64'], + }, + { + target: 'Snap', + arch: ['arm64'], + }, + 'rpm', + ]; + + assert.deepEqual( + resolveConfiguredLinuxTargetNames(configuredTargets, 'x64'), + ['appimage', 'deb', 'rpm'] + ); + assert.deepEqual( + resolveConfiguredLinuxTargetNames(configuredTargets, 'arm64'), + ['appimage', 'rpm', 'snap'] + ); +}); + function createResourceDir(files) { const resourceDir = fs.mkdtempSync( join(os.tmpdir(), 'iptvnator-embedded-mpv-arch-') diff --git a/tools/packaging/embedded-mpv-frame-copy-files.cjs b/tools/packaging/embedded-mpv-frame-copy-files.cjs index 51c83b8c2..eedffeccc 100644 --- a/tools/packaging/embedded-mpv-frame-copy-files.cjs +++ b/tools/packaging/embedded-mpv-frame-copy-files.cjs @@ -1,35 +1,563 @@ -const fs = require('fs'); -const path = require('path'); +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { + validateLinuxRuntimeManifest, +} = require('../embedded-mpv/linux-runtime-manifest.cjs'); +const { + validateLinuxSourceArchiveBinding, +} = require('../embedded-mpv/linux-source-archive-contract.cjs'); +const { + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + validateLinuxRuntimeNotices, +} = require('../embedded-mpv/generate-linux-runtime-notices.cjs'); +const { + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +} = require('./linux-frame-copy-profile.cjs'); const FRAME_COPY_HELPER = 'iptvnator_mpv_helper'; const WINDOWS_FRAME_COPY_HELPER = 'iptvnator_mpv_helper.exe'; const FRAME_COPY_READER = 'embedded_mpv_frame_reader.node'; +const EMBEDDED_MPV_ADDON = 'embedded_mpv.node'; +const RUNTIME_MANIFEST = 'embedded-mpv-runtime.json'; +const UNAVAILABLE_MARKER = 'embedded-mpv-unavailable.txt'; +const LICENSES_DIRECTORY = 'licenses'; +const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; +const EXPECTED_ARTIFACTS = Object.freeze({ + addon: EMBEDDED_MPV_ADDON, + frameReader: FRAME_COPY_READER, + helper: FRAME_COPY_HELPER, +}); +const EXPECTED_PROCESS_ISOLATION = Object.freeze({ + addonLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: Object.freeze(['$ORIGIN/lib']), +}); -function preparePackagedFrameCopyArtifacts(nativeDir, platform) { - if (platform === 'linux') { - // Until Linux ships a bundled libmpv runtime, do not package a helper - // linked against the build host's system library. Remove both names - // so a stale cross-platform build artifact cannot leak into a package. - for (const fileName of [ - FRAME_COPY_HELPER, - WINDOWS_FRAME_COPY_HELPER, - ]) { - fs.rmSync(path.join(nativeDir, fileName), { force: true }); +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function readJsonFile(filePath, label) { + let contents; + try { + contents = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + throw new Error( + `Unable to read ${label} at ${filePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + try { + return JSON.parse(contents); + } catch (error) { + throw new Error( + `Invalid JSON in ${label} at ${filePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +function assertRegularReadableFile(filePath, label) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + throw new Error(`Missing ${label}: ${filePath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`${label} must be a regular file: ${filePath}`); + } + try { + fs.accessSync(filePath, fs.constants.R_OK); + } catch { + throw new Error(`${label} must be readable: ${filePath}`); + } + return stat; +} + +function sameJson(left, right) { + return isDeepStrictEqual(left, right); +} + +function normalizeTargetNames(targetNames) { + if (!Array.isArray(targetNames) || targetNames.length === 0) { + throw new Error( + 'Linux frame-copy packaging requires at least one target.' + ); + } + const normalized = []; + for (const targetName of targetNames) { + const name = String(targetName ?? '') + .trim() + .toLowerCase(); + if (!name) { + throw new Error( + 'Linux frame-copy target names must be non-empty strings.' + ); } - return; + if (normalized.includes(name)) { + throw new Error(`Linux frame-copy target "${name}" is duplicated.`); + } + normalized.push(name); + } + return normalized.sort(); +} + +function validateLinuxFrameCopyBuildManifest(manifest) { + const errors = []; + if (!isObject(manifest)) { + return ['Linux frame-copy build manifest must be an object.']; + } + for (const [field, expected] of [ + ['schemaVersion', 1], + ['origin', 'linux-frame-copy-build'], + ['platform', 'linux'], + ['arch', 'x64'], + ['buildInputMode', 'bundled-runtime'], + ['sourceRuntimeValidated', true], + ]) { + if (manifest[field] !== expected) { + errors.push( + `Linux frame-copy build manifest ${field} must equal ${JSON.stringify( + expected + )}.` + ); + } + } + if (!sameJson(manifest.allowedPackageRuntimeModes, ['system', 'bundled'])) { + errors.push( + 'Linux frame-copy build manifest allowedPackageRuntimeModes must contain exactly system and bundled.' + ); + } + if ( + !sameJson(manifest.packageRuntimeAvailability, { + system: true, + bundled: true, + }) + ) { + errors.push( + 'Linux frame-copy build manifest packageRuntimeAvailability must mark exactly system and bundled as available.' + ); + } + if (!sameJson(manifest.artifacts, EXPECTED_ARTIFACTS)) { + errors.push( + 'Linux frame-copy build manifest artifacts must name the addon, frame reader, and helper exactly.' + ); + } + if (!sameJson(manifest.processIsolation, EXPECTED_PROCESS_ISOLATION)) { + errors.push( + 'Linux frame-copy build manifest processIsolation contract is invalid.' + ); + } + if (manifest.nativeViewFallback !== 'process-isolated mpv --wid') { + errors.push( + 'Linux frame-copy build manifest nativeViewFallback contract is invalid.' + ); + } + if ( + typeof manifest.generatedAt !== 'string' || + Number.isNaN(Date.parse(manifest.generatedAt)) + ) { + errors.push( + 'Linux frame-copy build manifest generatedAt must be a valid timestamp.' + ); + } + if ( + typeof manifest.libmpvSoname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname) + ) { + errors.push( + 'Linux frame-copy build manifest libmpvSoname must be a versioned libmpv SONAME.' + ); + } + const sourceLinkerAlias = + manifest.sourceRuntime?.runtimeDependencyClosure?.entries?.find( + (entry) => entry?.name === 'libmpv.so' + ); + if ( + typeof manifest.libmpvSoname === 'string' && + sourceLinkerAlias?.soname !== manifest.libmpvSoname + ) { + errors.push( + 'Linux frame-copy build manifest libmpvSoname must be derived from the validated source runtime libmpv.so SONAME.' + ); + } + const sourceErrors = validateLinuxRuntimeManifest(manifest.sourceRuntime); + errors.push( + ...sourceErrors.map( + (error) => `Invalid bundled source runtime: ${error}` + ) + ); + errors.push( + ...validateLinuxSourceArchiveBinding(manifest.sourceArchive).map( + (error) => `Invalid Linux source archive binding: ${error}` + ) + ); + if ( + Array.isArray(manifest.runtimeFiles) && + Array.isArray(manifest.sourceRuntime?.runtimeFiles) && + !sameJson(manifest.runtimeFiles, manifest.sourceRuntime.runtimeFiles) + ) { + errors.push( + 'Linux frame-copy build manifest runtimeFiles must exactly match sourceRuntime.runtimeFiles.' + ); + } + const expectedTotal = Array.isArray(manifest.runtimeFiles) + ? manifest.runtimeFiles.reduce( + (total, runtimeFile) => + total + + (isObject(runtimeFile) && + Number.isInteger(runtimeFile.size) && + runtimeFile.size > 0 + ? runtimeFile.size + : 0), + 0 + ) + : 0; + if (manifest.runtimeTotalBytes !== expectedTotal) { + errors.push( + `Linux frame-copy build manifest runtimeTotalBytes must equal ${expectedTotal}.` + ); + } + if ( + Array.isArray(manifest.runtimeFiles) && + typeof manifest.libmpvSoname === 'string' && + !manifest.runtimeFiles.some( + (runtimeFile) => runtimeFile.name === manifest.libmpvSoname + ) + ) { + errors.push( + 'Linux frame-copy build manifest runtimeFiles must include libmpvSoname.' + ); + } + return errors; +} + +function verifyBundledRuntimeFiles(nativeDir, manifest) { + const libDir = path.join(nativeDir, 'lib'); + let libDirStat; + try { + libDirStat = fs.lstatSync(libDir); + } catch { + throw new Error(`Missing bundled runtime directory: ${libDir}`); + } + if (!libDirStat.isDirectory() || libDirStat.isSymbolicLink()) { + throw new Error( + `Bundled runtime directory must be a regular directory: ${libDir}` + ); + } + + const declaredFiles = new Map( + manifest.runtimeFiles.map((runtimeFile) => [ + runtimeFile.name, + runtimeFile, + ]) + ); + for (const entry of fs.readdirSync(libDir, { withFileTypes: true })) { + if (!declaredFiles.has(entry.name)) { + throw new Error( + `Found undeclared bundled runtime file ${entry.name} in ${libDir}.` + ); + } + } + + for (const runtimeFile of manifest.runtimeFiles) { + const runtimePath = path.join(libDir, runtimeFile.name); + let stat; + try { + stat = fs.lstatSync(runtimePath); + } catch { + throw new Error(`Missing bundled runtime file: ${runtimePath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Bundled runtime file must be a materialized regular file: ${runtimePath}` + ); + } + try { + fs.accessSync(runtimePath, fs.constants.R_OK); + } catch { + throw new Error( + `Bundled runtime file must be readable: ${runtimePath}` + ); + } + const contents = fs.readFileSync(runtimePath); + if (contents.length !== runtimeFile.size) { + throw new Error( + `Bundled runtime file size mismatch for ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.length}.` + ); + } + const actualSha256 = crypto + .createHash('sha256') + .update(contents) + .digest('hex'); + if (actualSha256 !== runtimeFile.sha256) { + throw new Error( + `Bundled runtime file SHA-256 mismatch for ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.` + ); + } + } +} + +function writeManifest(nativeDir, manifest) { + const manifestPath = path.join(nativeDir, RUNTIME_MANIFEST); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { + mode: 0o644, + }); + return manifest; +} + +function removeLinuxRuntimeNotices(nativeDir) { + for (const relativePath of [ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + LICENSES_DIRECTORY, + 'notices', + ]) { + fs.rmSync(path.join(nativeDir, relativePath), { + recursive: true, + force: true, + }); + } +} + +function prepareBundledLinuxRuntimeNotices( + nativeDir, + sourceRuntime, + noticeSourceDir +) { + if (noticeSourceDir) { + const sourceErrors = validateLinuxRuntimeNotices( + noticeSourceDir, + sourceRuntime + ); + if (sourceErrors.length > 0) { + throw new Error( + [ + `Invalid Linux runtime notice source at ${noticeSourceDir}.`, + ...sourceErrors.map((error) => `- ${error}`), + ].join('\n') + ); + } + removeLinuxRuntimeNotices(nativeDir); + for (const relativePath of [ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + LICENSES_DIRECTORY, + ]) { + fs.cpSync( + path.join(noticeSourceDir, relativePath), + path.join(nativeDir, relativePath), + { + recursive: true, + force: true, + } + ); + } + } + + const packagedErrors = validateLinuxRuntimeNotices( + nativeDir, + sourceRuntime, + { allowUnrelatedFiles: true } + ); + if (packagedErrors.length > 0) { + throw new Error( + [ + `Invalid packaged Linux runtime notices at ${nativeDir}.`, + ...packagedErrors.map((error) => `- ${error}`), + ].join('\n') + ); + } +} + +function createPackagedManifest(buildManifest, profile, targetNames) { + const bundled = profile.runtimeMode === 'bundled'; + const runtimeFiles = bundled + ? buildManifest.runtimeFiles.map((runtimeFile) => ({ ...runtimeFile })) + : []; + return { + schemaVersion: 1, + origin: profile.manifestOrigin, + generatedAt: buildManifest.generatedAt, + platform: 'linux', + arch: 'x64', + profile: profile.name, + runtimeMode: profile.runtimeMode, + targets: [...new Set(targetNames)].sort(), + artifacts: { + addon: { + name: EMBEDDED_MPV_ADDON, + regularFile: true, + readable: true, + }, + frameReader: { + name: FRAME_COPY_READER, + regularFile: true, + readable: true, + }, + helper: { + name: FRAME_COPY_HELPER, + regularFile: true, + readable: true, + executable: true, + }, + }, + processIsolation: { + addonLoadsLibmpv: false, + readerLoadsLibmpv: false, + electronLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: buildManifest.nativeViewFallback, + libmpvSoname: buildManifest.libmpvSoname, + packageDependencies: + profile.runtimeMode === 'system' + ? { ...LINUX_SYSTEM_PACKAGE_DEPENDENCIES } + : {}, + runtimeFiles, + runtimeTotalBytes: runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ), + ...(bundled + ? { + runtimeDependencyClosure: { + entries: + buildManifest.sourceRuntime.runtimeDependencyClosure.entries.map( + (entry) => ({ + name: entry.name, + soname: entry.soname ?? null, + needed: [...entry.needed], + rpath: [...entry.rpath], + runpath: [...entry.runpath], + }) + ), + externalDependencies: [ + ...buildManifest.sourceRuntime + .runtimeDependencyClosure.externalDependencies, + ], + }, + externalSystemLibraries: + buildManifest.sourceRuntime.externalSystemLibraries.map( + (entry) => ({ ...entry }) + ), + sourceArchive: structuredClone(buildManifest.sourceArchive), + sourceRuntime: structuredClone(buildManifest.sourceRuntime), + } + : {}), + }; +} + +function prepareNativeViewOnlyLinuxArtifacts(nativeDir) { + for (const fileName of [ + FRAME_COPY_HELPER, + WINDOWS_FRAME_COPY_HELPER, + FRAME_COPY_READER, + UNAVAILABLE_MARKER, + ]) { + fs.rmSync(path.join(nativeDir, fileName), { force: true }); + } + fs.rmSync(path.join(nativeDir, 'lib'), { recursive: true, force: true }); + removeLinuxRuntimeNotices(nativeDir); + + return writeManifest(nativeDir, { + schemaVersion: 1, + origin: 'external-mpv-process', + platform: 'linux', + arch: 'x64', + runtimeMode: 'native-view-only', + frameCopyAvailable: false, + artifacts: { + addon: EMBEDDED_MPV_ADDON, + }, + nativeViewFallback: 'process-isolated mpv --wid', + }); +} + +function prepareLinuxFrameCopyArtifacts(nativeDir, options = {}) { + if (!options.profile) { + return prepareNativeViewOnlyLinuxArtifacts(nativeDir); + } + const profile = resolveLinuxFrameCopyProfile(options.profile); + const targetNames = normalizeTargetNames(options.targetNames); + const targetErrors = validateLinuxProfileTargets(profile.name, targetNames); + if (targetErrors.length > 0) { + throw new Error(targetErrors.join('\n')); + } + + const addonPath = path.join(nativeDir, EMBEDDED_MPV_ADDON); + const readerPath = path.join(nativeDir, FRAME_COPY_READER); + const helperPath = path.join(nativeDir, FRAME_COPY_HELPER); + const manifestPath = path.join(nativeDir, RUNTIME_MANIFEST); + assertRegularReadableFile(addonPath, 'embedded MPV addon'); + assertRegularReadableFile(readerPath, 'embedded MPV frame reader'); + assertRegularReadableFile(helperPath, 'embedded MPV frame-copy helper'); + assertRegularReadableFile(manifestPath, 'embedded MPV runtime manifest'); + + const buildManifest = readJsonFile( + manifestPath, + 'embedded MPV runtime manifest' + ); + const manifestErrors = validateLinuxFrameCopyBuildManifest(buildManifest); + if (manifestErrors.length > 0) { + throw new Error( + ['Invalid Linux frame-copy build manifest.', ...manifestErrors] + .map((error) => `- ${error}`) + .join('\n') + ); + } + verifyBundledRuntimeFiles(nativeDir, buildManifest); + + fs.chmodSync(helperPath, 0o755); + fs.chmodSync(readerPath, 0o644); + fs.rmSync(path.join(nativeDir, WINDOWS_FRAME_COPY_HELPER), { force: true }); + fs.rmSync(path.join(nativeDir, UNAVAILABLE_MARKER), { force: true }); + if (profile.runtimeMode === 'system') { + fs.rmSync(path.join(nativeDir, 'lib'), { + recursive: true, + force: true, + }); + removeLinuxRuntimeNotices(nativeDir); + } else { + prepareBundledLinuxRuntimeNotices( + nativeDir, + buildManifest.sourceRuntime, + options.noticeSourceDir + ); + } + + return writeManifest( + nativeDir, + createPackagedManifest(buildManifest, profile, targetNames) + ); +} + +function preparePackagedFrameCopyArtifacts(nativeDir, platform, options = {}) { + if (platform === 'linux') { + return prepareLinuxFrameCopyArtifacts(nativeDir, options); } const helperPath = path.join( nativeDir, - platform === 'win32' - ? WINDOWS_FRAME_COPY_HELPER - : FRAME_COPY_HELPER + platform === 'win32' ? WINDOWS_FRAME_COPY_HELPER : FRAME_COPY_HELPER ); if (platform !== 'win32' && fs.existsSync(helperPath)) { // Asset copying drops POSIX modes; restore spawn permission. fs.chmodSync(helperPath, 0o755); } + return undefined; } function removeStaleFrameCopyArtifacts(nativeDir) { @@ -40,9 +568,11 @@ function removeStaleFrameCopyArtifacts(nativeDir) { ]) { fs.rmSync(path.join(nativeDir, fileName), { force: true }); } + removeLinuxRuntimeNotices(nativeDir); } module.exports = { preparePackagedFrameCopyArtifacts, removeStaleFrameCopyArtifacts, + validateLinuxFrameCopyBuildManifest, }; diff --git a/tools/packaging/embedded-mpv-packaging.cjs b/tools/packaging/embedded-mpv-packaging.cjs index a17234156..02e2a0711 100644 --- a/tools/packaging/embedded-mpv-packaging.cjs +++ b/tools/packaging/embedded-mpv-packaging.cjs @@ -1,6 +1,30 @@ +const crypto = require('node:crypto'); const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { isDeepStrictEqual } = require('node:util'); +const { + EXTERNAL_SYSTEM_LIBRARIES, + GLIBC_TOOLCHAIN_ALLOWLIST, + parseReadelfDynamic, + validateRuntimeDependencyClosure, +} = require('../embedded-mpv/build-linux-runtime.cjs'); +const { + validateLinuxRuntimeManifest, +} = require('../embedded-mpv/linux-runtime-manifest.cjs'); +const { + validateLinuxSourceArchiveBinding, +} = require('../embedded-mpv/linux-source-archive-contract.cjs'); +const { + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + validateLinuxRuntimeNotices, +} = require('../embedded-mpv/generate-linux-runtime-notices.cjs'); +const { + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +} = require('./linux-frame-copy-profile.cjs'); const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/']; const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/']; @@ -10,6 +34,39 @@ const windowsMpvRuntimeNames = [ 'mpv.dll', 'libmpv.dll', ]; +const linuxFrameCopyArtifacts = Object.freeze({ + addon: Object.freeze({ + name: 'embedded_mpv.node', + regularFile: true, + readable: true, + }), + frameReader: Object.freeze({ + name: 'embedded_mpv_frame_reader.node', + regularFile: true, + readable: true, + }), + helper: Object.freeze({ + name: 'iptvnator_mpv_helper', + regularFile: true, + readable: true, + executable: true, + }), +}); +const linuxFrameCopyProcessIsolation = Object.freeze({ + addonLoadsLibmpv: false, + readerLoadsLibmpv: false, + electronLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: Object.freeze(['$ORIGIN/lib']), +}); +const linuxNativeViewFallback = 'process-isolated mpv --wid'; +const versionedLinuxLibmpvPattern = /^libmpv\.so\.\d+(?:\.\d+)*$/; +const anyLinuxLibmpvPattern = /^libmpv\.so(?:\.|$)/; +const linuxRuntimeLegalPaths = Object.freeze([ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + 'licenses', +]); function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -638,31 +695,80 @@ const ELECTRON_BUILDER_ARCH_NAMES = [ function resolveElectronBuilderArchName(arch) { if (typeof arch === 'string') { - return arch; + return ELECTRON_BUILDER_ARCH_NAMES.includes(arch) ? arch : null; } return ELECTRON_BUILDER_ARCH_NAMES[arch] ?? null; } -function getEmbeddedMpvAddonArch(env = process.env) { - return env.IPTVNATOR_EMBEDDED_MPV_ARCH || process.arch; +function resolveConfiguredLinuxTargetNames(configuredTargets, targetArch) { + if (!Array.isArray(configuredTargets)) { + throw new TypeError('Electron Builder linux.target must be an array.'); + } + const archName = resolveElectronBuilderArchName(targetArch); + if (!archName) { + throw new Error( + `Unknown Electron Builder architecture: ${String(targetArch)}.` + ); + } + + const targetNames = new Set(); + for (const target of configuredTargets) { + const targetName = + typeof target === 'string' + ? target + : target && typeof target === 'object' + ? target.target + : null; + if (typeof targetName !== 'string' || targetName.trim() === '') { + throw new Error( + 'Electron Builder Linux targets must have a non-empty target name.' + ); + } + + if (target && typeof target === 'object' && target.arch !== undefined) { + const configuredArches = Array.isArray(target.arch) + ? target.arch + : [target.arch]; + const configuredArchNames = configuredArches.map( + (configuredArch) => { + const configuredArchName = + resolveElectronBuilderArchName(configuredArch); + if (!configuredArchName) { + throw new Error( + `Unknown Electron Builder architecture: ${String( + configuredArch + )}.` + ); + } + return configuredArchName; + } + ); + if (!configuredArchNames.includes(archName)) { + continue; + } + } + targetNames.add(targetName.trim().toLowerCase()); + } + + if (targetNames.size === 0) { + throw new Error( + `Electron Builder has no Linux targets configured for ${archName}.` + ); + } + return [...targetNames].sort(); } /** - * The embedded MPV addon is compiled once per CI host (x64 on Linux), but - * electron-builder also produces arm64/armv7l Linux packages from the same - * dist output. Those packages must not ship a foreign-architecture - * `embedded_mpv.node` — it can never load and produces a cryptic error. + * Official Linux frame-copy support is x64-only. electron-builder also + * produces arm64/armv7l packages, which must always carry only the + * unavailable marker and native-view fallback. */ -function isForeignLinuxEmbeddedMpvArch( - platform, - targetArch, - env = process.env -) { +function isForeignLinuxEmbeddedMpvArch(platform, targetArch) { if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') { return false; } const archName = resolveElectronBuilderArchName(targetArch); - return Boolean(archName) && archName !== getEmbeddedMpvAddonArch(env); + return Boolean(archName) && archName !== 'x64'; } // Maps electron-builder Linux output directory names (`linux-unpacked`, @@ -676,8 +782,1003 @@ function linuxUnpackedDirArch(unpackedDirName) { return match[1] ?? 'x64'; } +function pathExistsByLstat(filePath) { + try { + fs.lstatSync(filePath); + return true; + } catch { + return false; + } +} + +function inspectRegularReadableFile( + filePath, + label, + errors, + expectedMode = null +) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + errors.push(`Missing ${label}: ${filePath}`); + return null; + } + + if (!stat.isFile() || stat.isSymbolicLink()) { + errors.push(`${label} must be a regular file: ${filePath}`); + return null; + } + try { + fs.accessSync(filePath, fs.constants.R_OK); + } catch { + errors.push(`${label} must be readable: ${filePath}`); + } + + if (expectedMode !== null && (stat.mode & 0o7777) !== expectedMode) { + errors.push( + `${label} must have mode ${expectedMode + .toString(8) + .padStart(4, '0')}: ${filePath}` + ); + } + return stat; +} + +function readPackagedJson(filePath, label, errors) { + let contents; + try { + contents = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + errors.push( + `Unable to read ${label} at ${filePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + } + + try { + return JSON.parse(contents); + } catch (error) { + errors.push( + `Invalid JSON in ${label} at ${filePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + } +} + +function validateForeignLinuxNativeDir(nativeDir) { + const errors = []; + const markerName = 'embedded-mpv-unavailable.txt'; + const markerPath = path.join(nativeDir, markerName); + let nativeStat; + try { + nativeStat = fs.lstatSync(nativeDir); + } catch { + return [ + `Missing embedded MPV unavailable marker for foreign-architecture package: ${markerPath}`, + ]; + } + if (!nativeStat.isDirectory() || nativeStat.isSymbolicLink()) { + return [ + `Foreign-architecture embedded MPV native path must be a regular directory: ${nativeDir}`, + ]; + } + + const entries = fs.readdirSync(nativeDir).sort(); + const unexpectedEntries = entries.filter((name) => name !== markerName); + if (unexpectedEntries.length > 0) { + errors.push( + [ + 'Embedded MPV artifacts must not ship in foreign-architecture Linux packages; only the unavailable marker is allowed.', + ...unexpectedEntries.map( + (name) => `- ${path.join(nativeDir, name)}` + ), + ].join('\n') + ); + } + if (!entries.includes(markerName)) { + errors.push( + `Missing embedded MPV unavailable marker for foreign-architecture package: ${markerPath}` + ); + } else { + inspectRegularReadableFile( + markerPath, + 'embedded MPV unavailable marker', + errors + ); + } + return errors; +} + +function validateNativeViewOnlyLinuxPackage( + nativeDir, + addonPath, + manifestPath, + errors +) { + for (const legalPath of linuxRuntimeLegalPaths) { + const packagedLegalPath = path.join(nativeDir, legalPath); + if (pathExistsByLstat(packagedLegalPath)) { + errors.push( + `Linux native-view-only packages must not ship bundled runtime legal files: ${packagedLegalPath}` + ); + } + } + for (const artifactName of [ + 'iptvnator_mpv_helper', + 'iptvnator_mpv_helper.exe', + 'embedded_mpv_frame_reader.node', + ]) { + const artifactPath = path.join(nativeDir, artifactName); + if (pathExistsByLstat(artifactPath)) { + errors.push( + `Linux native-view-only packages must not ship frame-copy helpers or readers: ${artifactPath}` + ); + } + } + + const markerPath = path.join(nativeDir, 'embedded-mpv-unavailable.txt'); + if (pathExistsByLstat(markerPath)) { + errors.push( + `Same-architecture Linux packages must not retain the unavailable marker: ${markerPath}` + ); + } + + const libDir = path.join(nativeDir, 'lib'); + if (pathExistsByLstat(libDir)) { + errors.push( + `Linux native-view-only packages must not bundle libmpv or retain a private runtime directory: ${libDir}` + ); + } + + inspectRegularReadableFile(addonPath, 'embedded MPV native addon', errors); + if ( + !inspectRegularReadableFile( + manifestPath, + 'embedded MPV runtime manifest', + errors + ) + ) { + return; + } + const manifest = readPackagedJson( + manifestPath, + 'embedded MPV runtime manifest', + errors + ); + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + if (manifest !== null) { + errors.push('Embedded MPV runtime manifest must be an object.'); + } + return; + } + + const expectedFields = { + schemaVersion: 1, + origin: 'external-mpv-process', + platform: 'linux', + arch: 'x64', + runtimeMode: 'native-view-only', + frameCopyAvailable: false, + artifacts: { addon: 'embedded_mpv.node' }, + nativeViewFallback: linuxNativeViewFallback, + }; + for (const [field, expected] of Object.entries(expectedFields)) { + if (!isDeepStrictEqual(manifest[field], expected)) { + errors.push( + `Linux native-view-only manifest ${field} must equal ${JSON.stringify( + expected + )}; received ${JSON.stringify(manifest[field])}.` + ); + } + } + if (Object.hasOwn(manifest, 'profile')) { + errors.push( + 'Linux native-view-only manifest must not include a frame-copy profile.' + ); + } +} + +function normalizeLinuxTargetNames(targetNames, errors) { + if (!Array.isArray(targetNames) || targetNames.length === 0) { + errors.push( + 'Linux frame-copy package validation requires at least one target name.' + ); + return []; + } + const normalized = []; + for (const targetName of targetNames) { + const name = String(targetName ?? '') + .trim() + .toLowerCase(); + if (!name) { + errors.push( + 'Linux frame-copy target names must be non-empty strings.' + ); + continue; + } + if (normalized.includes(name)) { + errors.push(`Linux frame-copy target "${name}" is duplicated.`); + continue; + } + normalized.push(name); + } + return normalized.sort(); +} + +function validatePackagedManifestContract( + manifest, + profile, + targetNames, + errors +) { + const expectedFields = { + schemaVersion: 1, + origin: profile.manifestOrigin, + platform: 'linux', + arch: 'x64', + profile: profile.name, + runtimeMode: profile.runtimeMode, + targets: targetNames, + artifacts: linuxFrameCopyArtifacts, + processIsolation: linuxFrameCopyProcessIsolation, + nativeViewFallback: linuxNativeViewFallback, + }; + for (const [field, expected] of Object.entries(expectedFields)) { + if (!isDeepStrictEqual(manifest[field], expected)) { + errors.push( + `Linux frame-copy manifest ${field} for profile "${profile.name}" must equal ${JSON.stringify( + expected + )}; received ${JSON.stringify(manifest[field])}.` + ); + } + } + + if ( + typeof manifest.generatedAt !== 'string' || + manifest.generatedAt.trim() === '' || + Number.isNaN(Date.parse(manifest.generatedAt)) + ) { + errors.push( + 'Linux frame-copy manifest generatedAt must be a valid timestamp.' + ); + } + if ( + typeof manifest.libmpvSoname !== 'string' || + !versionedLinuxLibmpvPattern.test(manifest.libmpvSoname) + ) { + errors.push( + 'Linux frame-copy manifest libmpvSoname must be a versioned libmpv SONAME.' + ); + } +} + +function validateSystemLinuxRuntime(nativeDir, manifest, errors) { + for (const legalPath of linuxRuntimeLegalPaths) { + const packagedLegalPath = path.join(nativeDir, legalPath); + if (pathExistsByLstat(packagedLegalPath)) { + errors.push( + `Linux system frame-copy packages must not ship bundled runtime legal files: ${packagedLegalPath}` + ); + } + } + if ( + !isDeepStrictEqual( + manifest.packageDependencies, + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + ) + ) { + errors.push( + `Linux system frame-copy manifest packageDependencies must equal ${JSON.stringify( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )}.` + ); + } + if (!isDeepStrictEqual(manifest.runtimeFiles, [])) { + errors.push( + 'Linux system frame-copy manifest runtimeFiles must be empty.' + ); + } + if (manifest.runtimeTotalBytes !== 0) { + errors.push( + 'Linux system frame-copy manifest runtimeTotalBytes must equal 0.' + ); + } + for (const forbiddenField of [ + 'runtimeDependencyClosure', + 'externalSystemLibraries', + 'sourceArchive', + 'sourceRuntime', + ]) { + if (Object.hasOwn(manifest, forbiddenField)) { + errors.push( + `Linux system frame-copy manifest must not include ${forbiddenField}.` + ); + } + } + + const libDir = path.join(nativeDir, 'lib'); + if (pathExistsByLstat(libDir)) { + errors.push( + `Linux system frame-copy packages must not retain a private runtime directory: ${libDir}` + ); + } +} + +function sha256File(filePath) { + return crypto + .createHash('sha256') + .update(fs.readFileSync(filePath)) + .digest('hex'); +} + +function validateBundledLinuxRuntime(nativeDir, manifest, errors) { + if (!isDeepStrictEqual(manifest.packageDependencies, {})) { + errors.push( + 'Linux bundled frame-copy manifest packageDependencies must be empty.' + ); + } + + const sourceRuntimeErrors = validateLinuxRuntimeManifest( + manifest.sourceRuntime + ); + errors.push( + ...sourceRuntimeErrors.map( + (error) => `Invalid packaged Linux source runtime: ${error}` + ) + ); + errors.push( + ...validateLinuxSourceArchiveBinding(manifest.sourceArchive).map( + (error) => `Invalid packaged Linux source archive binding: ${error}` + ) + ); + errors.push( + ...validateLinuxRuntimeNotices(nativeDir, manifest.sourceRuntime, { + allowUnrelatedFiles: true, + }).map((error) => `Invalid packaged Linux runtime notices: ${error}`) + ); + if ( + !isDeepStrictEqual( + manifest.runtimeFiles, + manifest.sourceRuntime?.runtimeFiles + ) + ) { + errors.push( + 'Linux bundled frame-copy manifest runtimeFiles must exactly match sourceRuntime.runtimeFiles.' + ); + } + if ( + !isDeepStrictEqual( + manifest.runtimeDependencyClosure, + manifest.sourceRuntime?.runtimeDependencyClosure + ) + ) { + errors.push( + 'Linux bundled frame-copy manifest runtimeDependencyClosure must exactly match sourceRuntime.runtimeDependencyClosure.' + ); + } + if ( + !isDeepStrictEqual( + manifest.externalSystemLibraries, + manifest.sourceRuntime?.externalSystemLibraries + ) + ) { + errors.push( + 'Linux bundled frame-copy manifest externalSystemLibraries must exactly match sourceRuntime.externalSystemLibraries.' + ); + } + + if (!Array.isArray(manifest.runtimeFiles)) { + errors.push( + 'Linux bundled frame-copy manifest runtimeFiles must be an array.' + ); + return; + } + const expectedTotal = manifest.runtimeFiles.reduce( + (total, runtimeFile) => + total + + (runtimeFile && + Number.isSafeInteger(runtimeFile.size) && + runtimeFile.size > 0 + ? runtimeFile.size + : 0), + 0 + ); + if (manifest.runtimeTotalBytes !== expectedTotal) { + errors.push( + `Linux bundled frame-copy manifest runtimeTotalBytes must equal ${expectedTotal}.` + ); + } + + const libDir = path.join(nativeDir, 'lib'); + let libStat; + try { + libStat = fs.lstatSync(libDir); + } catch { + errors.push(`Missing bundled Linux runtime directory: ${libDir}`); + return; + } + if (!libStat.isDirectory() || libStat.isSymbolicLink()) { + errors.push( + `Bundled Linux runtime path must be a regular directory: ${libDir}` + ); + return; + } + + const declaredNames = new Set(); + for (const runtimeFile of manifest.runtimeFiles) { + if ( + !runtimeFile || + typeof runtimeFile.name !== 'string' || + path.basename(runtimeFile.name) !== runtimeFile.name || + runtimeFile.name === '.' || + runtimeFile.name === '..' + ) { + errors.push( + `Bundled Linux runtime manifest contains an unsafe file name: ${JSON.stringify( + runtimeFile?.name + )}.` + ); + continue; + } + if (declaredNames.has(runtimeFile.name)) { + errors.push( + `Bundled Linux runtime manifest contains duplicate file ${runtimeFile.name}.` + ); + continue; + } + declaredNames.add(runtimeFile.name); + } + + let packagedEntries; + try { + packagedEntries = fs.readdirSync(libDir).sort(); + } catch (error) { + errors.push( + `Unable to enumerate bundled Linux runtime at ${libDir}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return; + } + for (const entryName of packagedEntries) { + if (!declaredNames.has(entryName)) { + errors.push( + `Found undeclared bundled Linux runtime artifact: ${path.join( + libDir, + entryName + )}` + ); + } + } + + for (const runtimeFile of manifest.runtimeFiles) { + if (!runtimeFile || !declaredNames.has(runtimeFile.name)) { + continue; + } + const runtimePath = path.join(libDir, runtimeFile.name); + const stat = inspectRegularReadableFile( + runtimePath, + `bundled Linux runtime file ${runtimeFile.name}`, + errors + ); + if (!stat) { + continue; + } + if (stat.size !== runtimeFile.size) { + errors.push( + `Bundled Linux runtime size mismatch for ${runtimeFile.name}: expected ${runtimeFile.size}, received ${stat.size}.` + ); + } + let actualSha256; + try { + actualSha256 = sha256File(runtimePath); + } catch (error) { + errors.push( + `Unable to hash bundled Linux runtime file ${runtimePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + continue; + } + if (actualSha256 !== runtimeFile.sha256) { + errors.push( + `Bundled Linux runtime SHA-256 mismatch for ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.` + ); + } + } +} + +function normalizeElfInspection(value, binaryPath) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error( + `ELF inspection for ${binaryPath} must return an object.` + ); + } + const result = { + soname: value.soname ?? null, + }; + if ( + result.soname !== null && + (typeof result.soname !== 'string' || + path.basename(result.soname) !== result.soname) + ) { + throw new Error( + `ELF inspection for ${binaryPath} must return a safe SONAME or null.` + ); + } + for (const field of ['needed', 'rpath', 'runpath']) { + if ( + !Array.isArray(value[field]) || + value[field].some((entry) => typeof entry !== 'string') + ) { + throw new Error( + `ELF inspection for ${binaryPath} must return string array ${field}.` + ); + } + result[field] = [...new Set(value[field])].sort(); + } + return result; +} + +function dependencyFileName(dependencyName) { + return dependencyName.replaceAll('\\', '/').split('/').at(-1) ?? ''; +} + +function listElectronShippedLinuxLibraries(resourceDir, options = {}) { + const appDir = path.dirname(resourceDir); + const normalizedResourceDir = path.resolve(resourceDir); + const excludedSnapLibraryRoots = + options.artifactFormat === 'snap' + ? new Set( + [ + path.join(appDir, 'lib'), + path.join(appDir, 'usr', 'lib'), + ].map((directoryPath) => path.resolve(directoryPath)) + ) + : new Set(); + const libraries = []; + + function visit(directoryPath) { + for (const entry of fs.readdirSync(directoryPath, { + withFileTypes: true, + })) { + const entryPath = path.join(directoryPath, entry.name); + if (path.resolve(entryPath) === normalizedResourceDir) { + continue; + } + if (entry.isDirectory()) { + if (excludedSnapLibraryRoots.has(path.resolve(entryPath))) { + continue; + } + visit(entryPath); + continue; + } + if ( + (entry.isFile() || entry.isSymbolicLink()) && + /\.so(?:\.\d+)*$/.test(entry.name) + ) { + libraries.push(entryPath); + } + } + } + + // afterPack and unpacked-layout checks see the pristine Electron tree, so + // recurse to catch future nested Electron libraries. An extracted Snap + // overlays package-manager lib/ and usr/lib/ trees onto that same root; + // exclude exactly those target-provided roots while scanning everything + // else recursively. + visit(appDir); + return libraries.sort(); +} + +function inspectLinuxElfIsolation( + resourceDir, + nativeDir, + manifest, + options, + errors +) { + const hostPlatform = options.hostPlatform ?? process.platform; + let inspectElf = options.elfInspector; + if (!inspectElf) { + if (hostPlatform !== 'linux') { + return; + } + if (!commandExists('readelf')) { + errors.push( + 'readelf is required to validate Linux embedded MPV packaging.' + ); + return; + } + inspectElf = (binaryPath) => + parseReadelfDynamic(run('readelf', ['-d', binaryPath])); + } + if (typeof inspectElf !== 'function') { + errors.push('Linux ELF inspector must be a function.'); + return; + } + + const executableName = options.executableName ?? 'iptvnator'; + const inspectedPaths = { + electron: path.join(path.dirname(resourceDir), `${executableName}.bin`), + addon: path.join(nativeDir, linuxFrameCopyArtifacts.addon.name), + reader: path.join(nativeDir, linuxFrameCopyArtifacts.frameReader.name), + helper: path.join(nativeDir, linuxFrameCopyArtifacts.helper.name), + }; + for (const [index, libraryPath] of listElectronShippedLinuxLibraries( + resourceDir, + { artifactFormat: options.artifactFormat } + ).entries()) { + inspectedPaths[`electronLibrary:${index}`] = libraryPath; + } + const inspections = {}; + for (const [label, binaryPath] of Object.entries(inspectedPaths)) { + if ( + !inspectRegularReadableFile( + binaryPath, + `Linux ${label} ELF binary`, + errors + ) + ) { + continue; + } + try { + inspections[label] = normalizeElfInspection( + inspectElf(binaryPath), + binaryPath + ); + } catch (error) { + errors.push( + `Unable to inspect Linux ${label} ELF binary at ${binaryPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + for (const label of Object.keys(inspections).filter( + (name) => + name === 'electron' || + name === 'addon' || + name === 'reader' || + name.startsWith('electronLibrary:') + )) { + const dynamic = inspections[label]; + if (!dynamic) { + continue; + } + const displayLabel = label.startsWith('electronLibrary:') + ? 'Electron library' + : label; + for (const dependencyName of dynamic.needed) { + if (dependencyFileName(dependencyName) !== dependencyName) { + errors.push( + `Linux ${displayLabel} DT_NEEDED entry must not contain a path: ${dependencyName} in ${inspectedPaths[label]}.` + ); + } + } + const libmpvDependencies = dynamic.needed.filter((dependencyName) => + anyLinuxLibmpvPattern.test(dependencyFileName(dependencyName)) + ); + if (libmpvDependencies.length > 0) { + errors.push( + `Linux ${displayLabel} must not link libmpv; found ${libmpvDependencies.join( + ', ' + )} in ${inspectedPaths[label]}.` + ); + } + } + + const helper = inspections.helper; + if (helper) { + if (!helper.needed.includes(manifest.libmpvSoname)) { + errors.push( + `Linux frame-copy helper must directly need ${manifest.libmpvSoname}.` + ); + } + const unexpectedLibmpvDependencies = helper.needed.filter( + (dependencyName) => + anyLinuxLibmpvPattern.test( + dependencyFileName(dependencyName) + ) && dependencyName !== manifest.libmpvSoname + ); + if (unexpectedLibmpvDependencies.length > 0) { + errors.push( + `Linux frame-copy helper must not need a different libmpv SONAME: ${unexpectedLibmpvDependencies.join( + ', ' + )}.` + ); + } + if (helper.rpath.length > 0) { + errors.push( + `Linux frame-copy helper must not contain RPATH; found ${helper.rpath.join( + ':' + )}.` + ); + } + if ( + helper.runpath.length !== 1 || + helper.runpath[0] !== '$ORIGIN/lib' + ) { + errors.push( + `Linux frame-copy helper RUNPATH must be exactly $ORIGIN/lib; received ${ + helper.runpath.length > 0 + ? helper.runpath.join(':') + : '' + }.` + ); + } + + const allowedHelperDependencies = new Set([ + manifest.libmpvSoname, + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), + ...(manifest.runtimeMode === 'bundled' && + Array.isArray(manifest.runtimeFiles) + ? manifest.runtimeFiles.map(({ name }) => name) + : []), + ...(manifest.runtimeMode === 'bundled' && + Array.isArray( + manifest.runtimeDependencyClosure?.externalDependencies + ) + ? manifest.runtimeDependencyClosure.externalDependencies + : []), + ]); + for (const dependencyName of helper.needed) { + if ( + dependencyFileName(dependencyName) !== dependencyName || + !allowedHelperDependencies.has(dependencyName) + ) { + errors.push( + `Linux frame-copy helper dependency is not bundled or allowlisted: ${dependencyName}.` + ); + } + } + } + + if ( + manifest.runtimeMode !== 'bundled' || + !Array.isArray(manifest.runtimeFiles) + ) { + return; + } + + const runtimeFileNames = manifest.runtimeFiles + .map((runtimeFile) => runtimeFile?.name) + .filter((name) => typeof name === 'string'); + const runtimeNameSet = new Set(runtimeFileNames); + const allowedExternalNames = new Set([ + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), + ]); + const closureEntries = []; + let closureHasErrors = false; + for (const runtimeFileName of runtimeFileNames) { + const runtimePath = path.join(nativeDir, 'lib', runtimeFileName); + let dynamic; + try { + dynamic = normalizeElfInspection( + inspectElf(runtimePath), + runtimePath + ); + } catch (error) { + closureHasErrors = true; + errors.push( + `Unable to inspect bundled Linux runtime ELF at ${runtimePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + continue; + } + closureEntries.push({ + name: runtimeFileName, + soname: dynamic.soname, + needed: dynamic.needed, + rpath: dynamic.rpath, + runpath: dynamic.runpath, + }); + if (dynamic.rpath.length > 0) { + closureHasErrors = true; + errors.push( + `${runtimeFileName} has forbidden RPATH ${dynamic.rpath.join( + ':' + )}.` + ); + } + if (dynamic.runpath.length !== 1 || dynamic.runpath[0] !== '$ORIGIN') { + closureHasErrors = true; + errors.push( + `${runtimeFileName} RUNPATH must be exactly $ORIGIN; got ${ + dynamic.runpath.length > 0 + ? dynamic.runpath.join(':') + : '' + }.` + ); + } + for (const dependencyName of dynamic.needed) { + if ( + !runtimeNameSet.has(dependencyName) && + !allowedExternalNames.has(dependencyName) + ) { + closureHasErrors = true; + errors.push( + `Runtime dependency is not bundled or allowlisted: ${runtimeFileName} -> ${dependencyName}.` + ); + } + } + } + + if (closureHasErrors) { + return; + } + try { + const actualClosure = validateRuntimeDependencyClosure({ + entries: closureEntries, + runtimeFileNames, + buildPrefix: '', + }); + if ( + !isDeepStrictEqual(actualClosure, manifest.runtimeDependencyClosure) + ) { + errors.push( + 'Actual bundled Linux ELF dependency closure does not match the packaged manifest.' + ); + } + } catch (error) { + errors.push( + `Invalid bundled Linux ELF dependency closure: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +function validateLinuxPackagedEmbeddedMpv(resourceDir, options) { + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + if (options.foreignArch) { + return validateForeignLinuxNativeDir(nativeDir); + } + + const errors = []; + const addonPath = path.join(nativeDir, 'embedded_mpv.node'); + const manifestPath = path.join(nativeDir, 'embedded-mpv-runtime.json'); + if (!pathExistsByLstat(addonPath)) { + if (options.required) { + errors.push(`Missing embedded MPV native addon: ${addonPath}`); + } + if (pathExistsByLstat(nativeDir)) { + for (const staleName of [ + 'embedded_mpv_frame_reader.node', + 'iptvnator_mpv_helper', + 'iptvnator_mpv_helper.exe', + 'embedded-mpv-runtime.json', + 'embedded-mpv-unavailable.txt', + 'lib', + ]) { + const stalePath = path.join(nativeDir, staleName); + if (pathExistsByLstat(stalePath)) { + errors.push( + `Embedded MPV addon is missing but stale packaged artifact remains: ${stalePath}` + ); + } + } + } + return errors; + } + + if (!options.profile) { + if (options.required) { + errors.push( + 'Linux frame-copy profile is required for a required same-architecture package.' + ); + } + validateNativeViewOnlyLinuxPackage( + nativeDir, + addonPath, + manifestPath, + errors + ); + return errors; + } + + let profile; + try { + profile = resolveLinuxFrameCopyProfile(options.profile); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + return errors; + } + const targetNames = normalizeLinuxTargetNames(options.targetNames, errors); + if (targetNames.length > 0) { + try { + errors.push( + ...validateLinuxProfileTargets(profile.name, targetNames) + ); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + + const readerPath = path.join( + nativeDir, + linuxFrameCopyArtifacts.frameReader.name + ); + const helperPath = path.join( + nativeDir, + linuxFrameCopyArtifacts.helper.name + ); + inspectRegularReadableFile(addonPath, 'embedded MPV native addon', errors); + inspectRegularReadableFile( + readerPath, + 'embedded MPV frame reader', + errors, + 0o644 + ); + inspectRegularReadableFile( + helperPath, + 'embedded MPV frame-copy helper', + errors, + 0o755 + ); + const staleWindowsHelper = path.join(nativeDir, 'iptvnator_mpv_helper.exe'); + if (pathExistsByLstat(staleWindowsHelper)) { + errors.push( + `Linux frame-copy package must not retain the Windows helper: ${staleWindowsHelper}` + ); + } + const staleMarker = path.join(nativeDir, 'embedded-mpv-unavailable.txt'); + if (pathExistsByLstat(staleMarker)) { + errors.push( + `Same-architecture Linux package must not retain the unavailable marker: ${staleMarker}` + ); + } + + if ( + !inspectRegularReadableFile( + manifestPath, + 'embedded MPV runtime manifest', + errors + ) + ) { + return errors; + } + const manifest = readPackagedJson( + manifestPath, + 'embedded MPV runtime manifest', + errors + ); + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + if (manifest !== null) { + errors.push('Embedded MPV runtime manifest must be an object.'); + } + return errors; + } + + validatePackagedManifestContract(manifest, profile, targetNames, errors); + if (profile.runtimeMode === 'system') { + validateSystemLinuxRuntime(nativeDir, manifest, errors); + } else { + validateBundledLinuxRuntime(nativeDir, manifest, errors); + } + inspectLinuxElfIsolation(resourceDir, nativeDir, manifest, options, errors); + return errors; +} + function validatePackagedEmbeddedMpv(resourceDir, options = {}) { const platform = normalizeEmbeddedMpvPlatform(options.platform); + if (platform === 'linux') { + return validateLinuxPackagedEmbeddedMpv(resourceDir, options); + } const unpackedNativeDir = path.join( resourceDir, 'app.asar.unpacked', @@ -692,24 +1793,6 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) { ); const errors = []; - if (options.foreignArch) { - if (fs.existsSync(addonPath)) { - errors.push( - `Embedded MPV addon must not ship in foreign-architecture Linux packages: ${addonPath}` - ); - } - const markerPath = path.join( - unpackedNativeDir, - 'embedded-mpv-unavailable.txt' - ); - if (!fs.existsSync(markerPath)) { - errors.push( - `Missing embedded MPV unavailable marker for foreign-architecture package: ${markerPath}` - ); - } - return errors; - } - if (!fs.existsSync(addonPath)) { if (options.required) { errors.push(`Missing embedded MPV native addon: ${addonPath}`); @@ -721,8 +1804,7 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) { // The frame-copy engine artifacts are built by the same binding.gyp // run as the addon; a macOS/Windows package that ships the addon // without them would silently lose the engine (support probe hides - // it). Linux packages intentionally strip the helper until the - // bundled-libmpv runtime lands (see electron-after-pack.cjs). + // it). const missingFrameCopyArtifacts = [ platform === 'win32' ? 'iptvnator_mpv_helper.exe' @@ -814,8 +1896,7 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) { errors.push(`Missing embedded MPV runtime manifest: ${manifestPath}`); } else { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - const expectedOrigin = - platform === 'linux' ? 'external-mpv-process' : 'vendored-lgpl'; + const expectedOrigin = 'vendored-lgpl'; if (manifest.origin !== expectedOrigin) { errors.push( `Embedded MPV packaged runtime must be ${expectedOrigin}, received: ${manifest.origin}` @@ -823,40 +1904,6 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) { } } - if (platform === 'linux') { - const packagedFrameCopyHelpers = [ - path.join(unpackedNativeDir, 'iptvnator_mpv_helper'), - path.join(unpackedNativeDir, 'iptvnator_mpv_helper.exe'), - ].filter((candidate) => fs.existsSync(candidate)); - if (packagedFrameCopyHelpers.length > 0) { - errors.push( - [ - 'Linux packages must not ship frame-copy helpers linked against the build host system libmpv.', - 'Remove:', - ...packagedFrameCopyHelpers.map( - (candidate) => `- ${candidate}` - ), - ].join('\n') - ); - } - - const bundledLinuxRuntime = [ - path.join(libDir, 'libmpv.so.2'), - path.join(libDir, 'libmpv.so.1'), - path.join(libDir, 'libmpv.so'), - ].filter((candidate) => fs.existsSync(candidate)); - - if (bundledLinuxRuntime.length > 0) { - errors.push( - [ - 'Linux embedded MPV must use the external mpv process backend and must not bundle libmpv.', - 'Remove:', - ...bundledLinuxRuntime.map((candidate) => `- ${candidate}`), - ].join('\n') - ); - } - } - const runtimeCandidates = getPackagedRuntimeCandidates( libDir, platform, @@ -898,6 +1945,7 @@ module.exports = { commandExists, copyRuntimeToNativeBuild, findLibMpv, + listElectronShippedLinuxLibraries, listRuntimeFiles, listDylibs, parseOtoolDependencies, @@ -906,8 +1954,8 @@ module.exports = { validateNoForbiddenRuntimeLinks, getPackagedRuntimeCandidates, validatePackagedEmbeddedMpv, - getEmbeddedMpvAddonArch, isForeignLinuxEmbeddedMpvArch, linuxUnpackedDirArch, + resolveConfiguredLinuxTargetNames, resolveElectronBuilderArchName, }; diff --git a/tools/packaging/linux-frame-copy-profile.cjs b/tools/packaging/linux-frame-copy-profile.cjs new file mode 100644 index 000000000..0276e94c6 --- /dev/null +++ b/tools/packaging/linux-frame-copy-profile.cjs @@ -0,0 +1,100 @@ +'use strict'; + +function createProfile(name, runtimeMode, targets, manifestOrigin) { + return Object.freeze({ + name, + runtimeMode, + targets: Object.freeze([...targets]), + manifestOrigin, + }); +} + +const LINUX_FRAME_COPY_PROFILES = Object.freeze({ + system: createProfile( + 'system', + 'system', + ['deb', 'rpm', 'pacman'], + 'system-libmpv-frame-copy' + ), + portable: createProfile( + 'portable', + 'bundled', + ['appimage', 'snap'], + 'bundled-lgpl-frame-copy' + ), + flatpak: createProfile( + 'flatpak', + 'bundled', + ['flatpak'], + 'bundled-lgpl-frame-copy' + ), +}); +const SUPPORTED_PROFILE_NAMES = Object.freeze( + Object.keys(LINUX_FRAME_COPY_PROFILES) +); + +const LINUX_SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({ + deb: Object.freeze(['libmpv2', 'libegl1', 'libgl1', 'libgbm1']), + rpm: Object.freeze([ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-glx', + 'mesa-libgbm', + ]), + pacman: Object.freeze(['mpv', 'libglvnd', 'mesa']), +}); + +function expectedProfileNames() { + return SUPPORTED_PROFILE_NAMES.map((name) => `"${name}"`).join(', '); +} + +function findLinuxFrameCopyProfile(value) { + if (value == null || (typeof value === 'string' && value.trim() === '')) { + throw new Error( + `Linux frame-copy profile is required. Expected one of: ${expectedProfileNames()}.` + ); + } + + const name = typeof value === 'string' ? value.trim() : String(value); + if (!Object.hasOwn(LINUX_FRAME_COPY_PROFILES, name)) { + throw new Error( + `Unsupported Linux frame-copy profile "${name}". Expected one of: ${expectedProfileNames()}.` + ); + } + return LINUX_FRAME_COPY_PROFILES[name]; +} + +function resolveLinuxFrameCopyProfile(value) { + const profile = findLinuxFrameCopyProfile(value); + return createProfile( + profile.name, + profile.runtimeMode, + profile.targets, + profile.manifestOrigin + ); +} + +function validateLinuxProfileTargets(profileName, targetNames) { + const profile = findLinuxFrameCopyProfile(profileName); + if (!Array.isArray(targetNames)) { + throw new TypeError('Linux frame-copy targets must be an array.'); + } + + const allowedTargets = new Set(profile.targets); + return targetNames.flatMap((targetName) => { + const normalizedTarget = String(targetName).trim().toLowerCase(); + if (allowedTargets.has(normalizedTarget)) { + return []; + } + return [ + `Linux frame-copy profile "${profile.name}" cannot build target "${normalizedTarget}".`, + ]; + }); +} + +module.exports = { + LINUX_FRAME_COPY_PROFILES, + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +}; diff --git a/tools/packaging/linux-frame-copy-profile.test.mjs b/tools/packaging/linux-frame-copy-profile.test.mjs new file mode 100644 index 000000000..8d3f858e0 --- /dev/null +++ b/tools/packaging/linux-frame-copy-profile.test.mjs @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { configureLinuxFrameCopyBuild } from './configure-linux-frame-copy-build.mjs'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const { + LINUX_FRAME_COPY_PROFILES, + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +} = require('./linux-frame-copy-profile.cjs'); +const electronBuilderConfig = JSON.parse( + fs.readFileSync( + join(currentDir, '..', '..', 'electron-builder.json'), + 'utf8' + ) +); + +function hasSystemFrameCopyDependency(config, format, dependency) { + return (config[format]?.fpm ?? []).some((option) => + new RegExp(`^--depends(?:=|\\s+)${dependency}(?:$|\\s|[<>=])`).test( + option + ) + ); +} + +test('defines the exact immutable Linux frame-copy profile matrix', () => { + assert.deepEqual(LINUX_FRAME_COPY_PROFILES, { + system: { + name: 'system', + runtimeMode: 'system', + targets: ['deb', 'rpm', 'pacman'], + manifestOrigin: 'system-libmpv-frame-copy', + }, + portable: { + name: 'portable', + runtimeMode: 'bundled', + targets: ['appimage', 'snap'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }, + flatpak: { + name: 'flatpak', + runtimeMode: 'bundled', + targets: ['flatpak'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }, + }); + assert.equal(Object.isFrozen(LINUX_FRAME_COPY_PROFILES), true); + for (const profile of Object.values(LINUX_FRAME_COPY_PROFILES)) { + assert.equal(Object.isFrozen(profile), true); + assert.equal(Object.isFrozen(profile.targets), true); + } +}); + +test('defines immutable system-package helper runtime dependencies', () => { + assert.deepEqual(LINUX_SYSTEM_PACKAGE_DEPENDENCIES, { + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-glx', 'mesa-libgbm'], + pacman: ['mpv', 'libglvnd', 'mesa'], + }); + assert.equal(Object.isFrozen(LINUX_SYSTEM_PACKAGE_DEPENDENCIES), true); + for (const dependencies of Object.values( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )) { + assert.equal(Object.isFrozen(dependencies), true); + } +}); + +test('resolves each supported profile as an immutable defensive value', () => { + const firstSystemProfile = resolveLinuxFrameCopyProfile('system'); + const secondSystemProfile = resolveLinuxFrameCopyProfile('system'); + + assert.deepEqual(firstSystemProfile, { + name: 'system', + runtimeMode: 'system', + targets: ['deb', 'rpm', 'pacman'], + manifestOrigin: 'system-libmpv-frame-copy', + }); + assert.deepEqual(resolveLinuxFrameCopyProfile('portable'), { + name: 'portable', + runtimeMode: 'bundled', + targets: ['appimage', 'snap'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }); + assert.deepEqual(resolveLinuxFrameCopyProfile('flatpak'), { + name: 'flatpak', + runtimeMode: 'bundled', + targets: ['flatpak'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }); + assert.notEqual(firstSystemProfile, LINUX_FRAME_COPY_PROFILES.system); + assert.notEqual(firstSystemProfile, secondSystemProfile); + assert.notEqual( + firstSystemProfile.targets, + LINUX_FRAME_COPY_PROFILES.system.targets + ); + assert.notEqual(firstSystemProfile.targets, secondSystemProfile.targets); + assert.equal(Object.isFrozen(firstSystemProfile), true); + assert.equal(Object.isFrozen(firstSystemProfile.targets), true); + assert.throws(() => firstSystemProfile.targets.push('appimage'), TypeError); + assert.deepEqual(resolveLinuxFrameCopyProfile('system').targets, [ + 'deb', + 'rpm', + 'pacman', + ]); +}); + +test('rejects missing and unsupported profile names with clear errors', () => { + for (const value of [undefined, null, '', ' ']) { + assert.throws( + () => resolveLinuxFrameCopyProfile(value), + /Linux frame-copy profile is required/ + ); + } + assert.throws( + () => resolveLinuxFrameCopyProfile('standard'), + /Unsupported Linux frame-copy profile "standard"/ + ); +}); + +test('rejects inherited object property names as unsupported profiles', () => { + for (const value of ['constructor', 'toString', '__proto__']) { + const unsupportedProfileError = new RegExp( + `Unsupported Linux frame-copy profile "${value}"` + ); + + assert.throws( + () => resolveLinuxFrameCopyProfile(value), + unsupportedProfileError + ); + assert.throws( + () => validateLinuxProfileTargets(value, ['deb']), + unsupportedProfileError + ); + } +}); + +test('validates profile targets case-insensitively with deterministic errors', () => { + const targets = ['DEB', 'AppImage', 'RPM']; + + assert.deepEqual(validateLinuxProfileTargets('system', targets), [ + 'Linux frame-copy profile "system" cannot build target "appimage".', + ]); + assert.deepEqual(targets, ['DEB', 'AppImage', 'RPM']); + assert.deepEqual( + validateLinuxProfileTargets('portable', ['APPIMAGE', 'sNaP']), + [] + ); + assert.deepEqual(validateLinuxProfileTargets('flatpak', ['FlatPak']), []); +}); + +test('rejects a non-array profile target list', () => { + assert.throws( + () => validateLinuxProfileTargets('system', 'deb'), + /Linux frame-copy targets must be an array/ + ); +}); + +test('keeps frame-copy package dependencies out of the base Electron Builder config', () => { + for (const [target, dependencies] of Object.entries( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )) { + assert.equal( + electronBuilderConfig[target]?.depends, + undefined, + `${target}.depends must remain unset so electron-builder keeps its defaults` + ); + for (const dependency of dependencies) { + assert.equal( + hasSystemFrameCopyDependency( + electronBuilderConfig, + target, + dependency + ), + false + ); + } + } +}); + +test('keeps frame-copy package dependencies out of non-system passes', () => { + const configs = [ + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'portable', + }), + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'flatpak', + }), + configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + }), + ]; + + for (const config of configs) { + for (const [format, dependencies] of Object.entries( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )) { + for (const dependency of dependencies) { + assert.equal( + hasSystemFrameCopyDependency(config, format, dependency), + false + ); + } + } + } +}); diff --git a/tools/packaging/prepare-linux-runtime-source-snapshot.cjs b/tools/packaging/prepare-linux-runtime-source-snapshot.cjs new file mode 100644 index 000000000..bdf6b9e3c --- /dev/null +++ b/tools/packaging/prepare-linux-runtime-source-snapshot.cjs @@ -0,0 +1,753 @@ +#!/usr/bin/env node + +'use strict'; + +const childProcess = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { + canonicalizeGitSubmoduleStatus, +} = require('../embedded-mpv/build-linux-runtime.cjs'); + +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT = Object.freeze({ + schemaVersion: 1, + hashAlgorithm: 'sha256', + canonicalEncoding: 'utf8-json-line-v1', +}); + +// Derived from a clean recursive checkout of libplacebo v7.360.1 at +// cee9b076f2c63104ccfd497fa79c39a867293ec4 with every recorded submodule at +// its pinned commit. The inventory contract above excludes all .git entries. +// Derivation: git clone --branch v7.360.1 --single-branch --recurse-submodules +// https://github.com/haasn/libplacebo.git, then globally sort and inventory the +// VCS-free copy under LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT. +// The trusted snapshot has 1,456 entries and 54,312,340 regular-file bytes. +const EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256 = + '0db67c1523411255244186af437e9fbfe7ccac04a5ac1b3dc9275dd0806f6f0c'; + +function gitOutput(checkoutPath, ...args) { + try { + return childProcess + .execFileSync('git', ['-C', checkoutPath, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + .trim(); + } catch (error) { + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String(error.stderr).trim() + : ''; + throw new Error( + `Unable to inspect source checkout with git ${args.join(' ')}${stderr ? `: ${stderr}` : '.'}` + ); + } +} + +function assertExpectedGitRecord(expected) { + if ( + expected === null || + typeof expected !== 'object' || + typeof expected.sourceGitCommit !== 'string' || + !GIT_COMMIT_PATTERN.test(expected.sourceGitCommit) || + !Array.isArray(expected.sourceSubmodules) || + expected.sourceSubmodules.some( + (record) => typeof record !== 'string' || record.length === 0 + ) + ) { + throw new Error( + 'Expected source identity must contain one commit and a submodule record array.' + ); + } +} + +function assertCleanCheckout(checkoutPath, label) { + const status = gitOutput( + checkoutPath, + 'status', + '--porcelain=v1', + '--untracked-files=all', + '--ignore-submodules=none' + ); + if (status) { + throw new Error(`${label} checkout contains dirty or untracked files.`); + } +} + +function sourceSubmoduleIdentity(record) { + const match = record.match(/^([a-f0-9]{40,64})\s+([A-Za-z0-9_+./-]+)$/); + if (!match) { + throw new Error(`Invalid source submodule record: ${record}`); + } + const submodulePath = match[2]; + if ( + path.isAbsolute(submodulePath) || + submodulePath + .split('/') + .some((part) => part === '' || part === '.' || part === '..') + ) { + throw new Error(`Unsafe source submodule path: ${submodulePath}`); + } + return { + commit: match[1], + path: submodulePath, + }; +} + +function inspectCleanGitSource(checkoutPath, expected) { + assertExpectedGitRecord(expected); + const sourceGitCommit = gitOutput(checkoutPath, 'rev-parse', 'HEAD'); + if (sourceGitCommit !== expected.sourceGitCommit) { + throw new Error( + 'Source checkout commit does not match the runtime manifest.' + ); + } + const submoduleOutput = gitOutput( + checkoutPath, + 'submodule', + 'status', + '--recursive' + ); + const sourceSubmodules = canonicalizeGitSubmoduleStatus(submoduleOutput); + if (!isDeepStrictEqual(sourceSubmodules, expected.sourceSubmodules)) { + throw new Error( + 'Source checkout submodules do not match the runtime manifest.' + ); + } + + assertCleanCheckout(checkoutPath, 'Source'); + for (const submoduleRecord of sourceSubmodules) { + const submodule = sourceSubmoduleIdentity(submoduleRecord); + const submoduleCheckout = path.join( + checkoutPath, + ...submodule.path.split('/') + ); + if ( + gitOutput(submoduleCheckout, 'rev-parse', 'HEAD') !== + submodule.commit + ) { + throw new Error( + `Source submodule ${submodule.path} commit does not match its recorded identity.` + ); + } + assertCleanCheckout( + submoduleCheckout, + `Source submodule ${submodule.path}` + ); + } + return { + sourceGitCommit, + sourceSubmodules, + }; +} + +function gitMetadataEntries(rootPath) { + const entries = []; + function visit(directoryPath, relativeDirectory = '') { + for (const entry of fs.readdirSync(directoryPath, { + withFileTypes: true, + })) { + const relativePath = path.posix.join(relativeDirectory, entry.name); + const absolutePath = path.join(directoryPath, entry.name); + if (entry.name === '.git') { + entries.push(relativePath); + } else if (entry.isDirectory()) { + visit(absolutePath, relativePath); + } + } + } + visit(rootPath); + return entries.sort(); +} + +function assertNoGitMetadata(rootPath) { + const entries = gitMetadataEntries(rootPath); + if (entries.length > 0) { + throw new Error( + `Prepared source snapshot must not contain VCS metadata: ${entries.join(', ')}` + ); + } +} + +function compareCanonicalPaths(left, right) { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +function sha256File(filePath) { + const descriptor = fs.openSync(filePath, 'r'); + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) { + throw new Error( + `Unsupported source snapshot entry (not a regular file): ${filePath}` + ); + } + if (!Number.isSafeInteger(stat.size) || stat.size < 0) { + throw new Error( + `Source snapshot file has an unsupported size: ${filePath}` + ); + } + const hash = crypto.createHash('sha256'); + const buffer = Buffer.allocUnsafe(1024 * 1024); + let offset = 0; + while (offset < stat.size) { + const bytesRead = fs.readSync( + descriptor, + buffer, + 0, + Math.min(buffer.length, stat.size - offset), + offset + ); + if (bytesRead === 0) { + throw new Error( + `Source snapshot file changed while hashing: ${filePath}` + ); + } + hash.update(buffer.subarray(0, bytesRead)); + offset += bytesRead; + } + const finalStat = fs.fstatSync(descriptor); + if ( + !finalStat.isFile() || + finalStat.size !== stat.size || + finalStat.mtimeMs !== stat.mtimeMs + ) { + throw new Error( + `Source snapshot file changed while hashing: ${filePath}` + ); + } + return { + size: stat.size, + executable: (stat.mode & 0o111) !== 0, + sha256: hash.digest('hex'), + }; + } finally { + fs.closeSync(descriptor); + } +} + +function assertSafeSnapshotPath(relativePath) { + if ( + typeof relativePath !== 'string' || + relativePath.length === 0 || + relativePath.includes('\\') || + path.posix.isAbsolute(relativePath) || + path.win32.isAbsolute(relativePath) || + [...relativePath].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) || + relativePath + .split('/') + .some( + (part) => + part === '' || + part === '.' || + part === '..' || + part === '.git' + ) + ) { + throw new Error(`Unsafe source snapshot path: ${relativePath}`); + } +} + +function assertSafeSymlinkTarget(relativePath, target) { + const targetSegments = target.split('/'); + const resolvedTarget = path.posix.normalize( + path.posix.join(path.posix.dirname(relativePath), target) + ); + if ( + target.length === 0 || + target.includes('\\') || + path.posix.isAbsolute(target) || + path.win32.isAbsolute(target) || + [...target].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) || + resolvedTarget === '..' || + resolvedTarget.startsWith('../') || + path.posix.isAbsolute(resolvedTarget) || + targetSegments.includes('.git') + ) { + throw new Error( + `Unsafe source snapshot symlink ${relativePath}: ${target}` + ); + } +} + +function hasExactFields(value, expectedFields) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + isDeepStrictEqual( + Object.keys(value).sort(compareCanonicalPaths), + [...expectedFields].sort(compareCanonicalPaths) + ) + ); +} + +function canonicalSourceSnapshotSha256({ + schemaVersion, + entryCount, + totalBytes, + entries, +}) { + return crypto + .createHash('sha256') + .update( + `${JSON.stringify({ + schemaVersion, + entryCount, + totalBytes, + entries, + })}\n`, + 'utf8' + ) + .digest('hex'); +} + +function invalidSourceSnapshot(detail) { + throw new Error(`Invalid source snapshot: ${detail}`); +} + +function validateLinuxRuntimeSourceSnapshot(snapshot, { expectedSha256 } = {}) { + if ( + !hasExactFields(snapshot, [ + 'schemaVersion', + 'sha256', + 'entryCount', + 'totalBytes', + 'entries', + ]) || + snapshot.schemaVersion !== + LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT.schemaVersion || + typeof snapshot.sha256 !== 'string' || + !SHA256_PATTERN.test(snapshot.sha256) || + !Number.isSafeInteger(snapshot.entryCount) || + snapshot.entryCount < 0 || + !Number.isSafeInteger(snapshot.totalBytes) || + snapshot.totalBytes < 0 || + !Array.isArray(snapshot.entries) + ) { + invalidSourceSnapshot('top-level fields do not match the contract.'); + } + + const entries = []; + const entryTypes = new Map(); + let previousPath = null; + let totalBytes = 0; + for (const entry of snapshot.entries) { + if ( + entry === null || + typeof entry !== 'object' || + Array.isArray(entry) || + typeof entry.path !== 'string' + ) { + invalidSourceSnapshot('an entry is not an exact object.'); + } + assertSafeSnapshotPath(entry.path); + if ( + previousPath !== null && + compareCanonicalPaths(previousPath, entry.path) >= 0 + ) { + throw new Error( + 'Invalid source snapshot: entry paths must be sorted and unique.' + ); + } + previousPath = entry.path; + + let normalizedEntry; + if (entry.type === 'directory') { + if (!hasExactFields(entry, ['path', 'type'])) { + invalidSourceSnapshot( + `directory entry ${entry.path} has invalid fields.` + ); + } + normalizedEntry = { + path: entry.path, + type: 'directory', + }; + } else if (entry.type === 'file') { + if ( + !hasExactFields(entry, [ + 'path', + 'type', + 'size', + 'executable', + 'sha256', + ]) || + !Number.isSafeInteger(entry.size) || + entry.size < 0 || + typeof entry.executable !== 'boolean' || + typeof entry.sha256 !== 'string' || + !SHA256_PATTERN.test(entry.sha256) + ) { + invalidSourceSnapshot( + `file entry ${entry.path} has invalid fields.` + ); + } + totalBytes += entry.size; + if (!Number.isSafeInteger(totalBytes)) { + invalidSourceSnapshot( + 'regular-file byte total exceeds the supported range.' + ); + } + normalizedEntry = { + path: entry.path, + type: 'file', + size: entry.size, + executable: entry.executable, + sha256: entry.sha256, + }; + } else if (entry.type === 'symlink') { + if ( + !hasExactFields(entry, ['path', 'type', 'target']) || + typeof entry.target !== 'string' + ) { + invalidSourceSnapshot( + `symlink entry ${entry.path} has invalid fields.` + ); + } + assertSafeSymlinkTarget(entry.path, entry.target); + normalizedEntry = { + path: entry.path, + type: 'symlink', + target: entry.target, + }; + } else { + invalidSourceSnapshot( + `entry ${entry.path} has an unsupported type.` + ); + } + + const parentPath = path.posix.dirname(entry.path); + if (parentPath !== '.' && entryTypes.get(parentPath) !== 'directory') { + throw new Error( + `Invalid source snapshot: parent ${parentPath} of ${entry.path} must be a directory entry.` + ); + } + entryTypes.set(entry.path, entry.type); + entries.push(normalizedEntry); + } + + if ( + snapshot.entryCount !== entries.length || + snapshot.totalBytes !== totalBytes + ) { + invalidSourceSnapshot( + 'entryCount or totalBytes does not match the entries.' + ); + } + const normalizedSnapshot = { + schemaVersion: LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT.schemaVersion, + sha256: snapshot.sha256, + entryCount: entries.length, + totalBytes, + entries, + }; + const canonicalSha256 = canonicalSourceSnapshotSha256(normalizedSnapshot); + if (snapshot.sha256 !== canonicalSha256) { + invalidSourceSnapshot('canonical SHA-256 does not match the entries.'); + } + assertExpectedSourceSnapshot(normalizedSnapshot, expectedSha256); + return normalizedSnapshot; +} + +function inventoryLinuxRuntimeSourceSnapshot(rootPath) { + const rootStat = fs.lstatSync(rootPath); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error('Source snapshot root must be a real directory.'); + } + assertNoGitMetadata(rootPath); + const entries = []; + let totalBytes = 0; + + function visit(directoryPath, relativeDirectory = '') { + const childNames = fs + .readdirSync(directoryPath) + .sort(compareCanonicalPaths); + for (const childName of childNames) { + const relativePath = relativeDirectory + ? `${relativeDirectory}/${childName}` + : childName; + assertSafeSnapshotPath(relativePath); + const absolutePath = path.join(directoryPath, childName); + const stat = fs.lstatSync(absolutePath); + if (stat.isDirectory()) { + entries.push({ + path: relativePath, + type: 'directory', + }); + visit(absolutePath, relativePath); + continue; + } + if (stat.isFile()) { + const file = sha256File(absolutePath); + totalBytes += file.size; + if (!Number.isSafeInteger(totalBytes)) { + throw new Error( + 'Source snapshot total byte count exceeds the supported range.' + ); + } + entries.push({ + path: relativePath, + type: 'file', + size: file.size, + executable: file.executable, + sha256: file.sha256, + }); + continue; + } + if (stat.isSymbolicLink()) { + const target = fs.readlinkSync(absolutePath); + assertSafeSymlinkTarget(relativePath, target); + entries.push({ + path: relativePath, + type: 'symlink', + target, + }); + continue; + } + throw new Error( + `Unsupported source snapshot entry: ${relativePath}` + ); + } + } + + visit(rootPath); + entries.sort(({ path: left }, { path: right }) => + compareCanonicalPaths(left, right) + ); + const canonicalInventory = { + schemaVersion: LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT.schemaVersion, + entryCount: entries.length, + totalBytes, + entries, + }; + const sha256 = canonicalSourceSnapshotSha256(canonicalInventory); + return { + schemaVersion: canonicalInventory.schemaVersion, + sha256, + entryCount: canonicalInventory.entryCount, + totalBytes: canonicalInventory.totalBytes, + entries, + }; +} + +function lstatIfExists(candidatePath) { + try { + return fs.lstatSync(candidatePath); + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return null; + } + throw error; + } +} + +function assertExpectedSourceSnapshot( + sourceSnapshot, + expectedSourceSnapshotSha256 +) { + if (expectedSourceSnapshotSha256 === undefined) { + return; + } + if ( + typeof expectedSourceSnapshotSha256 !== 'string' || + !SHA256_PATTERN.test(expectedSourceSnapshotSha256) + ) { + throw new Error( + 'Expected source snapshot digest must be a lowercase SHA-256 digest.' + ); + } + if (sourceSnapshot.sha256 !== expectedSourceSnapshotSha256) { + throw new Error( + `Source snapshot digest mismatch: expected ${expectedSourceSnapshotSha256}, received ${sourceSnapshot.sha256}.` + ); + } +} + +function copyWorkingTreeWithoutGitMetadata( + checkoutPath, + outputPath, + expectedSourceSnapshotSha256 +) { + const outputParent = path.dirname(outputPath); + const temporaryPath = fs.mkdtempSync( + path.join(outputParent, `.${path.basename(outputPath)}-`) + ); + try { + fs.cpSync(checkoutPath, temporaryPath, { + recursive: true, + dereference: false, + verbatimSymlinks: true, + filter: (sourcePath) => path.basename(sourcePath) !== '.git', + }); + assertNoGitMetadata(temporaryPath); + const sourceSnapshot = + inventoryLinuxRuntimeSourceSnapshot(temporaryPath); + assertExpectedSourceSnapshot( + sourceSnapshot, + expectedSourceSnapshotSha256 + ); + if (lstatIfExists(outputPath)) { + throw new Error( + `Prepared source snapshot output must not already exist: ${outputPath}` + ); + } + fs.renameSync(temporaryPath, outputPath); + return sourceSnapshot; + } catch (error) { + fs.rmSync(temporaryPath, { recursive: true, force: true }); + throw error; + } +} + +function prepareLinuxRuntimeSourceSnapshot({ + checkoutPath, + outputPath, + expected, + expectedSourceSnapshotSha256, +}) { + const checkoutStat = fs.lstatSync(checkoutPath); + if (!checkoutStat.isDirectory() || checkoutStat.isSymbolicLink()) { + throw new Error('Source checkout must be a real directory.'); + } + const checkoutRoot = fs.realpathSync(checkoutPath); + const outputRoot = path.resolve(outputPath); + const outputParent = path.dirname(outputRoot); + const outputParentStat = fs.lstatSync(outputParent); + if (!outputParentStat.isDirectory() || outputParentStat.isSymbolicLink()) { + throw new Error( + 'Prepared source snapshot parent must be a real directory.' + ); + } + if (lstatIfExists(outputRoot)) { + throw new Error( + `Prepared source snapshot output must not already exist: ${outputRoot}` + ); + } + const realOutputRoot = path.join( + fs.realpathSync(outputParent), + path.basename(outputRoot) + ); + const isInside = (root, candidate) => { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); + }; + if ( + isInside(checkoutRoot, realOutputRoot) || + isInside(realOutputRoot, checkoutRoot) + ) { + throw new Error( + 'Prepared source snapshot and checkout must be separate trees.' + ); + } + const record = inspectCleanGitSource(checkoutRoot, expected); + const sourceSnapshot = copyWorkingTreeWithoutGitMetadata( + checkoutRoot, + realOutputRoot, + expectedSourceSnapshotSha256 + ); + assertNoGitMetadata(realOutputRoot); + return { + ...record, + sourceSnapshot, + }; +} + +function parseArguments(argv) { + const [command, ...tokens] = argv; + const options = {}; + for (let index = 0; index < tokens.length; index += 2) { + const name = tokens[index]; + const value = tokens[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error(`Invalid command-line argument: ${name ?? ''}`); + } + options[name.slice(2)] = value; + } + return { command, options }; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function main(argv = process.argv.slice(2)) { + const { command, options } = parseArguments(argv); + if ( + command === 'prepare' && + options['runtime-manifest'] && + options.checkout && + options.output && + options['record-output'] + ) { + const runtimeManifest = readJson(options['runtime-manifest']); + const sourcePackage = runtimeManifest?.packages?.libplacebo; + const record = prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: options.checkout, + outputPath: options.output, + expected: { + sourceGitCommit: sourcePackage?.sourceGitCommit, + sourceSubmodules: sourcePackage?.sourceSubmodules, + }, + expectedSourceSnapshotSha256: + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + }); + writeJson(options['record-output'], record); + return; + } + if (command === 'assert-vcs-free' && options.directory) { + assertNoGitMetadata(options.directory); + return; + } + throw new Error( + 'Usage: prepare-linux-runtime-source-snapshot.cjs prepare --runtime-manifest --checkout --output --record-output | assert-vcs-free --directory ' + ); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +module.exports = { + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT, + assertNoGitMetadata, + inspectCleanGitSource, + inventoryLinuxRuntimeSourceSnapshot, + prepareLinuxRuntimeSourceSnapshot, + validateLinuxRuntimeSourceSnapshot, +}; diff --git a/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs b/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs new file mode 100644 index 000000000..6d9d0e0a0 --- /dev/null +++ b/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs @@ -0,0 +1,776 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const require = createRequire(import.meta.url); +const { + canonicalizeGitSubmoduleStatus, +} = require('../embedded-mpv/build-linux-runtime.cjs'); +const helperPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + 'prepare-linux-runtime-source-snapshot.cjs' +); + +async function loadHelper() { + if (!fs.existsSync(helperPath)) { + return null; + } + return import(pathToFileURL(helperPath).href); +} + +function runGit(cwd, args, env = {}) { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + ...env, + }, + }); + assert.equal( + result.status, + 0, + `git ${args.join(' ')} failed:\n${result.stderr}` + ); + return result.stdout.trim(); +} + +function createEquivalentCheckouts(root) { + const origin = path.join(root, 'origin'); + fs.mkdirSync(origin); + runGit(origin, ['init', '--quiet']); + fs.mkdirSync(path.join(origin, 'src')); + fs.writeFileSync(path.join(origin, 'src', 'renderer.c'), 'int main() {}\n'); + fs.writeFileSync(path.join(origin, 'LICENSE'), 'license\n'); + fs.symlinkSync('../LICENSE', path.join(origin, 'src', 'license-link')); + runGit(origin, ['add', '.']); + runGit(origin, ['commit', '--quiet', '-m', 'source fixture'], { + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', + }); + const commit = runGit(origin, ['rev-parse', 'HEAD']); + const first = path.join(root, 'first'); + const second = path.join(root, 'second'); + runGit(root, ['clone', '--quiet', origin, first]); + runGit(root, ['clone', '--quiet', origin, second]); + + fs.appendFileSync( + path.join(first, '.git', 'config'), + '\n[fixture]\n\tvalue = first\n' + ); + fs.appendFileSync( + path.join(second, '.git', 'config'), + '\n[fixture]\n\tvalue = second\n' + ); + fs.writeFileSync(path.join(first, '.git', 'fixture-cache'), 'one'); + fs.writeFileSync(path.join(second, '.git', 'fixture-cache'), 'two'); + const differentTime = new Date('2030-01-01T00:00:00Z'); + fs.utimesSync( + path.join(second, '.git', 'index'), + differentTime, + differentTime + ); + + return { commit, first, second }; +} + +function createCheckoutWithSubmodule(root) { + const submoduleOrigin = path.join(root, 'submodule-origin'); + fs.mkdirSync(submoduleOrigin); + runGit(submoduleOrigin, ['init', '--quiet']); + fs.writeFileSync( + path.join(submoduleOrigin, 'submodule-source.c'), + 'int submodule_source;\n' + ); + runGit(submoduleOrigin, ['add', '.']); + runGit(submoduleOrigin, ['commit', '--quiet', '-m', 'submodule source'], { + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', + }); + + const checkout = path.join(root, 'checkout-with-submodule'); + fs.mkdirSync(checkout); + runGit(checkout, ['init', '--quiet']); + fs.writeFileSync(path.join(checkout, 'README'), 'source tree\n'); + runGit(checkout, ['add', 'README']); + runGit(checkout, [ + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + '--quiet', + submoduleOrigin, + '3rdparty/example', + ]); + runGit(checkout, ['commit', '--quiet', '-m', 'source with submodule'], { + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_AUTHOR_DATE: '2000-01-02T00:00:00Z', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_DATE: '2000-01-02T00:00:00Z', + }); + return { + checkout, + expected: { + sourceGitCommit: runGit(checkout, ['rev-parse', 'HEAD']), + sourceSubmodules: canonicalizeGitSubmoduleStatus( + runGit(checkout, ['submodule', 'status', '--recursive']) + ), + }, + }; +} + +function snapshotRecords(root) { + const records = []; + function visit(directory, relativeDirectory = '') { + for (const entry of fs + .readdirSync(directory, { withFileTypes: true }) + .sort(({ name: left }, { name: right }) => + left.localeCompare(right) + )) { + const relativePath = path.posix.join(relativeDirectory, entry.name); + const absolutePath = path.join(directory, entry.name); + const stat = fs.lstatSync(absolutePath); + if (entry.isDirectory()) { + records.push({ + path: `${relativePath}/`, + mode: stat.mode & 0o777, + }); + visit(absolutePath, relativePath); + } else if (entry.isSymbolicLink()) { + records.push({ + path: relativePath, + mode: stat.mode & 0o777, + symlink: fs.readlinkSync(absolutePath), + }); + } else { + records.push({ + path: relativePath, + mode: stat.mode & 0o777, + sha256: crypto + .createHash('sha256') + .update(fs.readFileSync(absolutePath)) + .digest('hex'), + }); + } + } + } + visit(root); + return records; +} + +const FIXTURE_SOURCE_SNAPSHOT = Object.freeze({ + schemaVersion: 1, + sha256: '445a78c08a661d68f8ab15a790ba9c3462766d23e31b00eeca7429544c21a497', + entryCount: 4, + totalBytes: 22, + entries: [ + { + path: 'LICENSE', + type: 'file', + size: 8, + executable: false, + sha256: 'c0c56958ef8be5c1979366896b7e0c7206949a5aa2b23f51429c7f56b10990d3', + }, + { + path: 'src', + type: 'directory', + }, + { + path: 'src/license-link', + type: 'symlink', + target: '../LICENSE', + }, + { + path: 'src/renderer.c', + type: 'file', + size: 14, + executable: false, + sha256: 'bc8bb8e433bf65214540115414c821c904b2a30d60a3ac0424bf9b77a00024b7', + }, + ], +}); + +function cloneFixtureSourceSnapshot() { + return JSON.parse(JSON.stringify(FIXTURE_SOURCE_SNAPSHOT)); +} + +test('purely validates the exact source snapshot contract independent of object key order', async () => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const shuffled = { + entries: FIXTURE_SOURCE_SNAPSHOT.entries.map((entry) => { + if (entry.type === 'file') { + return { + sha256: entry.sha256, + executable: entry.executable, + size: entry.size, + type: entry.type, + path: entry.path, + }; + } + if (entry.type === 'symlink') { + return { + target: entry.target, + type: entry.type, + path: entry.path, + }; + } + return { + type: entry.type, + path: entry.path, + }; + }), + totalBytes: FIXTURE_SOURCE_SNAPSHOT.totalBytes, + entryCount: FIXTURE_SOURCE_SNAPSHOT.entryCount, + sha256: FIXTURE_SOURCE_SNAPSHOT.sha256, + schemaVersion: FIXTURE_SOURCE_SNAPSHOT.schemaVersion, + }; + + assert.deepEqual( + helper.validateLinuxRuntimeSourceSnapshot(shuffled), + FIXTURE_SOURCE_SNAPSHOT + ); + assert.deepEqual( + helper.validateLinuxRuntimeSourceSnapshot(shuffled, { + expectedSha256: FIXTURE_SOURCE_SNAPSHOT.sha256, + }), + FIXTURE_SOURCE_SNAPSHOT + ); + assert.deepEqual(shuffled.entries[0], { + sha256: FIXTURE_SOURCE_SNAPSHOT.entries[0].sha256, + executable: false, + size: 8, + type: 'file', + path: 'LICENSE', + }); +}); + +test('rejects non-exact source snapshot fields and malformed entry shapes', async () => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const invalidSnapshots = []; + + const extraTopLevelField = cloneFixtureSourceSnapshot(); + extraTopLevelField.extra = true; + invalidSnapshots.push(extraTopLevelField); + + const missingTopLevelField = cloneFixtureSourceSnapshot(); + delete missingTopLevelField.totalBytes; + invalidSnapshots.push(missingTopLevelField); + + const extraEntryField = cloneFixtureSourceSnapshot(); + extraEntryField.entries[0].mode = 0o644; + invalidSnapshots.push(extraEntryField); + + const missingEntryField = cloneFixtureSourceSnapshot(); + delete missingEntryField.entries[0].executable; + invalidSnapshots.push(missingEntryField); + + const invalidFileSize = cloneFixtureSourceSnapshot(); + invalidFileSize.entries[0].size = -1; + invalidSnapshots.push(invalidFileSize); + + const invalidExecutable = cloneFixtureSourceSnapshot(); + invalidExecutable.entries[0].executable = 1; + invalidSnapshots.push(invalidExecutable); + + const invalidFileHash = cloneFixtureSourceSnapshot(); + invalidFileHash.entries[0].sha256 = 'A'.repeat(64); + invalidSnapshots.push(invalidFileHash); + + const invalidType = cloneFixtureSourceSnapshot(); + invalidType.entries[1].type = 'socket'; + invalidSnapshots.push(invalidType); + + for (const snapshot of invalidSnapshots) { + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(snapshot), + /invalid source snapshot/i + ); + } +}); + +test('rejects unsafe or unsorted paths, bad links, aggregates, and digests', async () => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + + const unsorted = cloneFixtureSourceSnapshot(); + [unsorted.entries[0], unsorted.entries[1]] = [ + unsorted.entries[1], + unsorted.entries[0], + ]; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(unsorted), + /sorted and unique/i + ); + + const duplicate = cloneFixtureSourceSnapshot(); + duplicate.entries[1].path = duplicate.entries[0].path; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(duplicate), + /sorted and unique/i + ); + + const unsafePath = cloneFixtureSourceSnapshot(); + unsafePath.entries[0].path = '../LICENSE'; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(unsafePath), + /unsafe source snapshot path/i + ); + + const unsafeLink = cloneFixtureSourceSnapshot(); + unsafeLink.entries[2].target = '../../outside'; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(unsafeLink), + /unsafe source snapshot symlink/i + ); + + const missingParentDirectory = cloneFixtureSourceSnapshot(); + missingParentDirectory.entries.splice(1, 1); + missingParentDirectory.entryCount -= 1; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(missingParentDirectory), + /parent.*directory/i + ); + + for (const [field, value] of [ + ['entryCount', FIXTURE_SOURCE_SNAPSHOT.entryCount + 1], + ['totalBytes', FIXTURE_SOURCE_SNAPSHOT.totalBytes + 1], + ['sha256', '0'.repeat(64)], + ]) { + const invalidAggregate = cloneFixtureSourceSnapshot(); + invalidAggregate[field] = value; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(invalidAggregate), + /invalid source snapshot/i + ); + } + + assert.throws( + () => + helper.validateLinuxRuntimeSourceSnapshot(FIXTURE_SOURCE_SNAPSHOT, { + expectedSha256: '0'.repeat(64), + }), + /source snapshot digest mismatch/i + ); +}); + +test('prepares identical VCS-free snapshots from equivalent clean checkouts', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-snapshot-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { commit, first, second } = createEquivalentCheckouts(root); + const firstOutput = path.join(root, 'snapshot-first'); + const secondOutput = path.join(root, 'snapshot-second'); + const expected = { + sourceGitCommit: commit, + sourceSubmodules: [], + }; + + const firstRecord = helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: firstOutput, + expected, + }); + const secondRecord = helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: second, + outputPath: secondOutput, + expected, + }); + + assert.deepEqual(firstRecord, { + ...expected, + sourceSnapshot: FIXTURE_SOURCE_SNAPSHOT, + }); + assert.deepEqual(secondRecord, { + ...expected, + sourceSnapshot: FIXTURE_SOURCE_SNAPSHOT, + }); + assert.deepEqual( + helper.LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT, + Object.freeze({ + schemaVersion: 1, + hashAlgorithm: 'sha256', + canonicalEncoding: 'utf8-json-line-v1', + }) + ); + assert.deepEqual( + helper.inventoryLinuxRuntimeSourceSnapshot(firstOutput), + FIXTURE_SOURCE_SNAPSHOT + ); + assert.deepEqual( + snapshotRecords(firstOutput), + snapshotRecords(secondOutput) + ); + assert.equal( + snapshotRecords(firstOutput).some(({ path: recordPath }) => + recordPath.split('/').includes('.git') + ), + false + ); + assert.equal( + fs.readlinkSync(path.join(firstOutput, 'src', 'license-link')), + '../LICENSE' + ); +}); + +test('normalizes regular-file executable permissions in the canonical inventory', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-modes-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const first = path.join(root, 'first'); + const second = path.join(root, 'second'); + fs.mkdirSync(path.join(first, 'bin'), { recursive: true, mode: 0o700 }); + fs.mkdirSync(path.join(second, 'bin'), { recursive: true, mode: 0o755 }); + fs.writeFileSync(path.join(first, 'bin', 'tool'), '#!/bin/sh\n', { + mode: 0o700, + }); + fs.writeFileSync(path.join(second, 'bin', 'tool'), '#!/bin/sh\n', { + mode: 0o755, + }); + fs.writeFileSync(path.join(first, 'README'), 'same\n', { mode: 0o600 }); + fs.writeFileSync(path.join(second, 'README'), 'same\n', { mode: 0o644 }); + + const firstInventory = helper.inventoryLinuxRuntimeSourceSnapshot(first); + const secondInventory = helper.inventoryLinuxRuntimeSourceSnapshot(second); + + assert.deepEqual(firstInventory, secondInventory); + assert.deepEqual( + firstInventory.entries.map((entry) => ({ + path: entry.path, + executable: entry.executable, + })), + [ + { path: 'README', executable: false }, + { path: 'bin', executable: undefined }, + { path: 'bin/tool', executable: true }, + ] + ); +}); + +test('globally sorts prefix-colliding sibling paths before hashing', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-global-order-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'c')); + fs.mkdirSync(path.join(root, 'c++')); + fs.writeFileSync(path.join(root, 'c', 'tool'), 'c\n'); + fs.writeFileSync(path.join(root, 'c++', 'tool'), 'c++\n'); + + const inventory = helper.inventoryLinuxRuntimeSourceSnapshot(root); + + assert.deepEqual( + inventory.entries.map(({ path: entryPath }) => entryPath), + ['c', 'c++', 'c++/tool', 'c/tool'] + ); + assert.doesNotThrow(() => + helper.validateLinuxRuntimeSourceSnapshot(inventory) + ); +}); + +test('fails closed on VCS entries, unsafe symlinks, and special files', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-unsafe-entry-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const vcsRoot = path.join(root, 'vcs'); + fs.mkdirSync(vcsRoot); + fs.writeFileSync(path.join(vcsRoot, '.git'), 'gitdir: elsewhere\n'); + assert.throws( + () => helper.inventoryLinuxRuntimeSourceSnapshot(vcsRoot), + /must not contain VCS metadata.*\.git/i + ); + + const escapingLinkRoot = path.join(root, 'escaping-link'); + fs.mkdirSync(path.join(escapingLinkRoot, 'nested'), { recursive: true }); + fs.symlinkSync( + '../../outside', + path.join(escapingLinkRoot, 'nested', 'link') + ); + assert.throws( + () => helper.inventoryLinuxRuntimeSourceSnapshot(escapingLinkRoot), + /unsafe source snapshot symlink.*nested\/link.*\.\.\/\.\.\/outside/i + ); + + const absoluteLinkRoot = path.join(root, 'absolute-link'); + fs.mkdirSync(absoluteLinkRoot); + fs.symlinkSync('/outside', path.join(absoluteLinkRoot, 'link')); + assert.throws( + () => helper.inventoryLinuxRuntimeSourceSnapshot(absoluteLinkRoot), + /unsafe source snapshot symlink.*\/outside/i + ); + + if (process.platform !== 'win32') { + const specialRoot = path.join(root, 'special'); + fs.mkdirSync(specialRoot); + const fifoPath = path.join(specialRoot, 'pipe'); + const mkfifo = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' }); + assert.equal(mkfifo.status, 0, mkfifo.stderr); + assert.throws( + () => helper.inventoryLinuxRuntimeSourceSnapshot(specialRoot), + /unsupported source snapshot entry.*pipe/i + ); + } +}); + +test('checks an optional expected digest atomically for direct callers', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-expected-digest-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { commit, first } = createEquivalentCheckouts(root); + const expected = { + sourceGitCommit: commit, + sourceSubmodules: [], + }; + const matchingOutput = path.join(root, 'matching'); + + assert.deepEqual( + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: matchingOutput, + expected, + expectedSourceSnapshotSha256: FIXTURE_SOURCE_SNAPSHOT.sha256, + }).sourceSnapshot, + FIXTURE_SOURCE_SNAPSHOT + ); + + const mismatchingOutput = path.join(root, 'mismatching'); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: mismatchingOutput, + expected, + expectedSourceSnapshotSha256: '0'.repeat(64), + }), + /source snapshot digest mismatch.*expected 000000.*received 445a78/i + ); + assert.equal(fs.existsSync(mismatchingOutput), false); +}); + +test('production CLI always enforces the trusted pinned libplacebo digest', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + assert.match( + helper.EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + /^[a-f0-9]{64}$/ + ); + assert.equal( + helper.EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + '0db67c1523411255244186af437e9fbfe7ccac04a5ac1b3dc9275dd0806f6f0c' + ); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-cli-digest-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { commit, first } = createEquivalentCheckouts(root); + const runtimeManifestPath = path.join(root, 'runtime-manifest.json'); + fs.writeFileSync( + runtimeManifestPath, + JSON.stringify({ + packages: { + libplacebo: { + sourceGitCommit: commit, + sourceSubmodules: [], + }, + }, + }) + ); + const outputPath = path.join(root, 'output'); + const recordOutputPath = path.join(root, 'record.json'); + const result = spawnSync( + process.execPath, + [ + helperPath, + 'prepare', + '--runtime-manifest', + runtimeManifestPath, + '--checkout', + first, + '--output', + outputPath, + '--record-output', + recordOutputPath, + ], + { encoding: 'utf8' } + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /source snapshot digest mismatch/i); + assert.equal(fs.existsSync(outputPath), false); + assert.equal(fs.existsSync(recordOutputPath), false); +}); + +test('validates checkout cleanliness and exact commit/submodule identities before copying', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-identity-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { commit, first, second } = createEquivalentCheckouts(root); + + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: path.join(root, 'wrong-commit'), + expected: { + sourceGitCommit: '0'.repeat(40), + sourceSubmodules: [], + }, + }), + /commit does not match/ + ); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: path.join(root, 'wrong-submodules'), + expected: { + sourceGitCommit: commit, + sourceSubmodules: [`${'1'.repeat(40)} 3rdparty/example`], + }, + }), + /submodules do not match/ + ); + + fs.writeFileSync(path.join(second, 'untracked-build-output'), 'dirty'); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: second, + outputPath: path.join(root, 'dirty'), + expected: { + sourceGitCommit: commit, + sourceSubmodules: [], + }, + }), + /dirty or untracked files/ + ); + + const existingOutput = path.join(root, 'existing-output'); + fs.mkdirSync(existingOutput); + fs.writeFileSync( + path.join(existingOutput, 'owned-by-someone-else'), + 'keep' + ); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: existingOutput, + expected: { + sourceGitCommit: commit, + sourceSubmodules: [], + }, + }), + /output.*must not already exist/i + ); + assert.equal( + fs.readFileSync( + path.join(existingOutput, 'owned-by-someone-else'), + 'utf8' + ), + 'keep' + ); + + const existingSymlink = path.join(root, 'existing-symlink'); + fs.symlinkSync('missing-target', existingSymlink); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: existingSymlink, + expected: { + sourceGitCommit: commit, + sourceSubmodules: [], + }, + }), + /output.*must not already exist/i + ); + assert.equal(fs.readlinkSync(existingSymlink), 'missing-target'); +}); + +test('preserves recursive submodule sources while stripping every nested .git link', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-submodule-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { checkout, expected } = createCheckoutWithSubmodule(root); + const output = path.join(root, 'submodule-snapshot'); + + const record = helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: checkout, + outputPath: output, + expected, + }); + assert.deepEqual( + { + sourceGitCommit: record.sourceGitCommit, + sourceSubmodules: record.sourceSubmodules, + }, + expected + ); + assert.deepEqual( + record.sourceSnapshot, + helper.inventoryLinuxRuntimeSourceSnapshot(output) + ); + assert.equal( + fs.readFileSync( + path.join(output, '3rdparty', 'example', 'submodule-source.c'), + 'utf8' + ), + 'int submodule_source;\n' + ); + assert.doesNotThrow(() => helper.assertNoGitMetadata(output)); +}); + +test('fails closed when a prepared snapshot contains any nested .git entry', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-vcs-entry-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'nested', '.git'), { recursive: true }); + fs.writeFileSync(path.join(root, 'nested', '.git', 'index'), 'metadata'); + + assert.throws( + () => helper.assertNoGitMetadata(root), + /must not contain VCS metadata.*nested\/\.git/i + ); +}); diff --git a/tools/packaging/project.json b/tools/packaging/project.json index 25e5aa53f..1ac68583b 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -12,6 +12,7 @@ "{workspaceRoot}/package.json", "{workspaceRoot}/electron-builder.json", "{workspaceRoot}/.github/workflows/build-and-make.yaml", + "{workspaceRoot}/.github/workflows/publish-snap.yaml", "{workspaceRoot}/apps/electron-backend/build-embedded-mpv.js", "{workspaceRoot}/apps/electron-backend/project.json", "{workspaceRoot}/apps/electron-backend/native/src/embedded_mpv_win32.cc", @@ -21,16 +22,56 @@ "{workspaceRoot}/tools/packaging/asar-dependency-closure.test.mjs", "{workspaceRoot}/tools/packaging/embedded-mpv-packaging.cjs", "{workspaceRoot}/tools/packaging/embedded-mpv-arch.test.mjs", + "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.mjs", + "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.test.mjs", + "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.cjs", + "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.test.mjs", + "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.cjs", + "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs", + "{workspaceRoot}/tools/packaging/publish-snap-workflow.test.mjs", + "{workspaceRoot}/tools/packaging/release-snap-assets.cjs", + "{workspaceRoot}/tools/packaging/release-snap-assets.test.mjs", + "{workspaceRoot}/tools/packaging/release-snap-source-binding.cjs", + "{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.mjs", + "{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.cjs", + "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs", + "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/generate-linux-runtime-notices.cjs", + "{workspaceRoot}/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts", "{workspaceRoot}/tools/embedded-mpv/stage-runtime.mjs", "{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs" ], "options": { - "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs", + "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/release-snap-assets.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", "cwd": "{workspaceRoot}" } }, "lint": { - "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\"" + "inputs": [ + "default", + "{workspaceRoot}/.github/workflows/build-and-make.yaml", + "{workspaceRoot}/.github/workflows/publish-snap.yaml", + "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.cjs", + "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs", + "{workspaceRoot}/tools/packaging/publish-snap-workflow.test.mjs", + "{workspaceRoot}/tools/packaging/release-snap-assets.cjs", + "{workspaceRoot}/tools/packaging/release-snap-assets.test.mjs", + "{workspaceRoot}/tools/packaging/release-snap-source-binding.cjs", + "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs", + "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" + ], + "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/build-linux-runtime.{mjs,test.mjs}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\" \"tools/embedded-mpv/linux-source-archive-contract.cjs\" \"tools/embedded-mpv/runtime-probe-contract.cjs\"" } }, "tags": ["scope:tools", "domain:packaging", "type:tool"] diff --git a/tools/packaging/publish-snap-workflow.test.mjs b/tools/packaging/publish-snap-workflow.test.mjs new file mode 100644 index 000000000..33085595f --- /dev/null +++ b/tools/packaging/publish-snap-workflow.test.mjs @@ -0,0 +1,507 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse } from 'yaml'; +import { + assertBuildSnapWorkflowPolicy, + assertPublishSnapWorkflowPolicy, +} from './snap-workflow-policy.test-helpers.mjs'; + +const workspaceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..' +); +const buildWorkflowPath = path.join( + workspaceRoot, + '.github', + 'workflows', + 'build-and-make.yaml' +); +const publishWorkflowPath = path.join( + workspaceRoot, + '.github', + 'workflows', + 'publish-snap.yaml' +); +const releaseAssetHelperPath = path.join( + workspaceRoot, + 'tools', + 'packaging', + 'release-snap-assets.cjs' +); + +async function loadReleaseAssetHelper() { + if (!fs.existsSync(releaseAssetHelperPath)) { + return null; + } + return import(pathToFileURL(releaseAssetHelperPath).href); +} + +function insertFirstJobStep(workflowText, stepSource) { + const stepsHeader = /^([ \t]+)steps:\r?\n/m.exec(workflowText); + assert.ok(stepsHeader, 'workflow must contain a steps list'); + const insertionIndex = stepsHeader.index + stepsHeader[0].length; + const stepIndent = `${stepsHeader[1]} `; + const indentedStep = stepSource + .split('\n') + .map((line) => `${stepIndent}${line}`) + .join('\n'); + return `${workflowText.slice( + 0, + insertionIndex + )}${indentedStep}\n${workflowText.slice(insertionIndex)}`; +} + +function insertWorkflowJob(workflowText, jobSource) { + const jobsHeader = /^([ \t]*)jobs:\r?\n/m.exec(workflowText); + assert.ok(jobsHeader, 'workflow must contain a jobs mapping'); + const insertionIndex = jobsHeader.index + jobsHeader[0].length; + const jobIndent = `${jobsHeader[1]} `; + const indentedJob = jobSource + .split('\n') + .map((line) => `${jobIndent}${line}`) + .join('\n'); + return `${workflowText.slice( + 0, + insertionIndex + )}${indentedJob}\n${workflowText.slice(insertionIndex)}`; +} + +function insertFirstJobField(workflowText, fieldSource) { + const stepsHeader = /^([ \t]+)steps:\r?\n/m.exec(workflowText); + assert.ok(stepsHeader, 'workflow must contain a steps list'); + const fieldIndent = stepsHeader[1]; + const indentedField = fieldSource + .split('\n') + .map((line) => `${fieldIndent}${line}`) + .join('\n'); + return `${workflowText.slice( + 0, + stepsHeader.index + )}${indentedField}\n${workflowText.slice(stepsHeader.index)}`; +} + +function assertStepRejectedByBothPolicies(stepSource) { + for (const [workflowPath, assertPolicy] of [ + [publishWorkflowPath, assertPublishSnapWorkflowPolicy], + [buildWorkflowPath, assertBuildSnapWorkflowPolicy], + ]) { + const workflowText = insertFirstJobStep( + fs.readFileSync(workflowPath, 'utf8'), + stepSource + ); + assert.doesNotThrow(() => parse(workflowText)); + assert.throws(() => assertPolicy(workflowText)); + } +} + +test('publishes Snap only after a public v-tag release contains binary and source assets', () => { + assert.equal( + fs.existsSync(publishWorkflowPath), + true, + 'the release-published Snap workflow must exist' + ); + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + assert.match(workflowText, /^permissions:\n {4}contents: read$/m); + assert.match( + workflowText, + /startsWith\(github\.event\.release\.tag_name,\s*'v'\)/ + ); + assert.match(workflowText, /github\.event\.release\.draft\s*==\s*false/); + + const validateIndex = workflowText.indexOf( + '- name: Select exact public release assets' + ); + const verifyIndex = workflowText.indexOf( + '- name: Verify downloaded public release assets' + ); + const uploadIndex = workflowText.indexOf( + '- name: Publish all public-release snaps to edge' + ); + assert.ok(validateIndex >= 0); + assert.ok(verifyIndex > validateIndex); + assert.ok(uploadIndex > verifyIndex); + + assert.match( + workflowText, + /github\.event\.release\.id[\s\S]*release-snap-assets\.cjs select/ + ); + assert.match(workflowText, /linux-frame-copy-runtime-sources\.tar\.xz/); + assert.match(workflowText, /release-snap-assets\.cjs verify/); + assertPublishSnapWorkflowPolicy(workflowText); + const disabledWorkflow = workflowText.replace( + 'github.event.release.draft == false }}', + 'github.event.release.draft == false && false }}' + ); + assert.notEqual(disabledWorkflow, workflowText); + assert.doesNotThrow(() => parse(disabledWorkflow)); + assert.throws(() => assertPublishSnapWorkflowPolicy(disabledWorkflow)); + const extraTrigger = workflowText.replace( + ' - published', + ' - published\n - edited' + ); + assert.doesNotThrow(() => parse(extraTrigger)); + assert.throws(() => assertPublishSnapWorkflowPolicy(extraTrigger)); + assert.match( + workflowText, + /Candidate\/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke/ + ); + + const buildWorkflow = fs.readFileSync(buildWorkflowPath, 'utf8'); + assert.doesNotMatch(buildWorkflow, /^ {4}publish-snap:/m); + assertBuildSnapWorkflowPolicy(buildWorkflow); +}); + +test('isolates released verification from the fresh credentialed upload runner', () => { + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + const workflow = parse(workflowText); + assert.deepEqual(Object.keys(workflow.jobs), [ + 'verify-snap', + 'publish-snap', + ]); + + const verifyJob = workflow.jobs['verify-snap']; + const publishJob = workflow.jobs['publish-snap']; + assert.equal(publishJob.needs, 'verify-snap'); + assert.deepEqual(verifyJob.outputs, { + 'receipt-sha256': '${{ steps.bind-transfer.outputs.receipt-sha256 }}', + }); + assert.equal(JSON.stringify(verifyJob).includes('snapcraft_token'), false); + assert.deepEqual( + verifyJob.steps.filter((step) => step.uses).map((step) => step.uses), + [ + 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5', + 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02', + ] + ); + assert.deepEqual( + publishJob.steps.filter((step) => step.uses).map((step) => step.uses), + ['actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'] + ); + + const bindingStep = verifyJob.steps.find( + (step) => step.name === 'Bind verified release transfer' + ); + assert.equal(bindingStep.id, 'bind-transfer'); + assert.match( + bindingStep.run, + /\/usr\/bin\/sha256sum --binary[\s\S]*receipt-sha256/ + ); + const transferredSealStep = publishJob.steps.find( + (step) => step.name === 'Seal transferred public release assets' + ); + assert.deepEqual(transferredSealStep.env, { + EXPECTED_RECEIPT_SHA256: + '${{ needs.verify-snap.outputs.receipt-sha256 }}', + }); + assert.match( + transferredSealStep.run, + /\/usr\/bin\/sha256sum --binary[\s\S]*EXPECTED_RECEIPT_SHA256/ + ); + assert.match( + transferredSealStep.run, + /\/usr\/bin\/jq --exit-status[\s\S]*\/usr\/bin\/sha256sum --strict --check/ + ); + assert.match( + transferredSealStep.run, + /\/usr\/bin\/jq --raw-output[\s\S]*@tsv[\s\S]*while IFS=\$'\\t' read -r ASSET_NAME EXPECTED_SIZE[\s\S]*\/usr\/bin\/stat --format=%s -- "\$\{ASSET_PATH\}"[\s\S]*test "\$\{ACTUAL_SIZE\}" = "\$\{EXPECTED_SIZE\}"/ + ); + + const uploadJobCommands = publishJob.steps + .map((step) => step.run ?? '') + .join('\n'); + assert.doesNotMatch(uploadJobCommands, /\bnode\b/); + assert.doesNotMatch(uploadJobCommands, /release-snap-assets\.cjs/); + const uploadStep = publishJob.steps.at(-1); + assert.deepEqual(uploadStep.env, { + SNAPCRAFT_STORE_CREDENTIALS: '${{ secrets.snapcraft_token }}', + }); + assert.match(uploadStep.run, /shopt -s nullglob dotglob/); + assert.match( + uploadStep.run, + /SNAP_FILES=\("\$\{VERIFIED_ASSET_DIRECTORY\}"\/\*\.snap\)/ + ); + assert.match( + uploadStep.run, + /SNAPCRAFT_STORE_CREDENTIALS="\$\{STORE_CREDENTIALS\}" \/snap\/bin\/snapcraft upload --release=edge/ + ); + assert.doesNotMatch(uploadStep.run, /\bfind\b|\bsort\b|\bnode\b/); + assertPublishSnapWorkflowPolicy(workflowText); +}); + +test('rejects environment and execution-surface expansion on the fresh publish runner', () => { + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + for (const mutatedWorkflow of [ + workflowText.replace( + ' publish-snap:\n', + ' publish-snap:\n env:\n BASH_ENV: /tmp/release-hook\n' + ), + workflowText.replace( + ' runs-on: ubuntu-latest\n timeout-minutes: 20', + ' runs-on: ubuntu-latest\n container: ubuntu:latest\n timeout-minutes: 20' + ), + workflowText.replace( + 'permissions:\n contents: read', + 'env:\n BASH_ENV: /tmp/release-hook\n\npermissions:\n contents: read' + ), + workflowText.replaceAll( + 'runs-on: ubuntu-latest', + 'runs-on: self-hosted' + ), + workflowText.replace( + ' timeout-minutes: 20', + ' timeout-minutes: 120' + ), + ]) { + assert.notEqual(mutatedWorkflow, workflowText); + assert.doesNotThrow(() => parse(mutatedWorkflow)); + assert.throws(() => assertPublishSnapWorkflowPolicy(mutatedWorkflow)); + } +}); + +test('rejects Snap uploads that target candidate or stable channels', () => { + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + for (const forbiddenReleaseArgument of [ + '--release=edge,candidate,stable', + '--release edge,candidate,stable', + '--release=candidate', + '--release stable', + ]) { + const mixedChannelWorkflow = workflowText.replace( + '--release=edge', + forbiddenReleaseArgument + ); + assert.notEqual(mixedChannelWorkflow, workflowText); + assert.doesNotThrow(() => parse(mixedChannelWorkflow)); + assert.throws(() => + assertPublishSnapWorkflowPolicy(mixedChannelWorkflow) + ); + } +}); + +test('rejects edge upload text in non-executing shell contexts', () => { + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + const edgeUpload = + 'SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"'; + const blockIndent = ' '.repeat(18); + for (const replacement of [ + `cat <<123\n${edgeUpload}\n123`, + `cat <<\\EOF\n${edgeUpload}\nEOF`, + `printf '%s\\n' "\n${edgeUpload}\n"`, + `publish_snap() {\n${edgeUpload}\n}`, + `if false; then\n${edgeUpload}\nfi`, + `cat < parse(mutatedWorkflow)); + assert.throws(() => assertPublishSnapWorkflowPolicy(mutatedWorkflow)); + } +}); + +test('rejects extra CLI tokens across wrappers, YAML forms, quotes, and heredocs', () => { + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + for (const stepSource of [ + '- run: command snapcraft upload --release=stable package.snap', + '- run: |\n if true; then command snapcraft \\\n upload --release=edge,candidate,stable package.snap; fi', + '- run: |\n snap\\\n craft upload --release=stable package.snap', + '- run: |\n snapcraft up\\\n load --release=stable package.snap', + '- run: |2\n snapcraft upload --release=stable package.snap', + '- run: snapcraft upload --release=stable package.snap', + '- { run: snapcraft upload --release=stable package.snap }', + '- run: echo "snapcraft upload --release=edge package.snap"', + "- run: |\n cat <<'EOF'\n snapcraft upload --release=edge package.snap\n EOF", + '- run: command snapcraft release iptvnator stable', + ]) { + const mutatedWorkflow = insertFirstJobStep(workflowText, stepSource); + assert.doesNotThrow(() => parse(mutatedWorkflow)); + assert.throws(() => assertPublishSnapWorkflowPolicy(mutatedWorkflow)); + } +}); + +test('rejects continued uploads and release commands in the build workflow', () => { + const workflowText = fs.readFileSync(buildWorkflowPath, 'utf8'); + for (const stepSource of [ + '- run: |\n if true; then command snapcraft \\\n upload --release=edge package.snap; fi', + '- run: command snapcraft release iptvnator edge', + ]) { + const mutatedWorkflow = insertFirstJobStep(workflowText, stepSource); + assert.doesNotThrow(() => parse(mutatedWorkflow)); + assert.throws(() => assertBuildSnapWorkflowPolicy(mutatedWorkflow)); + } +}); + +test('rejects encoded, indirect, and unknown actions in both workflows', () => { + for (const stepSource of [ + '- uses: snapcore/action-publish@v1\n with:\n release: stable', + '- "uses": snapcore/action-publish@v1\n with:\n release: stable', + "- 'uses' : snapcore/action-publish@v1\n with:\n release: stable", + '- "\\x75ses": snapcore/action-publish@v1\n with:\n release: stable', + '- ? uses\n : snapcore/action-publish@v1\n with:\n release: stable', + '- { uses: snapcore/action-publish@v1, with: { release: stable } }', + '- uses: >-\n snapcore/action-publish@v1\n with:\n release: stable', + '- name: Alias publisher\n env:\n PUBLISHER: &publisher snapcore/action-publish@v1\n uses: *publisher\n with:\n release: stable', + '- uses: "\\x73napcore/action-publish@v1"\n with:\n release: stable', + '- uses: example/action-publish@v1\n with:\n release: stable', + ]) { + assertStepRejectedByBothPolicies(stepSource); + } +}); + +test('rejects job-level reusable workflow publication in both workflows', () => { + const reusableJob = + 'synthetic-publisher:\n uses: snapcore/action-publish/.github/workflows/publish.yml@v1\n with:\n release: stable\n secrets: inherit'; + for (const [workflowPath, assertPolicy] of [ + [publishWorkflowPath, assertPublishSnapWorkflowPolicy], + [buildWorkflowPath, assertBuildSnapWorkflowPolicy], + ]) { + const workflowText = insertWorkflowJob( + fs.readFileSync(workflowPath, 'utf8'), + reusableJob + ); + assert.doesNotThrow(() => parse(workflowText)); + assert.throws(() => assertPolicy(workflowText)); + } +}); + +test('rejects command-bearing explicit shell templates in both workflows', () => { + const maliciousShell = '"snapcraft release iptvnator stable; bash {0}"'; + for (const [workflowPath, assertPolicy] of [ + [publishWorkflowPath, assertPublishSnapWorkflowPolicy], + [buildWorkflowPath, assertBuildSnapWorkflowPolicy], + ]) { + const workflowText = fs.readFileSync(workflowPath, 'utf8'); + for (const mutatedWorkflow of [ + insertFirstJobStep( + workflowText, + `- shell: ${maliciousShell}\n run: echo safe` + ), + `${workflowText}\ndefaults:\n run:\n shell: ${maliciousShell}\n`, + insertFirstJobField( + workflowText, + `defaults:\n run:\n shell: ${maliciousShell}` + ), + ]) { + assert.doesNotThrow(() => parse(mutatedWorkflow)); + assert.throws(() => assertPolicy(mutatedWorkflow)); + } + } +}); + +test('ignores Snapcraft names in comments and allowlisted action uses', () => { + const commentStep = + '- run: |\n # snapcraft upload --release=stable package.snap\n # snapcraft release iptvnator stable'; + const publishWorkflow = insertFirstJobStep( + fs.readFileSync(publishWorkflowPath, 'utf8'), + commentStep + ); + const buildWorkflow = insertFirstJobStep( + fs.readFileSync(buildWorkflowPath, 'utf8'), + commentStep + ); + + assert.doesNotThrow(() => parse(publishWorkflow)); + assert.doesNotThrow(() => parse(buildWorkflow)); + assert.doesNotThrow(() => assertPublishSnapWorkflowPolicy(publishWorkflow)); + assert.doesNotThrow(() => assertBuildSnapWorkflowPolicy(buildWorkflow)); +}); + +test('selects every exact Snap and exactly one compliance source asset', async () => { + const helper = await loadReleaseAssetHelper(); + assert.ok(helper, 'the release asset selection helper must exist'); + const selected = helper.selectSnapReleaseAssets([ + { id: 9, name: 'IPTVnator-1.0.0-amd64.snap' }, + { id: 3, name: 'linux-frame-copy-runtime-sources.tar.xz' }, + { id: 8, name: 'IPTVnator-1.0.0-armhf.snap' }, + { id: 7, name: 'IPTVnator-1.0.0.AppImage' }, + ]); + + assert.deepEqual(selected, { + snapAssets: [ + { id: 9, name: 'IPTVnator-1.0.0-amd64.snap' }, + { id: 8, name: 'IPTVnator-1.0.0-armhf.snap' }, + ], + sourceAsset: { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }); +}); + +test('rejects a release missing either exact asset class or containing ambiguous source assets', async () => { + const helper = await loadReleaseAssetHelper(); + assert.ok(helper, 'the release asset selection helper must exist'); + assert.throws( + () => + helper.selectSnapReleaseAssets([ + { + id: 1, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + ]), + /at least one \.snap asset/ + ); + assert.throws( + () => + helper.selectSnapReleaseAssets([{ id: 1, name: 'IPTVnator.snap' }]), + /exactly one linux-frame-copy-runtime-sources\.tar\.xz/ + ); + assert.throws( + () => + helper.selectSnapReleaseAssets([ + { id: 1, name: 'IPTVnator.snap' }, + { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + ]), + /exactly one linux-frame-copy-runtime-sources\.tar\.xz/ + ); +}); + +test('verifies the complete selected download set before publication', async (t) => { + const helper = await loadReleaseAssetHelper(); + assert.ok(helper, 'the release asset selection helper must exist'); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const manifest = { + snapAssets: [{ id: 1, name: 'IPTVnator.snap' }], + sourceAsset: { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + + fs.writeFileSync(path.join(temporaryRoot, 'IPTVnator.snap'), 'snap'); + assert.throws( + () => helper.verifySnapReleaseDownloads(manifest, temporaryRoot), + /missing or empty.*linux-frame-copy-runtime-sources\.tar\.xz/i + ); + fs.writeFileSync( + path.join(temporaryRoot, 'linux-frame-copy-runtime-sources.tar.xz'), + 'sources' + ); + assert.deepEqual( + helper.verifySnapReleaseDownloads(manifest, temporaryRoot), + manifest + ); +}); diff --git a/tools/packaging/release-snap-assets.cjs b/tools/packaging/release-snap-assets.cjs new file mode 100644 index 000000000..0670df809 --- /dev/null +++ b/tools/packaging/release-snap-assets.cjs @@ -0,0 +1,701 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { + inspectSnapPayload, + inspectSourceArchive, + verifySnapReleaseSourceBinding, +} = require('./release-snap-source-binding.cjs'); +const { + SOURCE_ARCHIVE_NAME, +} = require('../embedded-mpv/linux-source-archive-contract.cjs'); + +const VERIFIED_RELEASE_RECEIPT_NAME = 'verified-release-assets.json'; +const VERIFIED_RELEASE_RECEIPT_SCHEMA_VERSION = 1; +const VERIFIED_RELEASE_RECEIPT_MAX_BYTES = 1024 * 1024; +const FILE_COPY_BUFFER_BYTES = 1024 * 1024; +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; + +function flattenAssetPages(value) { + if (!Array.isArray(value)) { + throw new Error('GitHub release assets must be an array.'); + } + return value.flatMap((entry) => + Array.isArray(entry) ? flattenAssetPages(entry) : [entry] + ); +} + +function normalizeReleaseAsset(asset) { + if ( + asset === null || + typeof asset !== 'object' || + !Number.isSafeInteger(asset.id) || + asset.id <= 0 || + typeof asset.name !== 'string' || + asset.name.length === 0 || + asset.name === '.' || + asset.name === '..' || + asset.name.includes('/') || + asset.name.includes('\\') || + [...asset.name].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { + throw new Error('GitHub release contains an invalid asset record.'); + } + return { + id: asset.id, + name: asset.name, + }; +} + +function selectSnapReleaseAssets(assets) { + const normalized = flattenAssetPages(assets).map(normalizeReleaseAsset); + const snapAssets = normalized + .filter(({ name }) => name.endsWith('.snap')) + .sort(({ name: left }, { name: right }) => left.localeCompare(right)); + if (snapAssets.length === 0) { + throw new Error( + 'Public release must contain at least one .snap asset.' + ); + } + + const sourceAssets = normalized.filter( + ({ name }) => name === SOURCE_ARCHIVE_NAME + ); + if (sourceAssets.length !== 1) { + throw new Error( + `Public release must contain exactly one ${SOURCE_ARCHIVE_NAME} asset.` + ); + } + const names = normalized.map(({ name }) => name); + if (new Set(names).size !== names.length) { + throw new Error('GitHub release asset names must be unique.'); + } + + return { + snapAssets, + sourceAsset: sourceAssets[0], + }; +} + +function canonicalSelection(selection) { + if ( + selection === null || + typeof selection !== 'object' || + !Array.isArray(selection.snapAssets) + ) { + throw new Error('Invalid selected Snap release asset manifest.'); + } + const canonical = selectSnapReleaseAssets([ + ...selection.snapAssets, + selection.sourceAsset, + ]); + if (!isDeepStrictEqual(selection, canonical)) { + throw new Error( + 'Selected Snap release asset manifest is not canonical.' + ); + } + return canonical; +} + +function verifySnapReleaseDownloads(selection, directoryPath) { + const canonical = canonicalSelection(selection); + const expectedNames = [ + ...canonical.snapAssets.map(({ name }) => name), + canonical.sourceAsset.name, + ].sort(); + try { + fs.accessSync(directoryPath); + } catch { + throw new Error( + `Missing public release asset directory: ${directoryPath}` + ); + } + for (const name of expectedNames) { + const assetPath = path.join(directoryPath, name); + let stat; + try { + stat = fs.lstatSync(assetPath); + } catch { + throw new Error(`Missing or empty public release asset: ${name}`); + } + if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) { + throw new Error(`Missing or empty public release asset: ${name}`); + } + } + const actualNames = fs.readdirSync(directoryPath).sort(); + if (!isDeepStrictEqual(actualNames, expectedNames)) { + throw new Error( + 'Downloaded public release assets do not match the exact selected set.' + ); + } + return canonical; +} + +function regularFileRecord(filePath, asset) { + const descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + ); + try { + const initialStat = fs.fstatSync(descriptor); + if (!initialStat.isFile() || initialStat.size === 0) { + throw new Error( + `Verified public release asset is not a non-empty regular file: ${asset.name}` + ); + } + const hash = crypto.createHash('sha256'); + const buffer = Buffer.allocUnsafe(FILE_COPY_BUFFER_BYTES); + let totalBytes = 0; + for (;;) { + const bytesRead = fs.readSync( + descriptor, + buffer, + 0, + buffer.length, + null + ); + if (bytesRead === 0) { + break; + } + hash.update(buffer.subarray(0, bytesRead)); + totalBytes += bytesRead; + } + const finalStat = fs.fstatSync(descriptor); + if ( + totalBytes !== initialStat.size || + finalStat.dev !== initialStat.dev || + finalStat.ino !== initialStat.ino || + finalStat.size !== initialStat.size || + finalStat.mtimeMs !== initialStat.mtimeMs || + finalStat.ctimeMs !== initialStat.ctimeMs + ) { + throw new Error( + `Verified public release asset changed while being hashed: ${asset.name}` + ); + } + return { + id: asset.id, + name: asset.name, + sha256: hash.digest('hex'), + size: totalBytes, + }; + } finally { + fs.closeSync(descriptor); + } +} + +function copyRegularFileSnapshot(sourcePath, destinationPath, asset) { + const noFollowFlag = fs.constants.O_NOFOLLOW ?? 0; + const sourceDescriptor = fs.openSync( + sourcePath, + fs.constants.O_RDONLY | noFollowFlag + ); + let destinationDescriptor; + try { + const sourceStat = fs.fstatSync(sourceDescriptor); + if (!sourceStat.isFile() || sourceStat.size === 0) { + throw new Error( + `Downloaded public release asset is not a non-empty regular file: ${asset.name}` + ); + } + destinationDescriptor = fs.openSync( + destinationPath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + noFollowFlag, + 0o444 + ); + const hash = crypto.createHash('sha256'); + const buffer = Buffer.allocUnsafe(FILE_COPY_BUFFER_BYTES); + let totalBytes = 0; + for (;;) { + const bytesRead = fs.readSync( + sourceDescriptor, + buffer, + 0, + buffer.length, + null + ); + if (bytesRead === 0) { + break; + } + hash.update(buffer.subarray(0, bytesRead)); + let written = 0; + while (written < bytesRead) { + written += fs.writeSync( + destinationDescriptor, + buffer, + written, + bytesRead - written + ); + } + totalBytes += bytesRead; + } + const finalSourceStat = fs.fstatSync(sourceDescriptor); + if ( + totalBytes !== sourceStat.size || + finalSourceStat.dev !== sourceStat.dev || + finalSourceStat.ino !== sourceStat.ino || + finalSourceStat.size !== sourceStat.size || + finalSourceStat.mtimeMs !== sourceStat.mtimeMs || + finalSourceStat.ctimeMs !== sourceStat.ctimeMs + ) { + throw new Error( + `Downloaded public release asset changed while being snapshotted: ${asset.name}` + ); + } + fs.fsyncSync(destinationDescriptor); + return { + id: asset.id, + name: asset.name, + sha256: hash.digest('hex'), + size: totalBytes, + }; + } finally { + if (destinationDescriptor !== undefined) { + fs.closeSync(destinationDescriptor); + } + fs.closeSync(sourceDescriptor); + } +} + +function snapshotSnapReleaseDownloads( + selection, + directoryPath, + verifiedDirectoryPath +) { + const canonical = verifySnapReleaseDownloads(selection, directoryPath); + const outputPath = path.resolve(verifiedDirectoryPath); + const sourcePath = path.resolve(directoryPath); + if ( + outputPath === sourcePath || + outputPath.startsWith(`${sourcePath}${path.sep}`) + ) { + throw new Error( + 'Verified public release asset directory must be separate from downloads.' + ); + } + const outputParent = path.dirname(outputPath); + const outputParentStat = fs.lstatSync(outputParent); + if (!outputParentStat.isDirectory() || outputParentStat.isSymbolicLink()) { + throw new Error( + 'Verified public release asset parent must be a real directory.' + ); + } + try { + fs.lstatSync(outputPath); + throw new Error( + 'Verified public release asset directory must not already exist.' + ); + } catch (error) { + if ( + !( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) + ) { + throw error; + } + } + const temporaryPath = fs.mkdtempSync( + path.join(outputParent, `.${path.basename(outputPath)}-`) + ); + const assets = [...canonical.snapAssets, canonical.sourceAsset]; + try { + const records = assets.map((asset) => + copyRegularFileSnapshot( + path.join(sourcePath, asset.name), + path.join(temporaryPath, asset.name), + asset + ) + ); + fs.renameSync(temporaryPath, outputPath); + return { canonical, records }; + } catch (error) { + fs.rmSync(temporaryPath, { recursive: true, force: true }); + throw error; + } +} + +function inspectStableReleaseFile(filePath, asset, inspector) { + const before = regularFileRecord(filePath, asset); + const inspection = inspector(); + const after = regularFileRecord(filePath, asset); + if (!isDeepStrictEqual(after, before)) { + throw new Error( + `Verified public release asset changed during inspection: ${asset.name}` + ); + } + return { inspection, record: after }; +} + +function writeVerifiedReleaseReceipt( + verifiedDirectoryPath, + expectedRepositoryRevision, + records +) { + if ( + typeof expectedRepositoryRevision !== 'string' || + !GIT_COMMIT_PATTERN.test(expectedRepositoryRevision) + ) { + throw new Error( + 'Verified release receipt requires a full repository revision.' + ); + } + const receipt = { + schemaVersion: VERIFIED_RELEASE_RECEIPT_SCHEMA_VERSION, + repositoryRevision: expectedRepositoryRevision, + assets: records, + }; + const receiptPath = path.join( + verifiedDirectoryPath, + VERIFIED_RELEASE_RECEIPT_NAME + ); + fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { + flag: 'wx', + mode: 0o444, + }); + return receipt; +} + +function verifyVerifiedReleaseReceipt( + selection, + verifiedDirectoryPath, + receiptPath, + expectedRepositoryRevision +) { + const canonical = canonicalSelection(selection); + const resolvedDirectory = path.resolve(verifiedDirectoryPath); + const resolvedReceipt = path.resolve(receiptPath); + if ( + path.dirname(resolvedReceipt) !== resolvedDirectory || + path.basename(resolvedReceipt) !== VERIFIED_RELEASE_RECEIPT_NAME + ) { + throw new Error( + 'Verified release receipt must use its canonical asset-directory path.' + ); + } + const receiptStat = fs.lstatSync(resolvedReceipt); + if ( + !receiptStat.isFile() || + receiptStat.isSymbolicLink() || + receiptStat.size === 0 || + receiptStat.size > VERIFIED_RELEASE_RECEIPT_MAX_BYTES + ) { + throw new Error( + 'Verified release receipt must be a bounded regular file.' + ); + } + let receipt; + try { + receipt = JSON.parse(fs.readFileSync(resolvedReceipt, 'utf8')); + } catch { + throw new Error('Verified release receipt is not valid JSON.'); + } + if ( + receipt === null || + typeof receipt !== 'object' || + !isDeepStrictEqual(Object.keys(receipt).sort(), [ + 'assets', + 'repositoryRevision', + 'schemaVersion', + ]) || + receipt.schemaVersion !== VERIFIED_RELEASE_RECEIPT_SCHEMA_VERSION || + typeof receipt.repositoryRevision !== 'string' || + !GIT_COMMIT_PATTERN.test(receipt.repositoryRevision) || + receipt.repositoryRevision !== expectedRepositoryRevision || + !Array.isArray(receipt.assets) + ) { + throw new Error('Verified release receipt has an invalid contract.'); + } + const expectedAssets = [...canonical.snapAssets, canonical.sourceAsset]; + if ( + receipt.assets.length !== expectedAssets.length || + receipt.assets.some((record, index) => { + const asset = expectedAssets[index]; + return ( + record === null || + typeof record !== 'object' || + !isDeepStrictEqual(Object.keys(record).sort(), [ + 'id', + 'name', + 'sha256', + 'size', + ]) || + record.id !== asset.id || + record.name !== asset.name || + !Number.isSafeInteger(record.size) || + record.size <= 0 || + typeof record.sha256 !== 'string' || + !/^[a-f0-9]{64}$/.test(record.sha256) + ); + }) + ) { + throw new Error( + 'Verified release receipt does not match the selected assets.' + ); + } + const actualNames = fs.readdirSync(resolvedDirectory).sort(); + const expectedNames = [ + ...expectedAssets.map(({ name }) => name), + VERIFIED_RELEASE_RECEIPT_NAME, + ].sort(); + if (!isDeepStrictEqual(actualNames, expectedNames)) { + throw new Error( + 'Verified release directory does not match its exact receipt.' + ); + } + for (const record of receipt.assets) { + const actual = regularFileRecord( + path.join(resolvedDirectory, record.name), + record + ); + if (!isDeepStrictEqual(actual, record)) { + throw new Error( + `Verified release asset no longer matches its receipt: ${record.name}` + ); + } + } + return receipt; +} + +function verifySnapReleaseCorrespondence( + selection, + directoryPath, + { + expectedRepositoryRevision, + expectedSourceSnapshotSha256, + inspectSnapPayload: inspectSnap = inspectSnapPayload, + inspectSourceArchive: inspectSource = inspectSourceArchive, + validateRuntimeManifest, + verifiedDirectory, + verifiedReceiptPath, + } = {} +) { + if (verifiedDirectory && verifiedReceiptPath) { + throw new Error( + 'Release verification cannot create and consume a verified receipt at the same time.' + ); + } + let canonical; + let inspectionDirectory = directoryPath; + let snapshottedRecords = null; + let verifiedReceipt = null; + if (verifiedDirectory) { + const snapshot = snapshotSnapReleaseDownloads( + selection, + directoryPath, + verifiedDirectory + ); + canonical = snapshot.canonical; + snapshottedRecords = snapshot.records; + inspectionDirectory = path.resolve(verifiedDirectory); + } else if (verifiedReceiptPath) { + canonical = canonicalSelection(selection); + inspectionDirectory = path.resolve(directoryPath); + verifiedReceipt = verifyVerifiedReleaseReceipt( + canonical, + inspectionDirectory, + verifiedReceiptPath, + expectedRepositoryRevision + ); + } else { + canonical = verifySnapReleaseDownloads(selection, directoryPath); + } + try { + const sourceResult = inspectStableReleaseFile( + path.join(inspectionDirectory, canonical.sourceAsset.name), + canonical.sourceAsset, + () => + inspectSource( + path.join(inspectionDirectory, canonical.sourceAsset.name), + { expectedSourceSnapshotSha256 } + ) + ); + const snapResults = canonical.snapAssets.map((asset) => + inspectStableReleaseFile( + path.join(inspectionDirectory, asset.name), + asset, + () => + inspectSnap( + path.join(inspectionDirectory, asset.name), + asset + ) + ) + ); + verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision, + sourceInspection: sourceResult.inspection, + snapPayloads: snapResults.map(({ inspection }) => inspection), + }, + { + expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ); + const inspectedRecords = [ + ...snapResults.map(({ record }) => record), + sourceResult.record, + ]; + if (verifiedDirectory) { + if (!isDeepStrictEqual(inspectedRecords, snapshottedRecords)) { + throw new Error( + 'Verified release assets changed after their stable snapshot was created.' + ); + } + writeVerifiedReleaseReceipt( + inspectionDirectory, + expectedRepositoryRevision, + inspectedRecords + ); + } + if (verifiedReceipt) { + if (!isDeepStrictEqual(inspectedRecords, verifiedReceipt.assets)) { + throw new Error( + 'Verified release assets do not match the initially verified receipt after inspection.' + ); + } + const reverifiedReceipt = verifyVerifiedReleaseReceipt( + canonical, + inspectionDirectory, + verifiedReceiptPath, + expectedRepositoryRevision + ); + if (!isDeepStrictEqual(reverifiedReceipt, verifiedReceipt)) { + throw new Error( + 'Verified release receipt changed during full inspection.' + ); + } + } + return canonical; + } catch (error) { + if (verifiedDirectory) { + fs.rmSync(path.resolve(verifiedDirectory), { + recursive: true, + force: true, + }); + } + throw error; + } +} + +function parseArguments(argv) { + const [command, ...tokens] = argv; + const options = {}; + for (let index = 0; index < tokens.length; index += 2) { + const name = tokens[index]; + const value = tokens[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error(`Invalid command-line argument: ${name ?? ''}`); + } + options[name.slice(2)] = value; + } + return { command, options }; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function main(argv = process.argv.slice(2)) { + const { command, options } = parseArguments(argv); + if ( + command === 'select' && + options['assets-json'] && + options['output-json'] + ) { + writeJson( + options['output-json'], + selectSnapReleaseAssets(readJson(options['assets-json'])) + ); + return; + } + if ( + command === 'verify' && + options.manifest && + options.directory && + options['repository-revision'] && + options['verified-directory'] + ) { + verifySnapReleaseCorrespondence( + readJson(options.manifest), + options.directory, + { + expectedRepositoryRevision: options['repository-revision'], + verifiedDirectory: options['verified-directory'], + } + ); + return; + } + if ( + command === 'verify-receipt' && + options.manifest && + options.directory && + options.receipt && + options['repository-revision'] + ) { + verifyVerifiedReleaseReceipt( + readJson(options.manifest), + options.directory, + options.receipt, + options['repository-revision'] + ); + return; + } + if ( + command === 'verify-sealed' && + options.manifest && + options.directory && + options.receipt && + options['repository-revision'] + ) { + verifySnapReleaseCorrespondence( + readJson(options.manifest), + options.directory, + { + expectedRepositoryRevision: options['repository-revision'], + verifiedReceiptPath: options.receipt, + } + ); + return; + } + throw new Error( + `Usage: release-snap-assets.cjs select --assets-json --output-json | verify --manifest --directory --repository-revision --verified-directory | verify-receipt --manifest --directory --receipt --repository-revision | verify-sealed --manifest --directory --receipt --repository-revision ` + ); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +module.exports = { + SOURCE_ARCHIVE_NAME, + VERIFIED_RELEASE_RECEIPT_NAME, + selectSnapReleaseAssets, + snapshotSnapReleaseDownloads, + verifyVerifiedReleaseReceipt, + verifySnapReleaseCorrespondence, + verifySnapReleaseDownloads, + verifySnapReleaseSourceBinding, +}; diff --git a/tools/packaging/release-snap-assets.test.mjs b/tools/packaging/release-snap-assets.test.mjs new file mode 100644 index 000000000..c1a3a4c1c --- /dev/null +++ b/tools/packaging/release-snap-assets.test.mjs @@ -0,0 +1,1649 @@ +import assert from 'node:assert/strict'; +import childProcess from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse } from 'yaml'; + +const require = createRequire(import.meta.url); +const { createPackage: createAsarPackage } = require('@electron/asar'); +const workspaceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..' +); +const helperPath = path.join( + workspaceRoot, + 'tools', + 'packaging', + 'release-snap-assets.cjs' +); +const sourceBindingHelperPath = path.join( + workspaceRoot, + 'tools', + 'packaging', + 'release-snap-source-binding.cjs' +); +const sourceArchiveContractPath = path.join( + workspaceRoot, + 'tools', + 'embedded-mpv', + 'linux-source-archive-contract.cjs' +); +const publishWorkflowPath = path.join( + workspaceRoot, + '.github', + 'workflows', + 'publish-snap.yaml' +); + +async function loadHelper() { + return import(pathToFileURL(helperPath).href); +} + +async function loadSourceBindingHelper() { + return import(pathToFileURL(sourceBindingHelperPath).href); +} + +async function loadSourceArchiveContract() { + return import(pathToFileURL(sourceArchiveContractPath).href); +} + +function syntheticSquashfsListing() { + return [ + 'drwxr-xr-x 0/0 0 2026-07-18 00:00 squashfs-root', + '-rw-r--r-- 0/0 1 2026-07-18 00:00 squashfs-root/payload', + '', + ].join('\n'); +} + +function sha256(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +const SYNTHETIC_LIBPLACEBO_CONTENTS = Buffer.from( + 'libplacebo source snapshot\n' +); +const SYNTHETIC_LIBPLACEBO_ENTRIES = Object.freeze([ + Object.freeze({ + path: 'README.md', + type: 'file', + size: SYNTHETIC_LIBPLACEBO_CONTENTS.length, + executable: false, + sha256: sha256(SYNTHETIC_LIBPLACEBO_CONTENTS), + }), +]); +const SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT = (() => { + const canonical = { + schemaVersion: 1, + entryCount: SYNTHETIC_LIBPLACEBO_ENTRIES.length, + totalBytes: SYNTHETIC_LIBPLACEBO_CONTENTS.length, + entries: SYNTHETIC_LIBPLACEBO_ENTRIES, + }; + return Object.freeze({ + schemaVersion: canonical.schemaVersion, + sha256: sha256(`${JSON.stringify(canonical)}\n`), + entryCount: canonical.entryCount, + totalBytes: canonical.totalBytes, + entries: SYNTHETIC_LIBPLACEBO_ENTRIES, + }); +})(); +const EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES = Object.freeze([ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear', +]); + +function sourcePackageIdentity(sourcePackage) { + return Object.fromEntries( + [ + 'version', + 'sourceUrl', + 'sourceTag', + 'sourceSha256', + 'sourceGitCommit', + 'license', + ] + .filter((field) => Object.hasOwn(sourcePackage, field)) + .map((field) => [field, sourcePackage[field]]) + ); +} + +function createComplianceInspection(sourceRuntime) { + const licenseInputFiles = []; + const noticeLicenseFiles = []; + const licenseInputPackages = []; + const noticePackages = []; + for (const [id, sourcePackage] of Object.entries(sourceRuntime.packages)) { + const contents = Buffer.from(`${id} license\n`); + const file = { + path: `licenses/${id}/LICENSE`, + size: contents.length, + sha256: sha256(contents), + }; + licenseInputFiles.push(file); + noticeLicenseFiles.push(file); + licenseInputPackages.push({ + id, + ...sourcePackageIdentity(sourcePackage), + files: [ + { + sourcePath: 'LICENSE', + ...file, + }, + ], + }); + noticePackages.push({ + id, + ...sourcePackageIdentity(sourcePackage), + files: [file], + }); + } + const aggregateNoticeContents = Buffer.from('third-party notices\n'); + const noticeFile = { + path: 'THIRD_PARTY_NOTICES.txt', + size: aggregateNoticeContents.length, + sha256: sha256(aggregateNoticeContents), + }; + return { + aggregateNoticeContents, + libplaceboSourceSnapshot: SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT, + licenseInputFiles, + licenseInputs: { + schemaVersion: 1, + origin: 'pinned-linux-runtime-license-inputs', + platform: 'linux', + arch: 'x64', + packages: licenseInputPackages, + }, + noticeFile, + noticeLicenseFiles, + notices: { + schemaVersion: 1, + origin: 'pinned-linux-runtime-upstream-licenses', + platform: 'linux', + arch: 'x64', + noticeFile, + packages: noticePackages, + totalBytes: + noticeFile.size + + noticeLicenseFiles.reduce( + (total, file) => total + file.size, + 0 + ), + }, + toolingValidated: true, + }; +} + +function createSourceBindingFixture() { + const repositoryRevision = 'a'.repeat(40); + const archiveSha256 = 'b'.repeat(64); + const sourceRuntime = { + generatedAt: '2026-07-18T00:00:00.000Z', + packages: { + ffmpeg: { + version: '1.0.0', + sourceUrl: 'https://example.test/ffmpeg.tar.xz', + sourceSha256: archiveSha256, + license: 'LGPL-2.1-or-later', + }, + libplacebo: { + version: '2.0.0', + sourceUrl: 'https://example.test/libplacebo.git', + sourceTag: 'v2.0.0', + sourceGitCommit: 'c'.repeat(40), + sourceSubmodules: [...EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES], + license: 'LGPL-2.1-or-later', + }, + }, + }; + const compliance = createComplianceInspection(sourceRuntime); + const sourceCompliance = { ...compliance }; + delete sourceCompliance.aggregateNoticeContents; + const sourceIndex = { + schemaVersion: 3, + repositoryRevision, + sourcePackages: sourceRuntime.packages, + archives: [ + { + name: 'ffmpeg.tar.xz', + sha256: archiveSha256, + }, + ], + libplacebo: { + sourceGitCommit: sourceRuntime.packages.libplacebo.sourceGitCommit, + sourceSubmodules: [...EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES], + sourceSnapshot: SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT, + }, + legal: { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: compliance.notices.noticeFile, + packages: compliance.notices.packages, + }, + }; + const sourceInspection = { + archiveSha256: 'd'.repeat(64), + sourceRuntime, + sourceIndex, + repositoryRevision, + localChanges: Buffer.alloc(0), + archiveFiles: sourceIndex.archives, + compliance: sourceCompliance, + }; + const snapPayloads = { + 'IPTVnator-amd64.snap': { + assetName: 'IPTVnator-amd64.snap', + architecture: 'x64', + markerOnly: false, + manifest: { + platform: 'linux', + arch: 'x64', + profile: 'portable', + runtimeMode: 'bundled', + targets: ['appimage', 'snap'], + sourceArchive: { + schemaVersion: 1, + name: 'linux-frame-copy-runtime-sources.tar.xz', + sha256: 'd'.repeat(64), + repositoryRevision, + }, + sourceRuntime, + }, + }, + 'IPTVnator-arm64.snap': { + assetName: 'IPTVnator-arm64.snap', + architecture: 'arm64', + markerOnly: true, + manifest: null, + }, + }; + return { + expectedSourceSnapshotSha256: + SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT.sha256, + repositoryRevision, + sourceInspection, + snapPayloads, + }; +} + +test('binds source metadata and checksums to every selected Snap before publication', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-source-binding-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const selection = { + snapAssets: [ + { id: 1, name: 'IPTVnator-amd64.snap' }, + { id: 2, name: 'IPTVnator-arm64.snap' }, + ], + sourceAsset: { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + ...selection.snapAssets.map(({ name }) => name), + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(temporaryRoot, name), name); + } + const inspectedSnaps = []; + + assert.deepEqual( + helper.verifySnapReleaseCorrespondence(selection, temporaryRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: () => fixture.sourceInspection, + inspectSnapPayload: (_snapPath, asset) => { + inspectedSnaps.push(asset.name); + return fixture.snapPayloads[asset.name]; + }, + validateRuntimeManifest: () => [], + }), + selection + ); + assert.deepEqual( + inspectedSnaps, + selection.snapAssets.map(({ name }) => name) + ); +}); + +test('publishes only a stable verified asset snapshot with an exact receipt', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-snapshot-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const downloadRoot = path.join(temporaryRoot, 'downloads'); + const verifiedRoot = path.join(temporaryRoot, 'verified'); + fs.mkdirSync(downloadRoot); + const selection = { + snapAssets: [ + { id: 1, name: 'IPTVnator-amd64.snap' }, + { id: 2, name: 'IPTVnator-arm64.snap' }, + ], + sourceAsset: { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + ...selection.snapAssets.map(({ name }) => name), + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(downloadRoot, name), name); + } + const inspectedPaths = []; + assert.deepEqual( + helper.verifySnapReleaseCorrespondence(selection, downloadRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: (sourcePath) => { + inspectedPaths.push(sourcePath); + return fixture.sourceInspection; + }, + inspectSnapPayload: (snapPath, asset) => { + inspectedPaths.push(snapPath); + return fixture.snapPayloads[asset.name]; + }, + validateRuntimeManifest: () => [], + verifiedDirectory: verifiedRoot, + }), + selection + ); + assert.ok( + inspectedPaths.every((filePath) => + filePath.startsWith(`${verifiedRoot}${path.sep}`) + ) + ); + const receiptPath = path.join( + verifiedRoot, + helper.VERIFIED_RELEASE_RECEIPT_NAME + ); + const receipt = helper.verifyVerifiedReleaseReceipt( + selection, + verifiedRoot, + receiptPath, + fixture.repositoryRevision + ); + assert.equal(receipt.repositoryRevision, fixture.repositoryRevision); + assert.deepEqual( + receipt.assets.map(({ name }) => name), + [ + ...selection.snapAssets.map(({ name }) => name), + selection.sourceAsset.name, + ] + ); + const sealedInspectionPaths = []; + assert.deepEqual( + helper.verifySnapReleaseCorrespondence(selection, verifiedRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: (sourcePath) => { + sealedInspectionPaths.push(sourcePath); + return fixture.sourceInspection; + }, + inspectSnapPayload: (snapPath, asset) => { + sealedInspectionPaths.push(snapPath); + return fixture.snapPayloads[asset.name]; + }, + validateRuntimeManifest: () => [], + verifiedReceiptPath: receiptPath, + }), + selection + ); + assert.ok( + sealedInspectionPaths.every((filePath) => + filePath.startsWith(`${verifiedRoot}${path.sep}`) + ) + ); + assert.throws( + () => + helper.verifyVerifiedReleaseReceipt( + selection, + verifiedRoot, + receiptPath, + 'f'.repeat(40) + ), + /invalid contract/i + ); + + const changedSnapPath = path.join( + verifiedRoot, + selection.snapAssets[0].name + ); + fs.chmodSync(changedSnapPath, 0o644); + fs.appendFileSync(changedSnapPath, 'changed'); + assert.throws( + () => + helper.verifyVerifiedReleaseReceipt( + selection, + verifiedRoot, + receiptPath, + fixture.repositoryRevision + ), + /no longer matches its receipt/i + ); +}); + +test('binds sealed inspection to the initially verified receipt across a mutually consistent replacement', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-replacement-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const downloadRoot = path.join(temporaryRoot, 'downloads'); + const verifiedRoot = path.join(temporaryRoot, 'verified'); + const replacementRoot = path.join(temporaryRoot, 'replacement'); + const originalRoot = path.join(temporaryRoot, 'original'); + fs.mkdirSync(downloadRoot); + fs.mkdirSync(replacementRoot); + const selection = { + snapAssets: [{ id: 1, name: 'IPTVnator-amd64.snap' }], + sourceAsset: { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + selection.snapAssets[0].name, + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(downloadRoot, name), name); + } + helper.verifySnapReleaseCorrespondence(selection, downloadRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: () => fixture.sourceInspection, + inspectSnapPayload: (_snapPath, asset) => + fixture.snapPayloads[asset.name], + validateRuntimeManifest: () => [], + verifiedDirectory: verifiedRoot, + }); + + const replacementSnapContents = Buffer.from('replacement Snap payload'); + const sourceContents = fs.readFileSync( + path.join(verifiedRoot, selection.sourceAsset.name) + ); + fs.writeFileSync( + path.join(replacementRoot, selection.snapAssets[0].name), + replacementSnapContents + ); + fs.writeFileSync( + path.join(replacementRoot, selection.sourceAsset.name), + sourceContents + ); + const replacementRecords = [ + { + ...selection.snapAssets[0], + sha256: sha256(replacementSnapContents), + size: replacementSnapContents.length, + }, + { + ...selection.sourceAsset, + sha256: sha256(sourceContents), + size: sourceContents.length, + }, + ]; + const replacementReceiptPath = path.join( + replacementRoot, + helper.VERIFIED_RELEASE_RECEIPT_NAME + ); + fs.writeFileSync( + replacementReceiptPath, + `${JSON.stringify( + { + schemaVersion: 1, + repositoryRevision: fixture.repositoryRevision, + assets: replacementRecords, + }, + null, + 2 + )}\n` + ); + assert.deepEqual( + helper.verifyVerifiedReleaseReceipt( + selection, + replacementRoot, + replacementReceiptPath, + fixture.repositoryRevision + ).assets, + replacementRecords + ); + const receiptPath = path.join( + verifiedRoot, + helper.VERIFIED_RELEASE_RECEIPT_NAME + ); + let replaced = false; + + assert.throws( + () => + helper.verifySnapReleaseCorrespondence(selection, verifiedRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: () => { + fs.renameSync(verifiedRoot, originalRoot); + fs.renameSync(replacementRoot, verifiedRoot); + replaced = true; + return fixture.sourceInspection; + }, + inspectSnapPayload: (_snapPath, asset) => + fixture.snapPayloads[asset.name], + validateRuntimeManifest: () => [], + verifiedReceiptPath: receiptPath, + }), + /initially verified receipt/i + ); + assert.equal(replaced, true); +}); + +test('removes a verified snapshot when an asset changes during inspection', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-race-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const downloadRoot = path.join(temporaryRoot, 'downloads'); + const verifiedRoot = path.join(temporaryRoot, 'verified'); + fs.mkdirSync(downloadRoot); + const selection = { + snapAssets: [{ id: 1, name: 'IPTVnator-amd64.snap' }], + sourceAsset: { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + selection.snapAssets[0].name, + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(downloadRoot, name), name); + } + + assert.throws( + () => + helper.verifySnapReleaseCorrespondence(selection, downloadRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: (sourcePath) => { + fs.chmodSync(sourcePath, 0o644); + fs.appendFileSync(sourcePath, 'changed'); + return fixture.sourceInspection; + }, + inspectSnapPayload: (_snapPath, asset) => + fixture.snapPayloads[asset.name], + validateRuntimeManifest: () => [], + verifiedDirectory: verifiedRoot, + }), + /changed during inspection/i + ); + assert.equal(fs.existsSync(verifiedRoot), false); +}); + +test('fails closed for stale source identity, archive bytes, and x64 marker-only payloads', async () => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const validateRuntimeManifest = () => []; + + const staleRevision = structuredClone(fixture.sourceInspection); + staleRevision.sourceIndex.repositoryRevision = 'e'.repeat(40); + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: staleRevision, + snapPayloads: Object.values(fixture.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /repository revision/i + ); + + const forgedSubmodules = structuredClone(fixture); + const forgedSubmoduleRecord = `${'f'.repeat(40)} 3rdparty/Vulkan-Headers`; + forgedSubmodules.sourceInspection.sourceRuntime.packages.libplacebo.sourceSubmodules[0] = + forgedSubmoduleRecord; + forgedSubmodules.sourceInspection.sourceIndex.libplacebo.sourceSubmodules[0] = + forgedSubmoduleRecord; + forgedSubmodules.sourceInspection.sourceIndex.sourcePackages = + forgedSubmodules.sourceInspection.sourceRuntime.packages; + forgedSubmodules.snapPayloads[ + 'IPTVnator-amd64.snap' + ].manifest.sourceRuntime = forgedSubmodules.sourceInspection.sourceRuntime; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: forgedSubmodules.sourceInspection, + snapPayloads: Object.values(forgedSubmodules.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /submodule/i + ); + + const tamperedArchive = structuredClone(fixture.sourceInspection); + tamperedArchive.archiveFiles[0].sha256 = 'f'.repeat(64); + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: tamperedArchive, + snapPayloads: Object.values(fixture.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /archive.*checksum/i + ); + + const staleSnapPayloads = structuredClone(fixture.snapPayloads); + staleSnapPayloads[ + 'IPTVnator-amd64.snap' + ].manifest.sourceRuntime.generatedAt = '2025-01-01T00:00:00.000Z'; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: Object.values(staleSnapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /Snap source runtime.*source archive/i + ); + + const markerOnlyX64 = structuredClone(fixture.snapPayloads); + markerOnlyX64['IPTVnator-amd64.snap'] = { + assetName: 'IPTVnator-amd64.snap', + architecture: 'x64', + markerOnly: true, + manifest: null, + }; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: Object.values(markerOnlyX64), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /x64 Snap.*frame-copy/i + ); + + const incompleteCompliance = structuredClone(fixture.sourceInspection); + incompleteCompliance.compliance.toolingValidated = false; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: incompleteCompliance, + snapPayloads: Object.values(fixture.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /source archive compliance/i + ); + + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: [ + fixture.snapPayloads['IPTVnator-arm64.snap'], + ], + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /exactly one x64 Snap/i + ); + + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: [ + fixture.snapPayloads['IPTVnator-amd64.snap'], + { + ...fixture.snapPayloads['IPTVnator-amd64.snap'], + assetName: 'IPTVnator-second-amd64.snap', + }, + ], + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /exactly one x64 Snap/i + ); +}); + +test('hashes the final source archive bytes and reads the exact packaged Snap binding', async (t) => { + const sourceBindingHelper = await loadSourceBindingHelper(); + const sourceArchiveContract = await loadSourceArchiveContract(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-source-inspection-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + + const sourceRoot = path.join(temporaryRoot, 'source'); + const metadataRoot = path.join(sourceRoot, 'metadata'); + const archivesRoot = path.join(sourceRoot, 'archives'); + fs.mkdirSync(metadataRoot, { recursive: true }); + fs.mkdirSync(archivesRoot, { recursive: true }); + const pinnedSourceContents = Buffer.from('pinned ffmpeg source'); + const pinnedSourceSha256 = crypto + .createHash('sha256') + .update(pinnedSourceContents) + .digest('hex'); + const sourceRuntime = structuredClone( + fixture.sourceInspection.sourceRuntime + ); + sourceRuntime.packages.ffmpeg.sourceSha256 = pinnedSourceSha256; + const sourceIndex = structuredClone(fixture.sourceInspection.sourceIndex); + sourceIndex.sourcePackages = sourceRuntime.packages; + sourceIndex.archives = [ + { + name: 'ffmpeg.tar.xz', + sha256: pinnedSourceSha256, + }, + ]; + sourceIndex.libplacebo = { + sourceGitCommit: sourceRuntime.packages.libplacebo.sourceGitCommit, + sourceSubmodules: [ + ...sourceRuntime.packages.libplacebo.sourceSubmodules, + ], + sourceSnapshot: SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT, + }; + const compliance = createComplianceInspection(sourceRuntime); + sourceIndex.legal = { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: compliance.notices.noticeFile, + packages: compliance.notices.packages, + }; + fs.writeFileSync( + path.join(archivesRoot, 'ffmpeg.tar.xz'), + pinnedSourceContents + ); + const licenseInputRoot = path.join(sourceRoot, 'license-inputs'); + const noticesRoot = path.join(sourceRoot, 'notices'); + for (const [root, files] of [ + [licenseInputRoot, compliance.licenseInputFiles], + [noticesRoot, compliance.noticeLicenseFiles], + ]) { + for (const file of files) { + const filePath = path.join(root, ...file.path.split('/')); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${file.path.split('/')[1]} license\n`); + } + } + fs.writeFileSync( + path.join(licenseInputRoot, 'linux-runtime-license-inputs.json'), + `${JSON.stringify(compliance.licenseInputs)}\n` + ); + fs.writeFileSync( + path.join(noticesRoot, 'embedded-mpv-notices.json'), + `${JSON.stringify(compliance.notices)}\n` + ); + fs.writeFileSync( + path.join(noticesRoot, 'THIRD_PARTY_NOTICES.txt'), + compliance.aggregateNoticeContents + ); + const libplaceboRoot = path.join(sourceRoot, 'git', 'libplacebo'); + fs.mkdirSync(libplaceboRoot, { recursive: true }); + fs.writeFileSync( + path.join(libplaceboRoot, 'README.md'), + 'libplacebo source snapshot\n' + ); + const toolingFiles = [ + ['embedded-mpv', 'build-linux-runtime.cjs'], + ['embedded-mpv', 'build-linux-runtime.mjs'], + ['embedded-mpv', 'generate-linux-runtime-notices.cjs'], + ['embedded-mpv', 'linux-runtime-manifest.cjs'], + ['embedded-mpv', 'linux-source-archive-contract.cjs'], + ['embedded-mpv', 'stage-runtime.mjs'], + ['packaging', 'prepare-linux-runtime-source-snapshot.cjs'], + ]; + const toolingRoot = path.join(sourceRoot, 'tooling'); + fs.mkdirSync(toolingRoot, { recursive: true }); + for (const [directoryName, fileName] of toolingFiles) { + fs.copyFileSync( + path.join(workspaceRoot, 'tools', directoryName, fileName), + path.join(toolingRoot, fileName) + ); + } + fs.writeFileSync( + path.join(metadataRoot, 'runtime-manifest.json'), + `${JSON.stringify(sourceRuntime)}\n` + ); + fs.writeFileSync( + path.join(metadataRoot, 'source-index.json'), + `${JSON.stringify(sourceIndex)}\n` + ); + fs.writeFileSync( + path.join(metadataRoot, 'iptvnator-git-revision.txt'), + `${fixture.repositoryRevision}\n` + ); + fs.writeFileSync(path.join(metadataRoot, 'local-changes.patch'), ''); + const archiveChecksumsPath = path.join(metadataRoot, 'archive-sha256.txt'); + fs.writeFileSync( + archiveChecksumsPath, + `${pinnedSourceSha256} ffmpeg.tar.xz\n` + ); + + const sourceArchivePath = path.join( + temporaryRoot, + 'linux-frame-copy-runtime-sources.tar.xz' + ); + const tarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + sourceArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(tarResult.error, undefined); + assert.equal(tarResult.status, 0, tarResult.stderr); + + let observedTarFilesFromFile = false; + const runCommand = (command, args, options = {}) => { + if (command === 'tar' && args.includes('-T')) { + const filesFromPath = args[args.indexOf('-T') + 1]; + assert.equal(path.isAbsolute(filesFromPath), true); + assert.equal(fs.lstatSync(filesFromPath).isFile(), true); + assert.equal( + fs.lstatSync(filesFromPath).mode & 0o077, + 0, + 'tar files-from input must not be group/world accessible' + ); + const filesFromContents = fs.readFileSync(filesFromPath, 'utf8'); + assert.match( + filesFromContents, + /(?:^|\n)\.\/git\/libplacebo\/README\.md(?:\n|$)/, + 'tar files-from input must preserve the exact archive member names' + ); + assert.ok( + args.includes('--no-recursion'), + 'tar must not recursively consume descendants that are also listed explicitly' + ); + observedTarFilesFromFile = true; + } + const result = childProcess.spawnSync(command, args, { + encoding: + options.encoding === undefined ? 'utf8' : options.encoding, + input: options.input, + killSignal: 'SIGKILL', + maxBuffer: options.maxBuffer, + stdio: 'pipe', + timeout: options.timeout, + windowsHide: true, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `${command} exited with status ${String(result.status)}: ${ + result.stderr ?? '' + }` + ); + } + return result.stdout; + }; + const sourceArchive = sourceArchiveContract.createLinuxSourceArchiveBinding( + { + archivePath: sourceArchivePath, + repositoryRevision: fixture.repositoryRevision, + } + ); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive(sourceArchivePath, { + runCommand, + }), + /snapshot digest mismatch/i + ); + const sourceInspection = sourceBindingHelper.inspectSourceArchive( + sourceArchivePath, + { + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + runCommand, + } + ); + assert.equal(observedTarFilesFromFile, true); + assert.equal(sourceInspection.archiveSha256, sourceArchive.sha256); + assert.deepEqual(sourceInspection.archiveFiles, sourceIndex.archives); + + const hiddenSourceRoot = path.join(temporaryRoot, 'hidden-source'); + fs.mkdirSync(hiddenSourceRoot); + fs.writeFileSync( + path.join(hiddenSourceRoot, 'undeclared-hidden-source.txt'), + 'not part of the canonical source bundle' + ); + const hiddenSourceArchivePath = path.join( + temporaryRoot, + 'hidden-source.tar.xz' + ); + const hiddenSourceTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + hiddenSourceArchivePath, + '--directory', + hiddenSourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(hiddenSourceTarResult.error, undefined); + assert.equal(hiddenSourceTarResult.status, 0, hiddenSourceTarResult.stderr); + const concatenatedSourceArchivePath = path.join( + temporaryRoot, + 'concatenated-source.tar.xz' + ); + fs.writeFileSync( + concatenatedSourceArchivePath, + Buffer.concat([ + fs.readFileSync(sourceArchivePath), + fs.readFileSync(hiddenSourceArchivePath), + ]) + ); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive( + concatenatedSourceArchivePath, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + } + ), + /undeclared source archive member/i + ); + + fs.writeFileSync( + archiveChecksumsPath, + `${'0'.repeat(64)} ffmpeg.tar.xz\n` + ); + const badChecksumArchivePath = path.join( + temporaryRoot, + 'bad-checksum-source.tar.xz' + ); + const badChecksumTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + badChecksumArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(badChecksumTarResult.error, undefined); + assert.equal(badChecksumTarResult.status, 0, badChecksumTarResult.stderr); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive(badChecksumArchivePath, { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + }), + /archive checksum/i + ); + fs.writeFileSync( + archiveChecksumsPath, + `${pinnedSourceSha256} ffmpeg.tar.xz\n` + ); + + const extraSourcePath = path.join(sourceRoot, 'undeclared-source.txt'); + fs.writeFileSync( + extraSourcePath, + 'not part of the canonical source bundle' + ); + const extraMemberArchivePath = path.join( + temporaryRoot, + 'extra-member-source.tar.xz' + ); + const extraMemberTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + extraMemberArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(extraMemberTarResult.error, undefined); + assert.equal(extraMemberTarResult.status, 0, extraMemberTarResult.stderr); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive(extraMemberArchivePath, { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + }), + /undeclared source archive member/i + ); + fs.unlinkSync(extraSourcePath); + + const oversizedSourceArchive = path.join( + temporaryRoot, + 'oversized-source.tar.xz' + ); + fs.writeFileSync(oversizedSourceArchive, ''); + fs.truncateSync(oversizedSourceArchive, 1024 * 1024 * 1024 + 1); + assert.throws( + () => sourceBindingHelper.inspectSourceArchive(oversizedSourceArchive), + /bounded non-empty regular file/i + ); + + const localChangesPath = path.join(metadataRoot, 'local-changes.patch'); + const emptyTargetPath = path.join(sourceRoot, 'empty-target'); + fs.writeFileSync(emptyTargetPath, ''); + fs.unlinkSync(localChangesPath); + fs.symlinkSync('../empty-target', localChangesPath); + const symlinkArchivePath = path.join( + temporaryRoot, + 'symlinked-source.tar.xz' + ); + const symlinkTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + symlinkArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(symlinkTarResult.error, undefined); + assert.equal(symlinkTarResult.status, 0, symlinkTarResult.stderr); + assert.throws( + () => sourceBindingHelper.inspectSourceArchive(symlinkArchivePath), + /required member.*regular file/i + ); + fs.unlinkSync(localChangesPath); + fs.writeFileSync(localChangesPath, ''); + + const oversizedMemberPath = path.join( + libplaceboRoot, + 'oversized-source-member.bin' + ); + fs.writeFileSync(oversizedMemberPath, ''); + fs.truncateSync(oversizedMemberPath, 128 * 1024 * 1024 + 1); + const oversizedMemberArchivePath = path.join( + temporaryRoot, + 'oversized-member-source.tar.xz' + ); + const oversizedMemberTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + oversizedMemberArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(oversizedMemberTarResult.error, undefined); + assert.equal( + oversizedMemberTarResult.status, + 0, + oversizedMemberTarResult.stderr + ); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive( + oversizedMemberArchivePath, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + } + ), + /member exceeds.*size limit/i + ); + fs.unlinkSync(oversizedMemberPath); + + const snapSourceRoot = path.join(temporaryRoot, 'snap-source'); + const appRoot = path.join(snapSourceRoot, 'usr', 'lib', 'iptvnator'); + const nativeRoot = path.join( + appRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeRoot, { recursive: true }); + const electronHeader = Buffer.alloc(20); + electronHeader.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); + electronHeader.writeUInt16LE(62, 18); + fs.writeFileSync(path.join(appRoot, 'iptvnator.bin'), electronHeader); + const packagedManifest = { + ...fixture.snapPayloads['IPTVnator-amd64.snap'].manifest, + sourceArchive, + sourceRuntime, + }; + fs.writeFileSync( + path.join(nativeRoot, 'embedded-mpv-runtime.json'), + `${JSON.stringify(packagedManifest)}\n` + ); + const snapYamlPath = path.join(snapSourceRoot, 'meta', 'snap.yaml'); + fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + const validSnapYaml = [ + 'name: iptvnator', + 'base: core22', + 'confinement: strict', + 'apps:', + ' iptvnator:', + ' command: iptvnator', + ' environment:', + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform', + ' plugs:', + ' - desktop', + ' - shared-memory', + ' - graphics-core22', + 'plugs:', + ' shared-memory:', + ' interface: shared-memory', + ' private: true', + ' graphics-core22:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + 'layout:', + ' /usr/share/libdrm:', + ' bind: $SNAP/graphics/libdrm', + ' /usr/share/drirc.d:', + ' symlink: $SNAP/graphics/drirc.d', + '', + ].join('\n'); + fs.writeFileSync(snapYamlPath, validSnapYaml); + const graphicsRoot = path.join(snapSourceRoot, 'graphics'); + fs.mkdirSync(graphicsRoot, { mode: 0o755 }); + fs.chmodSync(graphicsRoot, 0o755); + const asarSourceRoot = path.join(temporaryRoot, 'asar-source'); + const appAsarPath = path.join(appRoot, 'resources', 'app.asar'); + fs.mkdirSync(asarSourceRoot); + fs.writeFileSync( + path.join(asarSourceRoot, 'main.js'), + 'module.exports = {}' + ); + await createAsarPackage(asarSourceRoot, appAsarPath); + const cleanReleaseCheckoutRoot = path.join( + temporaryRoot, + 'clean-release-checkout' + ); + const cleanReleaseToolsRoot = path.join(cleanReleaseCheckoutRoot, 'tools'); + fs.mkdirSync(cleanReleaseToolsRoot, { recursive: true }); + for (const toolDirectory of ['packaging', 'embedded-mpv']) { + fs.cpSync( + path.join(workspaceRoot, 'tools', toolDirectory), + path.join(cleanReleaseToolsRoot, toolDirectory), + { recursive: true } + ); + } + const cleanReleaseBoundaryHelperPath = path.join( + cleanReleaseToolsRoot, + 'packaging', + 'validate-snap-release-boundary.mjs' + ); + const dependencyFreeBoundaryResult = childProcess.spawnSync( + process.execPath, + [cleanReleaseBoundaryHelperPath, snapSourceRoot], + { + encoding: 'utf8', + env: { + ...process.env, + NODE_OPTIONS: '', + NODE_PATH: '', + }, + } + ); + assert.equal( + dependencyFreeBoundaryResult.status, + 0, + dependencyFreeBoundaryResult.stderr + ); + assert.deepEqual(JSON.parse(dependencyFreeBoundaryResult.stdout), { + schemaVersion: 1, + errors: [], + }); + const snapPath = path.join(temporaryRoot, 'IPTVnator-amd64.snap'); + fs.writeFileSync(snapPath, 'synthetic Snap bytes'); + const staticValidationCalls = []; + const snapPayload = sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + assert.ok(destinationIndex > 0); + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: (resourceDirectory, options) => { + staticValidationCalls.push({ options, resourceDirectory }); + return []; + }, + } + ); + assert.deepEqual(snapPayload, { + architecture: 'x64', + assetName: 'IPTVnator-amd64.snap', + manifest: packagedManifest, + markerOnly: false, + }); + assert.equal(staticValidationCalls.length, 1); + assert.ok( + staticValidationCalls[0].resourceDirectory.endsWith( + ['payload', 'usr', 'lib', 'iptvnator', 'resources'].join(path.sep) + ) + ); + assert.deepEqual(staticValidationCalls[0].options, { + artifactFormat: 'snap', + executableName: 'iptvnator', + foreignArch: false, + hostPlatform: 'linux', + platform: 'linux', + profile: 'portable', + required: true, + targetArch: 'x64', + targetNames: ['appimage', 'snap'], + }); + + fs.writeFileSync( + snapYamlPath, + validSnapYaml.replace(' - shared-memory\n', '') + ); + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => [], + } + ), + /shared-memory plug/i + ); + fs.writeFileSync(snapYamlPath, validSnapYaml); + + fs.rmSync(asarSourceRoot, { recursive: true }); + const staleAsarNativeRoot = path.join( + asarSourceRoot, + 'electron-backend', + 'native' + ); + fs.mkdirSync(staleAsarNativeRoot, { recursive: true }); + fs.writeFileSync( + path.join(staleAsarNativeRoot, 'embedded-mpv-runtime.json'), + '{}\n' + ); + fs.rmSync(appAsarPath); + await createAsarPackage(asarSourceRoot, appAsarPath); + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => [], + } + ), + /app\.asar.*embedded MPV native payload/i + ); + fs.rmSync(asarSourceRoot, { recursive: true }); + fs.mkdirSync(asarSourceRoot); + fs.writeFileSync( + path.join(asarSourceRoot, 'main.js'), + 'module.exports = {}' + ); + fs.rmSync(appAsarPath); + await createAsarPackage(asarSourceRoot, appAsarPath); + + assert.equal( + sourceBindingHelper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection, + snapPayloads: [snapPayload], + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest: () => [], + } + ), + true + ); + + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => [ + 'Hidden inherited frame-copy artifact.', + ], + } + ), + /Hidden inherited frame-copy artifact/ + ); + + const decoySourceRoot = path.join(temporaryRoot, 'decoy-snap-source'); + const decoyAppRoot = path.join(decoySourceRoot, 'decoy'); + fs.cpSync(appRoot, decoyAppRoot, { recursive: true }); + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(decoySourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => { + throw new Error( + 'Static validation must not inspect a decoy root.' + ); + }, + } + ), + /canonical frame-copy manifest/i + ); + + const manifestPath = path.join(nativeRoot, 'embedded-mpv-runtime.json'); + fs.truncateSync(manifestPath, 16 * 1024 * 1024 + 1); + let oversizedStaticValidationCalls = 0; + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => { + oversizedStaticValidationCalls += 1; + return []; + }, + } + ), + /invalid frame-copy manifest/i + ); + assert.equal(oversizedStaticValidationCalls, 0); +}); + +test('publish workflow installs the source verifier and binds the release tag revision', () => { + const workflow = fs.readFileSync(publishWorkflowPath, 'utf8'); + const parsedWorkflow = parse(workflow); + const verifyJob = parsedWorkflow.jobs['verify-snap']; + const publishJob = parsedWorkflow.jobs['publish-snap']; + const uploadStep = publishJob.steps.find( + (step) => step.name === 'Publish all public-release snaps to edge' + ); + const checkoutStep = verifyJob.steps.find( + (step) => step.name === 'Checkout released tooling' + ); + const snapcraftStep = publishJob.steps.find( + (step) => step.name === 'Install Snapcraft' + ); + const selectStep = verifyJob.steps.find( + (step) => step.name === 'Select exact public release assets' + ); + const downloadStep = verifyJob.steps.find( + (step) => step.name === 'Download exact public release assets' + ); + const verifyStep = verifyJob.steps.find( + (step) => step.name === 'Verify downloaded public release assets' + ); + const sealedVerifyStep = verifyJob.steps.find( + (step) => step.name === 'Reverify sealed public release assets' + ); + const transferStep = verifyJob.steps.find( + (step) => step.name === 'Transfer verified release assets' + ); + const artifactDownloadStep = publishJob.steps.find( + (step) => step.name === 'Download verified release assets' + ); + const transferredSealStep = publishJob.steps.find( + (step) => step.name === 'Seal transferred public release assets' + ); + assert.equal(verifyJob['timeout-minutes'], 45); + assert.equal(publishJob['timeout-minutes'], 20); + assert.equal(publishJob.needs, 'verify-snap'); + assert.match( + workflow, + /apt-get install[\s\S]*binutils[\s\S]*squashfs-tools[\s\S]*xz-utils/ + ); + assert.match( + workflow, + /release-snap-assets\.cjs verify[\s\S]*--repository-revision/ + ); + assert.ok( + workflow.indexOf('--repository-revision') < + workflow.indexOf('snapcraft upload --release=edge') + ); + assert.equal(JSON.stringify(verifyJob).includes('snapcraft_token'), false); + assert.equal( + checkoutStep.with?.['persist-credentials'], + false, + 'release checkout must not persist github.token for later steps' + ); + assert.equal( + checkoutStep.uses, + 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' + ); + assert.equal(snapcraftStep.uses, undefined); + assert.match( + snapcraftStep.run, + /sudo snap install snapcraft --classic --channel=stable/ + ); + assert.match(verifyStep.run, /--verified-directory/); + assert.match(verifyStep.run, /chown -R root:root/); + assert.match(verifyStep.run, /chmod 0555/); + assert.match(verifyStep.run, /chmod 0444/); + assert.match( + verifyStep.run, + /SEALED_ASSET_PARENT="\/var\/lib\/iptvnator-snap-release"/ + ); + assert.match( + verifyStep.run, + /sudo mv "\$\{VERIFIED_ASSET_STAGING\}" "\$\{SEALED_ASSET_DIRECTORY\}"/ + ); + assert.ok(sealedVerifyStep); + assert.equal(sealedVerifyStep.env, undefined); + assert.match( + sealedVerifyStep.run, + /release-snap-assets\.cjs verify-sealed/ + ); + assert.ok( + verifyJob.steps.indexOf(sealedVerifyStep) < + verifyJob.steps.indexOf(transferStep) + ); + assert.equal( + transferStep.uses, + 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' + ); + assert.equal( + artifactDownloadStep.uses, + 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' + ); + assert.ok( + publishJob.steps.indexOf(artifactDownloadStep) < + publishJob.steps.indexOf(transferredSealStep) + ); + assert.ok( + publishJob.steps.indexOf(transferredSealStep) < + publishJob.steps.indexOf(snapcraftStep) + ); + assert.ok( + publishJob.steps.indexOf(snapcraftStep) < + publishJob.steps.indexOf(uploadStep) + ); + assert.match( + uploadStep.run, + /VERIFIED_ASSET_DIRECTORY="\/var\/lib\/iptvnator-snap-release\/assets"/ + ); + assert.doesNotMatch(uploadStep.run, /\bnode\b/); + assert.doesNotMatch(uploadStep.run, /release-snap-assets\.cjs/); + assert.doesNotMatch(uploadStep.run, /\bfind\b|\bsort\b/); + assert.match( + uploadStep.run, + /SNAP_FILES=\("\$\{VERIFIED_ASSET_DIRECTORY\}"\/\*\.snap\)/ + ); + assert.match( + uploadStep.run, + /SNAPCRAFT_STORE_CREDENTIALS="\$\{STORE_CREDENTIALS\}" \/snap\/bin\/snapcraft upload/ + ); + assert.doesNotMatch( + uploadStep.run, + /snap-release-downloads\/\$\{SNAP_NAME\}/ + ); + assert.equal( + Object.hasOwn(publishJob.env ?? {}, 'SNAPCRAFT_STORE_CREDENTIALS'), + false + ); + assert.equal(Object.hasOwn(publishJob.env ?? {}, 'GH_TOKEN'), false); + assert.deepEqual(selectStep.env, { + GH_TOKEN: '${{ github.token }}', + }); + assert.deepEqual(downloadStep.env, { + GH_TOKEN: '${{ github.token }}', + }); + assert.equal(verifyStep.env, undefined); + assert.deepEqual(uploadStep.env, { + SNAPCRAFT_STORE_CREDENTIALS: '${{ secrets.snapcraft_token }}', + }); +}); diff --git a/tools/packaging/release-snap-source-binding.cjs b/tools/packaging/release-snap-source-binding.cjs new file mode 100644 index 000000000..966df433b --- /dev/null +++ b/tools/packaging/release-snap-source-binding.cjs @@ -0,0 +1,1765 @@ +'use strict'; + +const childProcess = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { + validateLinuxRuntimeManifest, +} = require('../embedded-mpv/linux-runtime-manifest.cjs'); +const { + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES, +} = require('../embedded-mpv/build-linux-runtime.cjs'); +const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs'); +const { + SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + SOURCE_ARCHIVE_NAME, + sha256File, + validateLinuxSourceArchiveBinding, +} = require('../embedded-mpv/linux-source-archive-contract.cjs'); +const { + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + inventoryLinuxRuntimeSourceSnapshot, + validateLinuxRuntimeSourceSnapshot, +} = require('./prepare-linux-runtime-source-snapshot.cjs'); + +const COMMAND_OUTPUT_MAX_BUFFER_BYTES = 16 * 1024 * 1024; +const SQUASHFS_LIST_MAX_BUFFER_BYTES = 64 * 1024 * 1024; +const SOURCE_MEMBER_MAX_BUFFER_BYTES = 128 * 1024 * 1024; +const COMMAND_TIMEOUT_MS = 120_000; +const SOURCE_ARCHIVE_MAX_BYTES = 1024 * 1024 * 1024; +const SOURCE_ARCHIVE_EXTRACTED_MAX_BYTES = 2 * 1024 * 1024 * 1024; +const SNAP_ARCHIVE_MAX_BYTES = 1024 * 1024 * 1024; +const SNAP_EXTRACTED_MAX_BYTES = 2 * 1024 * 1024 * 1024; +const SOURCE_INDEX_SCHEMA_VERSION = 3; +const SNAP_PAYLOAD_ENTRY_LIMIT = 250_000; +const SOURCE_ARCHIVE_ENTRY_LIMIT = 250_000; +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const SAFE_BASENAME_PATTERN = /^[A-Za-z0-9_+.-]+$/; +const FRAME_COPY_MANIFEST_NAME = 'embedded-mpv-runtime.json'; +const FRAME_COPY_UNAVAILABLE_MARKER_NAME = 'embedded-mpv-unavailable.txt'; +const SNAP_RELEASE_BOUNDARY_SCHEMA_VERSION = 1; +const SNAP_RELEASE_BOUNDARY_HELPER_PATH = path.join( + __dirname, + 'validate-snap-release-boundary.mjs' +); +const NATIVE_PAYLOAD_SUFFIX = [ + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native', +].join('/'); +const SOURCE_TOOLING_FILES = Object.freeze([ + { + archivePath: 'tooling/build-linux-runtime.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'build-linux-runtime.cjs' + ), + }, + { + archivePath: 'tooling/build-linux-runtime.mjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'build-linux-runtime.mjs' + ), + }, + { + archivePath: 'tooling/generate-linux-runtime-notices.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'generate-linux-runtime-notices.cjs' + ), + }, + { + archivePath: 'tooling/linux-runtime-manifest.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'linux-runtime-manifest.cjs' + ), + }, + { + archivePath: 'tooling/linux-source-archive-contract.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'linux-source-archive-contract.cjs' + ), + }, + { + archivePath: 'tooling/stage-runtime.mjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'stage-runtime.mjs' + ), + }, + { + archivePath: 'tooling/prepare-linux-runtime-source-snapshot.cjs', + checkoutPath: path.join( + __dirname, + 'prepare-linux-runtime-source-snapshot.cjs' + ), + }, +]); + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function commandFailureDetails(result) { + const stderr = Buffer.isBuffer(result?.stderr) + ? result.stderr.toString('utf8') + : String(result?.stderr ?? ''); + return stderr.trim().slice(0, 2048); +} + +function defaultRunCommand( + command, + args, + { + encoding = 'utf8', + input, + maxBuffer = COMMAND_OUTPUT_MAX_BUFFER_BYTES, + timeout = COMMAND_TIMEOUT_MS, + } = {} +) { + const spawnOptions = { + killSignal: 'SIGKILL', + maxBuffer, + stdio: 'pipe', + timeout, + windowsHide: true, + }; + if (input !== undefined) { + spawnOptions.input = input; + } + if (encoding !== null) { + spawnOptions.encoding = encoding; + } + const result = childProcess.spawnSync(command, args, spawnOptions); + if (result.error) { + throw new Error( + `Unable to run ${command}: ${ + result.error instanceof Error + ? result.error.message + : String(result.error) + }` + ); + } + if (result.status !== 0) { + const details = commandFailureDetails(result); + throw new Error( + `${command} exited with status ${String(result.status)}${ + details ? `: ${details}` : '.' + }` + ); + } + return result.stdout ?? (encoding === null ? Buffer.alloc(0) : ''); +} + +function normalizeTarMember(rawName) { + if ( + typeof rawName !== 'string' || + rawName.length === 0 || + rawName.includes('\\') || + [...rawName].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { + throw new Error('Source archive contains an unsafe member name.'); + } + let memberName = rawName; + while (memberName.startsWith('./')) { + memberName = memberName.slice(2); + } + const isDirectory = memberName.endsWith('/'); + if (isDirectory) { + memberName = memberName.slice(0, -1); + } + if (memberName === '') { + return null; + } + const parts = memberName.split('/'); + if ( + path.posix.isAbsolute(memberName) || + parts.some( + (part) => + part === '' || + part === '.' || + part === '..' || + !SAFE_BASENAME_PATTERN.test(part) + ) + ) { + throw new Error( + `Source archive contains an unsafe member name: ${rawName}` + ); + } + return { + isDirectory, + name: parts.join('/'), + rawName, + }; +} + +function parseVerboseTarMembers(listing) { + if (typeof listing !== 'string') { + throw new Error('Unable to read source archive member types.'); + } + const memberTypes = new Map(); + for (const line of listing.split(/\r?\n/).filter(Boolean)) { + let type = line[0]; + if (!['-', 'd', 'h', 'l'].includes(type)) { + throw new Error( + 'Source archive contains an unsupported member type.' + ); + } + let identity = line.trimEnd(); + let linkTarget = null; + for (const marker of [' -> ', ' link to ']) { + const markerIndex = identity.indexOf(marker); + if (markerIndex !== -1) { + linkTarget = identity.slice(markerIndex + marker.length); + identity = identity.slice(0, markerIndex); + if (marker === ' link to ') { + type = 'h'; + } + break; + } + } + const trimmedIdentity = identity.trim(); + const rawName = trimmedIdentity.split(/\s+/).at(-1); + const rawNameOffset = trimmedIdentity.lastIndexOf(rawName); + const metadataTokens = trimmedIdentity + .slice(0, rawNameOffset) + .trim() + .split(/\s+/); + const dateTokenIndex = metadataTokens.findIndex( + (token) => + /^\d{4}-\d{2}-\d{2}$/.test(token) || + /^(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/.test( + token + ) + ); + const sizeToken = + dateTokenIndex > 0 ? metadataTokens[dateTokenIndex - 1] : ''; + const size = Number(sizeToken); + if ( + !/^\d+$/.test(sizeToken) || + !Number.isSafeInteger(size) || + size < 0 + ) { + throw new Error( + 'Unable to read a bounded source archive member size.' + ); + } + const member = normalizeTarMember(rawName); + if (!member) { + continue; + } + if (memberTypes.has(member.name)) { + throw new Error( + `Source archive contains duplicate verbose member: ${member.name}` + ); + } + if (member.isDirectory !== (type === 'd')) { + throw new Error( + `Source archive member type does not match its name: ${member.name}` + ); + } + if (type === 'l' || type === 'h') { + if ( + typeof linkTarget !== 'string' || + linkTarget.length === 0 || + linkTarget.includes('\\') || + path.posix.isAbsolute(linkTarget) || + [...linkTarget].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { + throw new Error( + `Source archive contains an unsafe link target: ${member.name}` + ); + } + const resolvedTarget = + type === 'h' + ? path.posix.normalize(linkTarget) + : path.posix.normalize( + path.posix.join( + path.posix.dirname(member.name), + linkTarget + ) + ); + if (resolvedTarget === '..' || resolvedTarget.startsWith('../')) { + throw new Error( + `Source archive link target escapes its root: ${member.name}` + ); + } + linkTarget = resolvedTarget.replace(/^\.\/+/, ''); + } else if (linkTarget !== null) { + throw new Error( + `Source archive regular member has link metadata: ${member.name}` + ); + } + memberTypes.set(member.name, { linkTarget, size, type }); + } + for (const [memberName, member] of memberTypes) { + if ( + member.type === 'h' && + memberTypes.get(member.linkTarget)?.type !== '-' + ) { + throw new Error( + `Source archive hardlink target must be a regular member: ${memberName}` + ); + } + } + return memberTypes; +} + +function listTarMembers(archivePath, runCommand) { + const listing = runCommand( + 'tar', + ['--list', '--ignore-zeros', '--xz', '--file', archivePath], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + } + ); + if (typeof listing !== 'string') { + throw new Error('Unable to read source archive member listing.'); + } + const verboseMembers = parseVerboseTarMembers( + runCommand( + 'tar', + [ + '--list', + '--verbose', + '--ignore-zeros', + '--xz', + '--file', + archivePath, + ], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + } + ) + ); + const members = new Map(); + let extractedBytes = 0; + let entryCount = 0; + for (const rawName of listing.split(/\r?\n/).filter(Boolean)) { + const member = normalizeTarMember(rawName); + if (!member) { + continue; + } + if (members.has(member.name)) { + throw new Error( + `Source archive contains duplicate member: ${member.name}` + ); + } + const verboseMember = verboseMembers.get(member.name); + if (!verboseMember) { + throw new Error( + `Source archive member type is missing: ${member.name}` + ); + } + entryCount += 1; + if (entryCount > SOURCE_ARCHIVE_ENTRY_LIMIT) { + throw new Error( + 'Source archive exceeds the release-verifier entry limit.' + ); + } + if (verboseMember.type === '-') { + if (verboseMember.size > SOURCE_MEMBER_MAX_BUFFER_BYTES) { + throw new Error( + `Source archive member exceeds the release-verifier size limit: ${member.name}` + ); + } + extractedBytes += verboseMember.size; + if ( + !Number.isSafeInteger(extractedBytes) || + extractedBytes > SOURCE_ARCHIVE_EXTRACTED_MAX_BYTES + ) { + throw new Error( + 'Source archive exceeds the release-verifier extracted-size limit.' + ); + } + } + members.set(member.name, { + ...member, + ...verboseMember, + }); + } + if (verboseMembers.size !== members.size) { + throw new Error( + 'Source archive member and type listings do not match.' + ); + } + return members; +} + +function readTarMember( + archivePath, + members, + memberName, + runCommand, + maxBuffer +) { + const member = members.get(memberName); + if (!member || member.type !== '-') { + throw new Error( + `Source archive required member must be a regular file: ${memberName}` + ); + } + const contents = runCommand( + 'tar', + [ + '--extract', + '--xz', + '--to-stdout', + '--file', + archivePath, + '--', + member.rawName, + ], + { + encoding: null, + maxBuffer, + } + ); + const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents); + if (buffer.length !== member.size) { + throw new Error( + `Source archive member size changed during inspection: ${memberName}` + ); + } + return buffer; +} + +function parseJsonMember(contents, memberName) { + try { + return JSON.parse(contents.toString('utf8')); + } catch { + throw new Error( + `Source archive member is not valid JSON: ${memberName}` + ); + } +} + +function sourceMemberRecord( + archivePath, + members, + memberName, + runCommand, + relativePath +) { + const contents = readTarMember( + archivePath, + members, + memberName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ); + return { + path: relativePath, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + size: contents.length, + }; +} + +function inspectLegalFiles( + archivePath, + members, + runCommand, + rootName, + rootFiles +) { + const rootPrefix = `${rootName}/`; + const licensePrefix = `${rootPrefix}licenses/`; + const allowedRootFiles = new Set( + rootFiles.map((name) => `${rootPrefix}${name}`) + ); + const records = []; + for (const member of members.values()) { + if (member.isDirectory || !member.name.startsWith(rootPrefix)) { + continue; + } + if ( + !member.name.startsWith(licensePrefix) && + !allowedRootFiles.has(member.name) + ) { + throw new Error( + `Source archive contains an undeclared ${rootName} file: ${member.name}` + ); + } + if (member.name.startsWith(licensePrefix)) { + records.push( + sourceMemberRecord( + archivePath, + members, + member.name, + runCommand, + member.name.slice(rootPrefix.length) + ) + ); + } + } + return records.sort(({ path: left }, { path: right }) => + left.localeCompare(right) + ); +} + +function inspectLibplaceboSourceSnapshot( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 +) { + const sourceRootName = 'git/libplacebo'; + const sourceRootMember = members.get(sourceRootName); + if (!sourceRootMember || sourceRootMember.type !== 'd') { + throw new Error( + 'Source archive must contain a regular libplacebo source directory.' + ); + } + const sourceMembers = [...members.values()].filter( + ({ name }) => + name === sourceRootName || name.startsWith(`${sourceRootName}/`) + ); + if (sourceMembers.length <= 1) { + throw new Error( + 'Source archive must contain the prepared libplacebo source snapshot.' + ); + } + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-libplacebo-source-verifier-') + ); + try { + const memberListPath = path.join( + temporaryRoot, + 'libplacebo-members.txt' + ); + fs.writeFileSync( + memberListPath, + `${sourceMembers.map(({ rawName }) => rawName).join('\n')}\n`, + { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + } + ); + runCommand( + 'tar', + [ + '--extract', + '--xz', + '--file', + archivePath, + '--directory', + temporaryRoot, + '--no-same-owner', + '--no-same-permissions', + '--no-recursion', + '-T', + memberListPath, + ], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + } + ); + const sourceSnapshot = inventoryLinuxRuntimeSourceSnapshot( + path.join(temporaryRoot, 'git', 'libplacebo') + ); + return validateLinuxRuntimeSourceSnapshot(sourceSnapshot, { + expectedSha256: expectedSourceSnapshotSha256, + }); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function inspectSourceCompliance( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 +) { + if ([...members.keys()].some((name) => name.split('/').includes('.git'))) { + throw new Error( + 'Source archive must not contain VCS metadata directories.' + ); + } + const expectedToolingNames = SOURCE_TOOLING_FILES.map( + ({ archivePath: memberName }) => memberName + ).sort(); + const actualToolingNames = [...members.values()] + .filter( + ({ isDirectory, name }) => + !isDirectory && name.startsWith('tooling/') + ) + .map(({ name }) => name) + .sort(); + if (!isDeepStrictEqual(actualToolingNames, expectedToolingNames)) { + throw new Error( + 'Source archive must contain the exact released runtime tooling set.' + ); + } + for (const toolingFile of SOURCE_TOOLING_FILES) { + const archivedContents = readTarMember( + archivePath, + members, + toolingFile.archivePath, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ); + const checkoutContents = fs.readFileSync(toolingFile.checkoutPath); + if (!archivedContents.equals(checkoutContents)) { + throw new Error( + `Source archive tooling does not match the released tag: ${toolingFile.archivePath}` + ); + } + } + const libplaceboSourceSnapshot = inspectLibplaceboSourceSnapshot( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 + ); + const licenseInputManifestName = + 'license-inputs/linux-runtime-license-inputs.json'; + const noticeManifestName = 'notices/embedded-mpv-notices.json'; + const noticeFileName = 'notices/THIRD_PARTY_NOTICES.txt'; + const licenseInputs = parseJsonMember( + readTarMember( + archivePath, + members, + licenseInputManifestName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ), + licenseInputManifestName + ); + const notices = parseJsonMember( + readTarMember( + archivePath, + members, + noticeManifestName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ), + noticeManifestName + ); + const noticeFile = sourceMemberRecord( + archivePath, + members, + noticeFileName, + runCommand, + 'THIRD_PARTY_NOTICES.txt' + ); + return { + libplaceboSourceSnapshot, + licenseInputFiles: inspectLegalFiles( + archivePath, + members, + runCommand, + 'license-inputs', + ['linux-runtime-license-inputs.json'] + ), + licenseInputs, + noticeFile, + noticeLicenseFiles: inspectLegalFiles( + archivePath, + members, + runCommand, + 'notices', + ['embedded-mpv-notices.json', 'THIRD_PARTY_NOTICES.txt'] + ), + notices, + toolingValidated: true, + }; +} + +function parseArchiveChecksumRecords(contents) { + const text = contents.toString('utf8'); + if (!text.endsWith('\n') || text.includes('\r')) { + throw new Error( + 'Source archive checksum manifest must use canonical LF-terminated sha256sum records.' + ); + } + const lines = text.slice(0, -1).split('\n'); + const records = lines.map((line) => { + const match = /^([a-f0-9]{64}) {2}([A-Za-z0-9_+.-]+)$/.exec(line); + if (!match) { + throw new Error( + 'Source archive checksum manifest contains an invalid record.' + ); + } + return { + name: match[2], + sha256: match[1], + }; + }); + return normalizeArchiveRecords(records, 'Source archive checksum manifest'); +} + +function assertExactSourceArchiveLayout(members, archiveFiles, compliance) { + const expectedFiles = new Set([ + ...archiveFiles.map(({ name }) => `archives/${name}`), + ...SOURCE_TOOLING_FILES.map(({ archivePath }) => archivePath), + 'license-inputs/linux-runtime-license-inputs.json', + ...compliance.licenseInputFiles.map( + ({ path: relativePath }) => `license-inputs/${relativePath}` + ), + 'metadata/archive-sha256.txt', + 'metadata/iptvnator-git-revision.txt', + 'metadata/local-changes.patch', + 'metadata/runtime-manifest.json', + 'metadata/source-index.json', + 'notices/THIRD_PARTY_NOTICES.txt', + 'notices/embedded-mpv-notices.json', + ...compliance.noticeLicenseFiles.map( + ({ path: relativePath }) => `notices/${relativePath}` + ), + ]); + const expectedDirectories = new Set(['git']); + for (const fileName of expectedFiles) { + const parts = fileName.split('/'); + parts.pop(); + while (parts.length > 0) { + expectedDirectories.add(parts.join('/')); + parts.pop(); + } + } + + const sourceRootName = 'git/libplacebo'; + for (const member of members.values()) { + if ( + member.name === sourceRootName || + member.name.startsWith(`${sourceRootName}/`) + ) { + if ( + member.type === 'h' && + !member.linkTarget.startsWith(`${sourceRootName}/`) + ) { + throw new Error( + `Source archive hardlink leaves the libplacebo snapshot: ${member.name}` + ); + } + continue; + } + if (expectedFiles.has(member.name)) { + if (member.type !== '-') { + throw new Error( + `Canonical source archive file must be regular: ${member.name}` + ); + } + continue; + } + if (expectedDirectories.has(member.name)) { + if (member.type !== 'd') { + throw new Error( + `Canonical source archive directory has the wrong type: ${member.name}` + ); + } + continue; + } + throw new Error( + `Source archive contains an undeclared source archive member: ${member.name}` + ); + } + + for (const fileName of expectedFiles) { + if (members.get(fileName)?.type !== '-') { + throw new Error( + `Source archive is missing canonical regular file: ${fileName}` + ); + } + } + for (const directoryName of expectedDirectories) { + if (members.get(directoryName)?.type !== 'd') { + throw new Error( + `Source archive is missing canonical directory: ${directoryName}` + ); + } + } +} + +function inspectSourceArchive( + archivePath, + { + expectedSourceSnapshotSha256 = EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + runCommand = defaultRunCommand, + } = {} +) { + const archiveStat = fs.lstatSync(archivePath); + if ( + !archiveStat.isFile() || + archiveStat.isSymbolicLink() || + archiveStat.size === 0 || + archiveStat.size > SOURCE_ARCHIVE_MAX_BYTES + ) { + throw new Error( + 'Linux source archive must be a bounded non-empty regular file.' + ); + } + const members = listTarMembers(archivePath, runCommand); + const readMetadata = (memberName) => + readTarMember( + archivePath, + members, + memberName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ); + const sourceRuntime = parseJsonMember( + readMetadata('metadata/runtime-manifest.json'), + 'metadata/runtime-manifest.json' + ); + const sourceIndex = parseJsonMember( + readMetadata('metadata/source-index.json'), + 'metadata/source-index.json' + ); + const repositoryRevision = readMetadata( + 'metadata/iptvnator-git-revision.txt' + ) + .toString('utf8') + .trim(); + const localChanges = readMetadata('metadata/local-changes.patch'); + const archiveMemberNames = [...members.values()] + .filter( + ({ isDirectory, name }) => + !isDirectory && + name.startsWith('archives/') && + name.split('/').length === 2 + ) + .map(({ name }) => name.slice('archives/'.length)) + .sort(); + const archiveFiles = archiveMemberNames.map((name) => ({ + name, + sha256: crypto + .createHash('sha256') + .update( + readTarMember( + archivePath, + members, + `archives/${name}`, + runCommand, + SOURCE_MEMBER_MAX_BUFFER_BYTES + ) + ) + .digest('hex'), + })); + const archiveChecksums = parseArchiveChecksumRecords( + readMetadata('metadata/archive-sha256.txt') + ); + if ( + !isDeepStrictEqual( + archiveChecksums, + normalizeArchiveRecords( + archiveFiles, + 'Source archive inspected archives' + ) + ) + ) { + throw new Error( + 'Source archive checksum manifest does not match the inspected archives.' + ); + } + const compliance = inspectSourceCompliance( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 + ); + assertExactSourceArchiveLayout(members, archiveFiles, compliance); + return { + archiveSha256: sha256File(archivePath), + archiveFiles, + compliance, + localChanges, + repositoryRevision, + sourceIndex, + sourceRuntime, + }; +} + +function readElfArchitecture(binaryPath) { + const descriptor = fs.openSync(binaryPath, 'r'); + try { + const header = Buffer.alloc(20); + const bytesRead = fs.readSync(descriptor, header, 0, header.length, 0); + if ( + bytesRead !== header.length || + header[0] !== 0x7f || + header[1] !== 0x45 || + header[2] !== 0x4c || + header[3] !== 0x46 || + ![1, 2].includes(header[4]) || + header[5] !== 1 + ) { + throw new Error('Snap Electron executable is not a supported ELF.'); + } + const machine = header.readUInt16LE(18); + if (machine === 62 && header[4] === 2) { + return 'x64'; + } + if (machine === 183 && header[4] === 2) { + return 'arm64'; + } + if (machine === 40 && header[4] === 1) { + return 'arm'; + } + throw new Error( + `Snap Electron executable uses unsupported ELF machine ${machine}.` + ); + } finally { + fs.closeSync(descriptor); + } +} + +function collectSnapNativePayloads(extractionRoot) { + const candidates = []; + const pendingDirectories = [extractionRoot]; + let entryCount = 0; + while (pendingDirectories.length > 0) { + const directoryPath = pendingDirectories.pop(); + const entries = fs + .readdirSync(directoryPath, { withFileTypes: true }) + .sort(({ name: left }, { name: right }) => + left.localeCompare(right) + ); + for (const entry of entries) { + entryCount += 1; + if (entryCount > SNAP_PAYLOAD_ENTRY_LIMIT) { + throw new Error( + 'Extracted Snap exceeds the release-verifier entry limit.' + ); + } + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + pendingDirectories.push(entryPath); + continue; + } + if ( + !entry.isFile() || + ![ + FRAME_COPY_MANIFEST_NAME, + FRAME_COPY_UNAVAILABLE_MARKER_NAME, + ].includes(entry.name) + ) { + continue; + } + const relativePath = path + .relative(extractionRoot, entryPath) + .split(path.sep) + .join('/'); + const expectedSuffix = `${NATIVE_PAYLOAD_SUFFIX}/${entry.name}`; + if ( + relativePath === expectedSuffix || + relativePath.endsWith(`/${expectedSuffix}`) + ) { + candidates.push(entryPath); + } + } + } + return candidates; +} + +function inspectSquashfsListing(snapPath, runCommand) { + const listing = runCommand('unsquashfs', ['-lln', snapPath], { + encoding: 'utf8', + maxBuffer: SQUASHFS_LIST_MAX_BUFFER_BYTES, + timeout: COMMAND_TIMEOUT_MS, + }); + if (typeof listing !== 'string') { + throw new Error('Unable to inspect the Snap filesystem listing.'); + } + let entryCount = 0; + let extractedBytes = 0; + for (const line of listing.split(/\r?\n/).filter(Boolean)) { + const match = + /^([bcdlps-])\S*\s+\d+\/\d+\s+(\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+.+$/.exec( + line + ); + if (!match || !['-', 'd', 'l'].includes(match[1])) { + throw new Error( + 'Snap filesystem listing contains an invalid entry.' + ); + } + entryCount += 1; + if (entryCount > SNAP_PAYLOAD_ENTRY_LIMIT) { + throw new Error('Snap exceeds the release-verifier entry limit.'); + } + if (match[1] === '-') { + extractedBytes += Number(match[2]); + if ( + !Number.isSafeInteger(extractedBytes) || + extractedBytes > SNAP_EXTRACTED_MAX_BYTES + ) { + throw new Error( + 'Snap exceeds the release-verifier extracted-size limit.' + ); + } + } + } + if (entryCount === 0) { + throw new Error('Snap filesystem listing must not be empty.'); + } +} + +function validateSnapReleaseBoundary(extractionRoot) { + const output = defaultRunCommand( + process.execPath, + [SNAP_RELEASE_BOUNDARY_HELPER_PATH, extractionRoot], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + timeout: COMMAND_TIMEOUT_MS, + } + ); + if (typeof output !== 'string' || !/^[^\r\n]+\n$/.test(output)) { + throw new Error( + 'Snap release-boundary validator must emit exactly one newline-terminated JSON line.' + ); + } + let payload; + try { + payload = JSON.parse(output.slice(0, -1)); + } catch { + throw new Error( + 'Snap release-boundary validator emitted malformed JSON.' + ); + } + if ( + !isObject(payload) || + !isDeepStrictEqual(Object.keys(payload).sort(), [ + 'errors', + 'schemaVersion', + ]) || + payload.schemaVersion !== SNAP_RELEASE_BOUNDARY_SCHEMA_VERSION || + !Array.isArray(payload.errors) || + payload.errors.some( + (error) => + typeof error !== 'string' || + error.length === 0 || + error.length > 4096 + ) + ) { + throw new Error( + 'Snap release-boundary validator emitted an invalid result.' + ); + } + return payload.errors; +} + +function inspectSnapPayload( + snapPath, + asset, + { + runCommand = defaultRunCommand, + validatePackagedEmbeddedMpv: + validatePackaged = validatePackagedEmbeddedMpv, + } = {} +) { + const snapStat = fs.lstatSync(snapPath); + if ( + !snapStat.isFile() || + snapStat.isSymbolicLink() || + snapStat.size === 0 || + snapStat.size > SNAP_ARCHIVE_MAX_BYTES + ) { + throw new Error( + `Snap ${asset.name} must be a bounded non-empty regular file.` + ); + } + inspectSquashfsListing(snapPath, runCommand); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-source-verifier-') + ); + try { + const extractionRoot = path.join(temporaryRoot, 'payload'); + runCommand( + 'unsquashfs', + ['-no-progress', '-dest', extractionRoot, snapPath], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + timeout: COMMAND_TIMEOUT_MS, + } + ); + const payloads = collectSnapNativePayloads(extractionRoot); + const canonicalNativeDirectory = path.join( + extractionRoot, + 'usr', + 'lib', + 'iptvnator', + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const canonicalPayloads = [ + FRAME_COPY_MANIFEST_NAME, + FRAME_COPY_UNAVAILABLE_MARKER_NAME, + ] + .map((name) => path.join(canonicalNativeDirectory, name)) + .filter((candidate) => payloads.includes(candidate)); + if ( + payloads.length !== 1 || + canonicalPayloads.length !== 1 || + payloads[0] !== canonicalPayloads[0] + ) { + throw new Error( + `Snap ${asset.name} must contain exactly one canonical frame-copy manifest or unavailable marker.` + ); + } + const boundaryErrors = validateSnapReleaseBoundary(extractionRoot); + if (boundaryErrors.length > 0) { + throw new Error( + `Snap ${asset.name} failed public-release boundary validation: ${boundaryErrors.join( + '; ' + )}` + ); + } + const payloadPath = canonicalPayloads[0]; + const resourcesDirectory = path.join( + extractionRoot, + 'usr', + 'lib', + 'iptvnator', + 'resources' + ); + const electronPath = path.join( + extractionRoot, + 'usr', + 'lib', + 'iptvnator', + 'iptvnator.bin' + ); + const electronStat = fs.lstatSync(electronPath); + if ( + !electronStat.isFile() || + electronStat.isSymbolicLink() || + electronStat.size < 20 + ) { + throw new Error( + `Snap ${asset.name} is missing its regular Electron executable.` + ); + } + const architecture = readElfArchitecture(electronPath); + if (path.basename(payloadPath) === FRAME_COPY_MANIFEST_NAME) { + const manifestStat = fs.lstatSync(payloadPath); + if ( + !manifestStat.isFile() || + manifestStat.isSymbolicLink() || + manifestStat.size === 0 || + manifestStat.size > COMMAND_OUTPUT_MAX_BUFFER_BYTES + ) { + throw new Error( + `Snap ${asset.name} contains an invalid frame-copy manifest.` + ); + } + } + const staticErrors = validatePackaged(resourcesDirectory, { + artifactFormat: 'snap', + executableName: 'iptvnator', + foreignArch: architecture !== 'x64', + hostPlatform: 'linux', + platform: 'linux', + profile: 'portable', + required: true, + targetArch: architecture, + targetNames: ['appimage', 'snap'], + }); + if (!Array.isArray(staticErrors) || staticErrors.length > 0) { + throw new Error( + `Snap ${asset.name} failed static frame-copy validation${ + Array.isArray(staticErrors) && staticErrors.length > 0 + ? `: ${staticErrors.join('; ')}` + : '.' + }` + ); + } + if (path.basename(payloadPath) === FRAME_COPY_UNAVAILABLE_MARKER_NAME) { + return { + architecture, + assetName: asset.name, + manifest: null, + markerOnly: true, + }; + } + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(payloadPath, 'utf8')); + } catch { + throw new Error( + `Snap ${asset.name} contains malformed frame-copy manifest JSON.` + ); + } + return { + architecture, + assetName: asset.name, + manifest, + markerOnly: false, + }; + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function normalizeArchiveRecords(records, label) { + if (!Array.isArray(records) || records.length === 0) { + throw new Error(`${label} must contain source archive checksums.`); + } + const normalized = records.map((record) => { + if ( + !isObject(record) || + !isDeepStrictEqual(Object.keys(record).sort(), [ + 'name', + 'sha256', + ]) || + typeof record.name !== 'string' || + !SAFE_BASENAME_PATTERN.test(record.name) || + typeof record.sha256 !== 'string' || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error(`${label} contains an invalid archive record.`); + } + return { + name: record.name, + sha256: record.sha256, + }; + }); + const names = normalized.map(({ name }) => name); + const hashes = normalized.map(({ sha256 }) => sha256); + if ( + new Set(names).size !== names.length || + new Set(hashes).size !== hashes.length + ) { + throw new Error(`${label} contains duplicate archive records.`); + } + return normalized.sort(({ name: left }, { name: right }) => + left.localeCompare(right) + ); +} + +function hasExactFields(value, fields) { + return ( + isObject(value) && + isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort()) + ); +} + +function safeRelativePath(value) { + return ( + typeof value === 'string' && + value.length > 0 && + !value.includes('\\') && + !path.posix.isAbsolute(value) && + value + .split('/') + .every( + (part) => + part !== '' && + part !== '.' && + part !== '..' && + SAFE_BASENAME_PATTERN.test(part) + ) + ); +} + +function normalizeLegalFileRecords(records, label) { + if (!Array.isArray(records) || records.length === 0) { + throw new Error(`${label} must contain legal files.`); + } + const normalized = records.map((record) => { + if ( + !hasExactFields(record, ['path', 'sha256', 'size']) || + !safeRelativePath(record.path) || + !record.path.startsWith('licenses/') || + !Number.isSafeInteger(record.size) || + record.size <= 0 || + typeof record.sha256 !== 'string' || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error(`${label} contains an invalid legal file.`); + } + return { ...record }; + }); + const paths = normalized.map(({ path: filePath }) => filePath); + if (new Set(paths).size !== paths.length) { + throw new Error(`${label} contains duplicate legal files.`); + } + return normalized.sort(({ path: left }, { path: right }) => + left.localeCompare(right) + ); +} + +function sourcePackageLegalIdentity(sourcePackage) { + return Object.fromEntries( + [ + 'version', + 'sourceUrl', + 'sourceTag', + 'sourceSha256', + 'sourceGitCommit', + 'license', + ] + .filter((field) => Object.hasOwn(sourcePackage, field)) + .map((field) => [field, sourcePackage[field]]) + ); +} + +function verifySourceArchiveCompliance({ + compliance, + sourceIndex, + sourceRuntime, +}) { + if ( + !hasExactFields(compliance, [ + 'libplaceboSourceSnapshot', + 'licenseInputFiles', + 'licenseInputs', + 'noticeFile', + 'noticeLicenseFiles', + 'notices', + 'toolingValidated', + ]) || + compliance.toolingValidated !== true + ) { + throw new Error('Source archive compliance payload is incomplete.'); + } + const { licenseInputs, notices } = compliance; + if ( + !hasExactFields(licenseInputs, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'packages', + ]) || + licenseInputs.schemaVersion !== 1 || + licenseInputs.origin !== 'pinned-linux-runtime-license-inputs' || + licenseInputs.platform !== 'linux' || + licenseInputs.arch !== 'x64' || + !Array.isArray(licenseInputs.packages) || + !hasExactFields(notices, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'noticeFile', + 'packages', + 'totalBytes', + ]) || + notices.schemaVersion !== 1 || + notices.origin !== 'pinned-linux-runtime-upstream-licenses' || + notices.platform !== 'linux' || + notices.arch !== 'x64' || + !Array.isArray(notices.packages) + ) { + throw new Error('Source archive compliance manifests are invalid.'); + } + const runtimePackages = sourceRuntime.packages; + const runtimePackageIds = Object.keys(runtimePackages).sort(); + const inputPackages = new Map( + licenseInputs.packages.map((record) => [record?.id, record]) + ); + const noticePackages = new Map( + notices.packages.map((record) => [record?.id, record]) + ); + if ( + inputPackages.size !== licenseInputs.packages.length || + noticePackages.size !== notices.packages.length || + !isDeepStrictEqual( + [...inputPackages.keys()].sort(), + runtimePackageIds + ) || + !isDeepStrictEqual([...noticePackages.keys()].sort(), runtimePackageIds) + ) { + throw new Error( + 'Source archive legal package set does not match the runtime.' + ); + } + const declaredLegalFiles = []; + for (const packageId of runtimePackageIds) { + if (!SAFE_BASENAME_PATTERN.test(packageId)) { + throw new Error( + 'Source archive runtime contains an unsafe package id.' + ); + } + const identity = sourcePackageLegalIdentity(runtimePackages[packageId]); + const inputPackage = inputPackages.get(packageId); + const noticePackage = noticePackages.get(packageId); + if ( + !hasExactFields(inputPackage, [ + 'id', + ...Object.keys(identity), + 'files', + ]) || + !hasExactFields(noticePackage, [ + 'id', + ...Object.keys(identity), + 'files', + ]) || + !Object.entries(identity).every( + ([field, value]) => + isDeepStrictEqual(inputPackage[field], value) && + isDeepStrictEqual(noticePackage[field], value) + ) || + !Array.isArray(inputPackage.files) || + !Array.isArray(noticePackage.files) || + inputPackage.files.length === 0 || + inputPackage.files.length !== noticePackage.files.length + ) { + throw new Error( + `Source archive legal identity is invalid for ${packageId}.` + ); + } + const inputFiles = inputPackage.files + .map((record) => { + if ( + !hasExactFields(record, [ + 'sourcePath', + 'path', + 'size', + 'sha256', + ]) || + !safeRelativePath(record.sourcePath) || + record.path !== `licenses/${packageId}/${record.sourcePath}` + ) { + throw new Error( + `Source archive license input is invalid for ${packageId}.` + ); + } + return { + path: record.path, + sha256: record.sha256, + size: record.size, + }; + }) + .sort(({ path: left }, { path: right }) => + left.localeCompare(right) + ); + const noticeFiles = normalizeLegalFileRecords( + noticePackage.files, + `Source archive notices for ${packageId}` + ); + const normalizedInputFiles = normalizeLegalFileRecords( + inputFiles, + `Source archive license inputs for ${packageId}` + ); + if (!isDeepStrictEqual(normalizedInputFiles, noticeFiles)) { + throw new Error( + `Source archive legal files diverge for ${packageId}.` + ); + } + declaredLegalFiles.push(...noticeFiles); + } + const normalizedDeclaredFiles = normalizeLegalFileRecords( + declaredLegalFiles, + 'Source archive declared legal files' + ); + if ( + !isDeepStrictEqual( + normalizeLegalFileRecords( + compliance.licenseInputFiles, + 'Source archive license input contents' + ), + normalizedDeclaredFiles + ) || + !isDeepStrictEqual( + normalizeLegalFileRecords( + compliance.noticeLicenseFiles, + 'Source archive notice contents' + ), + normalizedDeclaredFiles + ) + ) { + throw new Error( + 'Source archive legal contents do not match their manifests.' + ); + } + if ( + !hasExactFields(compliance.noticeFile, ['path', 'sha256', 'size']) || + compliance.noticeFile.path !== 'THIRD_PARTY_NOTICES.txt' || + !Number.isSafeInteger(compliance.noticeFile.size) || + compliance.noticeFile.size <= 0 || + !SHA256_PATTERN.test(compliance.noticeFile.sha256) || + !isDeepStrictEqual(notices.noticeFile, compliance.noticeFile) || + notices.totalBytes !== + compliance.noticeFile.size + + normalizedDeclaredFiles.reduce( + (total, file) => total + file.size, + 0 + ) + ) { + throw new Error('Source archive aggregate notices are invalid.'); + } + if ( + !isDeepStrictEqual(sourceIndex.legal, { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: notices.noticeFile, + packages: notices.packages, + }) + ) { + throw new Error( + 'Source archive legal index does not match its notices.' + ); + } +} + +function verifySnapReleaseSourceBinding( + { expectedRepositoryRevision, sourceInspection, snapPayloads }, + { + expectedSourceSnapshotSha256 = EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + validateRuntimeManifest = validateLinuxRuntimeManifest, + } = {} +) { + if ( + typeof expectedRepositoryRevision !== 'string' || + !GIT_COMMIT_PATTERN.test(expectedRepositoryRevision) + ) { + throw new Error( + 'Expected release repository revision must be a full Git commit.' + ); + } + if (!isObject(sourceInspection)) { + throw new Error('Source archive inspection is missing.'); + } + const { + archiveFiles, + archiveSha256, + compliance, + localChanges, + repositoryRevision, + sourceIndex, + sourceRuntime, + } = sourceInspection; + const runtimeErrors = validateRuntimeManifest(sourceRuntime); + if (!Array.isArray(runtimeErrors) || runtimeErrors.length > 0) { + throw new Error( + `Source archive runtime manifest is invalid${ + Array.isArray(runtimeErrors) && runtimeErrors.length > 0 + ? `: ${runtimeErrors.join('; ')}` + : '.' + }` + ); + } + if ( + repositoryRevision !== expectedRepositoryRevision || + !isObject(sourceIndex) || + sourceIndex.repositoryRevision !== expectedRepositoryRevision + ) { + throw new Error( + 'Source archive repository revision does not match the released tag.' + ); + } + const expectedSourceArchiveBinding = { + schemaVersion: SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + name: SOURCE_ARCHIVE_NAME, + sha256: archiveSha256, + repositoryRevision, + }; + const sourceArchiveBindingErrors = validateLinuxSourceArchiveBinding( + expectedSourceArchiveBinding, + { + expectedRepositoryRevision, + expectedSha256: archiveSha256, + } + ); + if (sourceArchiveBindingErrors.length > 0) { + throw new Error( + `Source archive byte binding is invalid: ${sourceArchiveBindingErrors.join( + '; ' + )}` + ); + } + const localChangesLength = + typeof localChanges === 'string' + ? Buffer.byteLength(localChanges) + : localChanges instanceof Uint8Array + ? localChanges.byteLength + : -1; + if (localChangesLength !== 0) { + throw new Error( + 'Source archive must describe a clean released repository revision.' + ); + } + if ( + sourceIndex.schemaVersion !== SOURCE_INDEX_SCHEMA_VERSION || + !isDeepStrictEqual(Object.keys(sourceIndex).sort(), [ + 'archives', + 'legal', + 'libplacebo', + 'repositoryRevision', + 'schemaVersion', + 'sourcePackages', + ]) || + !isObject(sourceIndex.legal) || + !isDeepStrictEqual(sourceIndex.sourcePackages, sourceRuntime.packages) + ) { + throw new Error( + 'Source archive index does not match its runtime manifest.' + ); + } + verifySourceArchiveCompliance({ + compliance, + sourceIndex, + sourceRuntime, + }); + const libplaceboPackage = sourceRuntime.packages?.libplacebo; + const sourceSnapshot = validateLinuxRuntimeSourceSnapshot( + compliance.libplaceboSourceSnapshot, + { + expectedSha256: expectedSourceSnapshotSha256, + } + ); + const expectedLibplaceboRecord = { + sourceGitCommit: libplaceboPackage?.sourceGitCommit, + sourceSubmodules: [...EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES], + sourceSnapshot, + }; + if ( + !isDeepStrictEqual( + libplaceboPackage?.sourceSubmodules, + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES + ) + ) { + throw new Error( + 'Source archive libplacebo submodule records do not match the pinned source identity.' + ); + } + if ( + !GIT_COMMIT_PATTERN.test( + expectedLibplaceboRecord.sourceGitCommit ?? '' + ) || + !isDeepStrictEqual(sourceIndex.libplacebo, expectedLibplaceboRecord) + ) { + throw new Error( + 'Source archive libplacebo identity does not match its runtime manifest.' + ); + } + const indexedArchives = normalizeArchiveRecords( + sourceIndex.archives, + 'Source archive index' + ); + const inspectedArchives = normalizeArchiveRecords( + archiveFiles, + 'Source archive contents' + ); + if (!isDeepStrictEqual(inspectedArchives, indexedArchives)) { + throw new Error( + 'Source archive checksums do not match its source index.' + ); + } + const expectedArchiveHashes = Object.values(sourceRuntime.packages ?? {}) + .filter( + (sourcePackage) => + isObject(sourcePackage) && + Object.hasOwn(sourcePackage, 'sourceSha256') + ) + .map((sourcePackage) => sourcePackage.sourceSha256) + .sort(); + const indexedArchiveHashes = indexedArchives + .map(({ sha256 }) => sha256) + .sort(); + if ( + expectedArchiveHashes.length === 0 || + expectedArchiveHashes.some( + (sha256) => + typeof sha256 !== 'string' || !SHA256_PATTERN.test(sha256) + ) || + new Set(expectedArchiveHashes).size !== expectedArchiveHashes.length || + !isDeepStrictEqual(indexedArchiveHashes, expectedArchiveHashes) + ) { + throw new Error( + 'Source archive checksums do not match the pinned runtime packages.' + ); + } + if (!Array.isArray(snapPayloads) || snapPayloads.length === 0) { + throw new Error('Every selected Snap must be inspected.'); + } + const assetNames = new Set(); + let x64SnapCount = 0; + for (const payload of snapPayloads) { + if ( + !isObject(payload) || + typeof payload.assetName !== 'string' || + payload.assetName.length === 0 || + assetNames.has(payload.assetName) + ) { + throw new Error('Selected Snap inspections must be unique.'); + } + assetNames.add(payload.assetName); + if (payload.architecture === 'x64') { + x64SnapCount += 1; + if (payload.markerOnly !== false || !isObject(payload.manifest)) { + throw new Error( + `x64 Snap ${payload.assetName} must contain the frame-copy runtime.` + ); + } + const manifest = payload.manifest; + if ( + manifest.platform !== 'linux' || + manifest.arch !== 'x64' || + manifest.profile !== 'portable' || + manifest.runtimeMode !== 'bundled' || + !Array.isArray(manifest.targets) || + !manifest.targets.includes('snap') || + !isDeepStrictEqual( + manifest.sourceArchive, + expectedSourceArchiveBinding + ) + ) { + throw new Error( + `x64 Snap ${payload.assetName} has an invalid frame-copy source archive binding.` + ); + } + if (!isDeepStrictEqual(manifest.sourceRuntime, sourceRuntime)) { + throw new Error( + `Snap source runtime does not match source archive: ${payload.assetName}` + ); + } + continue; + } + if ( + !['arm', 'arm64'].includes(payload.architecture) || + payload.markerOnly !== true || + payload.manifest !== null + ) { + throw new Error( + `Non-x64 Snap ${payload.assetName} must remain marker-only.` + ); + } + } + if (x64SnapCount !== 1) { + throw new Error( + `Public release must contain exactly one x64 Snap; received ${x64SnapCount}.` + ); + } + return true; +} + +module.exports = { + inspectSnapPayload, + inspectSourceArchive, + verifySnapReleaseSourceBinding, +}; diff --git a/tools/packaging/snap-workflow-policy.test-helpers.mjs b/tools/packaging/snap-workflow-policy.test-helpers.mjs new file mode 100644 index 000000000..d69a48b89 --- /dev/null +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -0,0 +1,564 @@ +import assert from 'node:assert/strict'; +import { parse } from 'yaml'; + +const PINNED_CHECKOUT_ACTION = + 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5'; +const PINNED_UPLOAD_ARTIFACT_ACTION = + 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02'; +const PINNED_DOWNLOAD_ARTIFACT_ACTION = + 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'; +const PUBLISH_ACTION_ALLOWLIST = Object.freeze([ + PINNED_CHECKOUT_ACTION, + PINNED_DOWNLOAD_ARTIFACT_ACTION, + PINNED_UPLOAD_ARTIFACT_ACTION, +]); +const BUILD_ACTION_ALLOWLIST = Object.freeze([ + 'actions/cache/restore@v4', + 'actions/cache/save@v4', + 'actions/cache@v4', + 'actions/checkout@v4', + 'actions/download-artifact@v4', + 'actions/setup-node@v4', + 'actions/upload-artifact@v4', + 'pnpm/action-setup@v4', + 'softprops/action-gh-release@v2', +]); +const VERIFY_JOB_ID = 'verify-snap'; +const PUBLISH_JOB_ID = 'publish-snap'; +const VERIFY_JOB_CONDITION = + "${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}"; +const PUBLISH_JOB_CONDITION = + "${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}"; +const VERIFIED_RELEASE_ARTIFACT_NAME = 'verified-snap-release-assets'; +const PUBLISH_STEP_NAME = 'Publish all public-release snaps to edge'; +const PUBLISH_CHECKOUT_STEP_NAME = 'Checkout released tooling'; +const PUBLISH_CHECKOUT_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_CHECKOUT_STEP_NAME, + uses: PINNED_CHECKOUT_ACTION, + with: { + ref: '${{ github.event.release.tag_name }}', + 'persist-credentials': false, + }, +}); +const PUBLISH_SNAPCRAFT_SETUP_STEP_NAME = 'Install Snapcraft'; +const PUBLISH_SNAPCRAFT_SETUP_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_SNAPCRAFT_SETUP_STEP_NAME, + shell: 'bash', + run: [ + 'set -euo pipefail', + '', + 'sudo snap install snapcraft --classic --channel=stable', + '', + ].join('\n'), +}); +const VERIFY_ARTIFACT_UPLOAD_STEP_NAME = 'Transfer verified release assets'; +const VERIFY_ARTIFACT_UPLOAD_STEP_CONTRACT = Object.freeze({ + name: VERIFY_ARTIFACT_UPLOAD_STEP_NAME, + uses: PINNED_UPLOAD_ARTIFACT_ACTION, + with: { + name: VERIFIED_RELEASE_ARTIFACT_NAME, + path: '/var/lib/iptvnator-snap-release/assets', + 'if-no-files-found': 'error', + 'retention-days': 1, + 'compression-level': 0, + 'include-hidden-files': true, + }, +}); +const PUBLISH_ARTIFACT_DOWNLOAD_STEP_NAME = 'Download verified release assets'; +const PUBLISH_ARTIFACT_DOWNLOAD_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_ARTIFACT_DOWNLOAD_STEP_NAME, + uses: PINNED_DOWNLOAD_ARTIFACT_ACTION, + with: { + name: VERIFIED_RELEASE_ARTIFACT_NAME, + path: '${{ runner.temp }}/verified-snap-release-assets', + }, +}); +const PUBLISH_SEALED_VERIFY_STEP_NAME = 'Reverify sealed public release assets'; +const PUBLISH_SEALED_VERIFY_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_SEALED_VERIFY_STEP_NAME, + shell: 'bash', + run: [ + 'set -euo pipefail', + '', + 'VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"', + 'node tools/packaging/release-snap-assets.cjs verify-sealed \\', + ' --manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \\', + ' --directory "${VERIFIED_ASSET_DIRECTORY}" \\', + ' --receipt "${VERIFIED_ASSET_DIRECTORY}/verified-release-assets.json" \\', + ' --repository-revision "$(git rev-parse HEAD)"', + '', + ].join('\n'), +}); +const VERIFY_TRANSFER_BINDING_STEP_NAME = 'Bind verified release transfer'; +const VERIFY_TRANSFER_BINDING_STEP_CONTRACT = Object.freeze({ + name: VERIFY_TRANSFER_BINDING_STEP_NAME, + id: 'bind-transfer', + shell: 'bash', + run: [ + 'set -euo pipefail', + '', + 'RECEIPT_PATH="/var/lib/iptvnator-snap-release/assets/verified-release-assets.json"', + 'RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"', + 'RECEIPT_SHA256="${RECEIPT_RECORD%% *}"', + '[[ "${RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]', + `printf 'receipt-sha256=%s\\n' "\${RECEIPT_SHA256}" >> "\${GITHUB_OUTPUT}"`, + '', + ].join('\n'), +}); +const PUBLISH_TRANSFER_VERIFY_STEP_NAME = + 'Seal transferred public release assets'; +const PUBLISH_TRANSFER_VERIFY_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_TRANSFER_VERIFY_STEP_NAME, + shell: 'bash', + env: { + EXPECTED_RECEIPT_SHA256: + '${{ needs.verify-snap.outputs.receipt-sha256 }}', + }, + run: [ + 'set -euo pipefail', + '', + 'TRANSFERRED_ASSET_DIRECTORY="${RUNNER_TEMP}/verified-snap-release-assets"', + 'SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release"', + 'SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets"', + 'test -d "${TRANSFERRED_ASSET_DIRECTORY}"', + 'test ! -L "${TRANSFERRED_ASSET_DIRECTORY}"', + 'shopt -s nullglob dotglob', + 'TRANSFERRED_FILES=("${TRANSFERRED_ASSET_DIRECTORY}"/*)', + 'TRANSFERRED_SNAPS=("${TRANSFERRED_ASSET_DIRECTORY}"/*.snap)', + 'test "${#TRANSFERRED_SNAPS[@]}" -gt 0', + 'test "${#TRANSFERRED_FILES[@]}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 2 ))"', + 'test -f "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"', + 'test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"', + 'test -f "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"', + 'test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"', + 'for ASSET_FILE in "${TRANSFERRED_FILES[@]}"; do', + ' test -f "${ASSET_FILE}"', + ' test ! -L "${ASSET_FILE}"', + 'done', + 'RECEIPT_PATH="${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"', + 'RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"', + 'ACTUAL_RECEIPT_SHA256="${RECEIPT_RECORD%% *}"', + '[[ "${EXPECTED_RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]', + 'test "${ACTUAL_RECEIPT_SHA256}" = "${EXPECTED_RECEIPT_SHA256}"', + "/usr/bin/jq --exit-status '", + ' type == "object" and', + ' (keys == ["assets", "repositoryRevision", "schemaVersion"]) and', + ' (.schemaVersion == 1) and', + ' (.repositoryRevision |', + ' type == "string" and test("^[a-f0-9]{40,64}$")) and', + ' (.assets | type == "array" and length >= 2) and', + ' (.assets | all(.[];', + ' type == "object" and', + ' (keys == ["id", "name", "sha256", "size"]) and', + ' (.id |', + ' type == "number" and . > 0 and', + ' . <= 9007199254740991 and . == floor) and', + ' (.name |', + ' type == "string" and length > 0 and', + ' . != "." and . != ".." and', + ' (contains("/") | not) and', + ' (contains("\\\\") | not) and', + ' (explode | all(.[]; . > 31 and . != 127))) and', + ' (.sha256 |', + ' type == "string" and test("^[a-f0-9]{64}$")) and', + ' (.size |', + ' type == "number" and . > 0 and', + ' . <= 9007199254740991 and . == floor))) and', + ' ([.assets[].name] | length == (unique | length)) and', + ' ([.assets[] |', + ' select(.name == "linux-frame-copy-runtime-sources.tar.xz")] |', + ' length == 1) and', + ' ([.assets[] | select(.name | endswith(".snap"))] |', + ' length >= 1) and', + ' (.assets | all(.[];', + ' .name == "linux-frame-copy-runtime-sources.tar.xz" or', + ' (.name | endswith(".snap"))))', + '\' "${RECEIPT_PATH}" > /dev/null', + `RECEIPT_ASSET_COUNT="$(/usr/bin/jq --raw-output '.assets | length' "\${RECEIPT_PATH}")"`, + 'test "${RECEIPT_ASSET_COUNT}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 1 ))"', + 'SIZE_MANIFEST="${RUNNER_TEMP}/verified-release-asset-sizes.tsv"', + 'CHECKSUM_MANIFEST="${RUNNER_TEMP}/verified-release-asset-checksums.txt"', + 'umask 077', + '/usr/bin/jq --raw-output \\', + ` '.assets[] | [.name, (.size | tostring)] | @tsv' \\`, + ' "${RECEIPT_PATH}" > "${SIZE_MANIFEST}"', + `while IFS=$'\\t' read -r ASSET_NAME EXPECTED_SIZE; do`, + ' ASSET_PATH="${TRANSFERRED_ASSET_DIRECTORY}/${ASSET_NAME}"', + ' ACTUAL_SIZE="$(/usr/bin/stat --format=%s -- "${ASSET_PATH}")"', + ' test "${ACTUAL_SIZE}" = "${EXPECTED_SIZE}"', + 'done < "${SIZE_MANIFEST}"', + '/usr/bin/jq --raw-output \\', + ` '.assets[] | "\\(.sha256) \\(.name)"' \\`, + ' "${RECEIPT_PATH}" > "${CHECKSUM_MANIFEST}"', + '(', + ' cd "${TRANSFERRED_ASSET_DIRECTORY}"', + ' /usr/bin/sha256sum --strict --check "${CHECKSUM_MANIFEST}"', + ')', + 'rm -f "${SIZE_MANIFEST}" "${CHECKSUM_MANIFEST}"', + 'shopt -u nullglob dotglob', + 'sudo test ! -e "${SEALED_ASSET_PARENT}"', + 'sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}"', + 'sudo mv "${TRANSFERRED_ASSET_DIRECTORY}" "${SEALED_ASSET_DIRECTORY}"', + 'sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}"', + 'sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} +', + 'sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +', + 'sudo chmod 0555 "${SEALED_ASSET_PARENT}"', + '', + ].join('\n'), +}); +const PUBLISH_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_STEP_NAME, + shell: 'bash', + env: { + SNAPCRAFT_STORE_CREDENTIALS: '${{ secrets.snapcraft_token }}', + }, + run: [ + 'set -euo pipefail', + '', + 'VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"', + 'STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}"', + 'unset SNAPCRAFT_STORE_CREDENTIALS', + 'shopt -s nullglob dotglob', + 'SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap)', + 'test "${#SNAP_FILES[@]}" -gt 0', + 'for SNAP_FILE in "${SNAP_FILES[@]}"; do', + ' SNAP_NAME="${SNAP_FILE##*/}"', + ' echo "Publishing public release asset: ${SNAP_NAME}"', + ' # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.', + ' # GitHub Actions never promotes automatically.', + ' SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"', + 'done', + 'unset STORE_CREDENTIALS', + 'shopt -u nullglob dotglob', + '', + ].join('\n'), +}); + +function stripShellComment(line) { + let quote = null; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (quote === "'") { + if (character === quote) { + quote = null; + } + continue; + } + if (character === '\\') { + index += 1; + continue; + } + if (quote === '"') { + if (character === quote) { + quote = null; + } + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if ( + character === '#' && + (index === 0 || /[\s;|&()]/.test(line[index - 1])) + ) { + return line.slice(0, index); + } + } + return line; +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeRunSource(runSource) { + return runSource + .split('\n') + .map(stripShellComment) + .join('\n') + .replace(/\\\r?\n[ \t]*/g, ''); +} + +function collectDefaultShell(container, containerName, explicitShells) { + if (!Object.hasOwn(container, 'defaults')) { + return; + } + assert.ok( + isRecord(container.defaults), + `${containerName} defaults must be a mapping` + ); + if (!Object.hasOwn(container.defaults, 'run')) { + return; + } + assert.ok( + isRecord(container.defaults.run), + `${containerName} run defaults must be a mapping` + ); + if (!Object.hasOwn(container.defaults.run, 'shell')) { + return; + } + assert.equal( + typeof container.defaults.run.shell, + 'string', + `${containerName} default shell must resolve to a string` + ); + explicitShells.push(container.defaults.run.shell); +} + +function collectWorkflowPolicyInputs(workflowText) { + const workflow = parse(workflowText); + assert.ok(isRecord(workflow), 'workflow must be a YAML mapping'); + assert.ok(isRecord(workflow.jobs), 'workflow jobs must be a YAML mapping'); + + const actions = []; + const explicitShells = []; + const jobActions = []; + const jobsWithoutSteps = []; + const runSources = []; + collectDefaultShell(workflow, 'workflow', explicitShells); + for (const [jobName, job] of Object.entries(workflow.jobs)) { + assert.ok(isRecord(job), `workflow job "${jobName}" must be a mapping`); + collectDefaultShell(job, `workflow job "${jobName}"`, explicitShells); + if (Object.hasOwn(job, 'uses')) { + assert.equal( + typeof job.uses, + 'string', + 'reusable workflow identifiers must resolve to strings' + ); + jobActions.push(job.uses); + } + if (!Array.isArray(job.steps)) { + jobsWithoutSteps.push(jobName); + continue; + } + for (const step of job.steps) { + assert.ok(isRecord(step), 'workflow steps must be mappings'); + if (Object.hasOwn(step, 'uses')) { + assert.equal( + typeof step.uses, + 'string', + 'workflow action identifiers must resolve to strings' + ); + actions.push(step.uses); + } + if (Object.hasOwn(step, 'run')) { + assert.equal( + typeof step.run, + 'string', + 'workflow run commands must resolve to strings' + ); + runSources.push(normalizeRunSource(step.run)); + } + if (Object.hasOwn(step, 'shell')) { + assert.equal( + typeof step.shell, + 'string', + 'workflow step shells must resolve to strings' + ); + explicitShells.push(step.shell); + } + } + } + + return { + actions, + commandSource: runSources.join('\n'), + explicitShells, + jobActions, + jobsWithoutSteps, + workflow, + }; +} + +function assertWorkflowExecutionShape({ + explicitShells, + jobActions, + jobsWithoutSteps, +}) { + assert.deepEqual( + jobActions, + [], + 'job-level reusable workflow delegation is not allowed' + ); + assert.deepEqual( + jobsWithoutSteps, + [], + 'every workflow job must define a concrete steps sequence' + ); + assert.deepEqual( + explicitShells.filter((shell) => shell !== 'bash'), + [], + 'every explicit workflow shell must be exactly "bash"' + ); +} + +function literalSnapcraftTokens(commandSource) { + return commandSource.match(/\bsnapcraft\b/g) ?? []; +} + +export function assertPublishSnapWorkflowPolicy(workflowText) { + const policyInputs = collectWorkflowPolicyInputs(workflowText); + const { actions, commandSource, workflow } = policyInputs; + assertWorkflowExecutionShape(policyInputs); + assert.deepEqual( + workflow.on, + { release: { types: ['published'] } }, + 'the publish workflow must retain its exact release trigger' + ); + assert.deepEqual( + Object.keys(workflow).sort(), + ['jobs', 'name', 'on', 'permissions'], + 'the publish workflow must not add global execution or environment surfaces' + ); + assert.deepEqual( + workflow.permissions, + { contents: 'read' }, + 'the publish workflow must retain read-only repository permissions' + ); + assert.deepEqual( + [...actions].sort(), + [...PUBLISH_ACTION_ALLOWLIST].sort(), + 'the publish workflow must use exactly the allowlisted actions' + ); + assert.deepEqual( + Object.keys(workflow.jobs), + [VERIFY_JOB_ID, PUBLISH_JOB_ID], + 'the publish workflow must isolate verification and credentialed upload on two exact jobs' + ); + const verifyJob = workflow.jobs[VERIFY_JOB_ID]; + const publishJob = workflow.jobs[PUBLISH_JOB_ID]; + assert.ok(isRecord(verifyJob), 'the canonical verification job must exist'); + assert.ok(isRecord(publishJob), 'the canonical publish job must exist'); + assert.deepEqual( + Object.keys(verifyJob).sort(), + ['env', 'if', 'name', 'outputs', 'runs-on', 'steps', 'timeout-minutes'], + 'the verification job must retain its exact execution surface' + ); + assert.deepEqual( + verifyJob.env, + { + SOURCE_ARCHIVE_NAME: 'linux-frame-copy-runtime-sources.tar.xz', + }, + 'the verification job must expose only the fixed source archive name' + ); + assert.deepEqual( + verifyJob.outputs, + { + 'receipt-sha256': + '${{ steps.bind-transfer.outputs.receipt-sha256 }}', + }, + 'the verification job must expose only the separately bound receipt digest' + ); + assert.deepEqual( + Object.keys(publishJob).sort(), + ['if', 'name', 'needs', 'runs-on', 'steps', 'timeout-minutes'], + 'the fresh credentialed job must retain its exact execution surface' + ); + assert.equal( + verifyJob['runs-on'], + 'ubuntu-latest', + 'the verification job must use a fresh GitHub-hosted runner' + ); + assert.equal( + publishJob['runs-on'], + 'ubuntu-latest', + 'the credentialed job must use a separate fresh GitHub-hosted runner' + ); + assert.equal( + verifyJob['timeout-minutes'], + 45, + 'the verification job must retain its bounded timeout' + ); + assert.equal( + publishJob['timeout-minutes'], + 20, + 'the credentialed job must retain its bounded timeout' + ); + assert.equal( + verifyJob.if, + VERIFY_JOB_CONDITION, + 'the verification job must retain its exact release condition' + ); + assert.equal( + publishJob.needs, + VERIFY_JOB_ID, + 'the publish job must depend on successful isolated verification' + ); + assert.equal( + publishJob.if, + PUBLISH_JOB_CONDITION, + 'the publish job must retain its exact verified-release condition' + ); + assert.deepEqual( + verifyJob.steps.filter( + (step) => step.name === PUBLISH_CHECKOUT_STEP_NAME + ), + [PUBLISH_CHECKOUT_STEP_CONTRACT], + 'the publish workflow must checkout released tooling without persisting repository credentials' + ); + assert.deepEqual( + verifyJob.steps.filter( + (step) => step.name === PUBLISH_SEALED_VERIFY_STEP_NAME + ), + [PUBLISH_SEALED_VERIFY_STEP_CONTRACT], + 'the verification job must fully verify root-sealed assets before transfer' + ); + assert.deepEqual( + verifyJob.steps.filter( + (step) => step.name === VERIFY_ARTIFACT_UPLOAD_STEP_NAME + ), + [VERIFY_ARTIFACT_UPLOAD_STEP_CONTRACT], + 'the verification job must transfer only the root-sealed verified data artifact' + ); + assert.deepEqual( + verifyJob.steps.filter( + (step) => step.name === VERIFY_TRANSFER_BINDING_STEP_NAME + ), + [VERIFY_TRANSFER_BINDING_STEP_CONTRACT], + 'the verification job must bind the exact receipt outside artifact transport' + ); + assert.deepEqual( + publishJob.steps, + [ + PUBLISH_ARTIFACT_DOWNLOAD_STEP_CONTRACT, + PUBLISH_TRANSFER_VERIFY_STEP_CONTRACT, + PUBLISH_SNAPCRAFT_SETUP_STEP_CONTRACT, + PUBLISH_STEP_CONTRACT, + ], + 'the fresh publish runner must only download, validate, seal, install Snapcraft, and upload' + ); + assert.equal( + JSON.stringify(verifyJob).includes('snapcraft_token'), + false, + 'the release-tag verification job must never receive the Store credential' + ); + assert.equal( + publishJob.steps + .slice(0, -1) + .some((step) => JSON.stringify(step).includes('snapcraft_token')), + false, + 'only the final credentialed upload step may receive the Store credential' + ); + assert.equal( + literalSnapcraftTokens(commandSource).length, + 2, + 'the publish workflow must contain exactly the reviewed install and upload Snapcraft commands' + ); +} + +export function assertBuildSnapWorkflowPolicy(workflowText) { + const policyInputs = collectWorkflowPolicyInputs(workflowText); + const { actions, commandSource } = policyInputs; + assertWorkflowExecutionShape(policyInputs); + assert.deepEqual( + actions.filter((action) => !BUILD_ACTION_ALLOWLIST.includes(action)), + [], + 'the build workflow must use only allowlisted actions' + ); + assert.equal( + literalSnapcraftTokens(commandSource).length, + 0, + 'the build workflow must not contain a literal Snapcraft token' + ); +} diff --git a/tools/packaging/validate-snap-release-boundary.mjs b/tools/packaging/validate-snap-release-boundary.mjs new file mode 100644 index 000000000..2a98df1c8 --- /dev/null +++ b/tools/packaging/validate-snap-release-boundary.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + collectEmbeddedMpvNativeArchiveEntries, + listAsarPackageEntries, +} from './asar-dependency-closure.mjs'; +import { validateExtractedSnapMetadata } from './verify-linux-frame-copy-runtime.mjs'; + +const scriptPath = fileURLToPath(import.meta.url); +const SNAP_RELEASE_BOUNDARY_SCHEMA_VERSION = 1; + +export function validateExtractedSnapReleaseBoundary( + extractionRoot, + { asarListPackage = listAsarPackageEntries } = {} +) { + const errors = [...validateExtractedSnapMetadata(extractionRoot)]; + const asarPath = path.join( + extractionRoot, + 'usr', + 'lib', + 'iptvnator', + 'resources', + 'app.asar' + ); + let asarStat; + try { + asarStat = fs.lstatSync(asarPath); + } catch { + errors.push( + `Public-release Snap must contain its canonical app.asar: ${asarPath}` + ); + return errors; + } + if ( + !asarStat.isFile() || + asarStat.isSymbolicLink() || + asarStat.size === 0 + ) { + errors.push( + `Public-release Snap app.asar must be a non-empty regular file: ${asarPath}` + ); + return errors; + } + + let nativeEntries; + try { + nativeEntries = collectEmbeddedMpvNativeArchiveEntries( + asarListPackage(asarPath) + ); + } catch (error) { + errors.push( + `Unable to inspect public-release Snap app.asar at ${asarPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return errors; + } + if (nativeEntries.length > 0) { + errors.push( + `Public-release Snap app.asar must not contain embedded MPV native payloads: ${nativeEntries.join( + ', ' + )}` + ); + } + return errors; +} + +function main() { + const args = process.argv.slice(2); + if (args.length !== 1 || args[0].length === 0) { + throw new Error( + 'Usage: validate-snap-release-boundary.mjs ' + ); + } + const errors = validateExtractedSnapReleaseBoundary(path.resolve(args[0])); + process.stdout.write( + `${JSON.stringify({ + schemaVersion: SNAP_RELEASE_BOUNDARY_SCHEMA_VERSION, + errors, + })}\n` + ); +} + +function isMainModule() { + if (!process.argv[1]) { + return false; + } + try { + return fs.realpathSync(process.argv[1]) === fs.realpathSync(scriptPath); + } catch { + return false; + } +} + +if (isMainModule()) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index 05a0deded..ab94e048c 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -3,15 +3,21 @@ import { createRequire } from 'module'; import path from 'path'; import { buildElectronBuilderMetadata } from './generate-electron-builder-metadata.mjs'; -import { inspectPackagedDependencyClosure } from './asar-dependency-closure.mjs'; +import { + collectEmbeddedMpvNativeArchiveEntries, + inspectPackagedDependencyClosure, +} from './asar-dependency-closure.mjs'; const require = createRequire(import.meta.url); const { extractFile, listPackage } = require('@electron/asar'); const { - getEmbeddedMpvAddonArch, linuxUnpackedDirArch, + resolveConfiguredLinuxTargetNames, validatePackagedEmbeddedMpv, } = require('./embedded-mpv-packaging.cjs'); +const { + validateLinuxProfileTargets, +} = require('./linux-frame-copy-profile.cjs'); const args = process.argv.slice(2); const normalizedArgs = args[0] === '--' ? args.slice(1) : args; const [platform, arch = ''] = normalizedArgs; @@ -53,6 +59,8 @@ const snapConfigInspection = loadSnapConfigInspection(); const embeddedMpvRequired = isTruthy( process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV ); +const linuxFrameCopyProfile = + process.env.IPTVNATOR_LINUX_FRAME_COPY_PROFILE?.trim() || undefined; const workerRelativeDir = path.join( 'dist', 'apps', @@ -601,7 +609,9 @@ function verifyPackagedDependencyClosure(resourceDir, errors) { } const details = missing - .map((entry) => `- ${entry.dependency} (required by ${entry.requiredBy})`) + .map( + (entry) => `- ${entry.dependency} (required by ${entry.requiredBy})` + ) .join('\n'); errors.push( @@ -614,9 +624,37 @@ function verifyPackagedDependencyClosure(resourceDir, errors) { ); } -// The embedded MPV addon is built once per CI host (x64), but electron-builder -// emits arm64/armv7l Linux output directories from the same dist tree. Those -// must carry the unavailable marker instead of a foreign-architecture addon. +function verifyNoEmbeddedMpvNativeArchiveEntries(resourceDir, errors) { + const asarPath = path.join(resourceDir, 'app.asar'); + if (!fileExists(asarPath)) { + // Missing archive is already reported by verifyPackagedPackageMetadata. + return; + } + + let nativeEntries; + try { + nativeEntries = collectEmbeddedMpvNativeArchiveEntries( + listPackage(asarPath) + ); + } catch (error) { + errors.push( + `Unable to inspect embedded MPV archive ownership in ${asarPath}: ${error.message}` + ); + return; + } + + if (nativeEntries.length > 0) { + errors.push( + [ + `Packaged app.asar must not contain embedded MPV native payloads; afterPack exclusively owns the profile-specific unpacked directory in ${asarPath}.`, + ...nativeEntries.map((entry) => `- ${entry}`), + ].join('\n') + ); + } +} + +// Official Linux frame-copy support is x64-only. Every arm64/armv7l output +// must carry the unavailable marker instead of native frame-copy artifacts. function isForeignArchLinuxResourceDir(resourceDir) { if (platform !== 'linux') { return false; @@ -625,7 +663,7 @@ function isForeignArchLinuxResourceDir(resourceDir) { const dirArch = linuxUnpackedDirArch( path.basename(path.dirname(resourceDir)) ); - return Boolean(dirArch) && dirArch !== getEmbeddedMpvAddonArch(); + return Boolean(dirArch) && dirArch !== 'x64'; } function verifyResourceDir(resourceDir) { @@ -642,9 +680,11 @@ function verifyResourceDir(resourceDir) { ); const errors = []; + let linuxTargetNames; verifyPackagedPackageMetadata(resourceDir, errors); verifyPackagedDependencyClosure(resourceDir, errors); + verifyNoEmbeddedMpvNativeArchiveEntries(resourceDir, errors); if (missingWorkers.length > 0) { errors.push( @@ -663,6 +703,28 @@ function verifyResourceDir(resourceDir) { } if (platform === 'linux') { + const resourceArch = + linuxUnpackedDirArch(path.basename(path.dirname(resourceDir))) || + arch; + try { + linuxTargetNames = resolveConfiguredLinuxTargetNames( + electronBuilderConfig.linux?.target, + resourceArch + ); + if (linuxFrameCopyProfile) { + errors.push( + ...validateLinuxProfileTargets( + linuxFrameCopyProfile, + linuxTargetNames + ) + ); + } + } catch (error) { + errors.push( + `Unable to resolve selected Linux package targets: ${error.message}` + ); + linuxTargetNames = []; + } if (!fileExists(flatpakMetainfoPath)) { errors.push( `Missing Flatpak metainfo file: ${flatpakMetainfoPath}` @@ -679,6 +741,9 @@ function verifyResourceDir(resourceDir) { platform, required: embeddedMpvRequired, foreignArch: isForeignArchLinuxResourceDir(resourceDir), + profile: linuxFrameCopyProfile, + targetNames: linuxTargetNames, + executableName: linuxExecutableName, }) ); diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs new file mode 100644 index 000000000..e7bfb7a3b --- /dev/null +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -0,0 +1,1744 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +import { + collectEmbeddedMpvNativeArchiveEntries, + listAsarPackageEntries, +} from './asar-dependency-closure.mjs'; + +const require = createRequire(import.meta.url); +const { + parseReadelfDynamic, +} = require('../embedded-mpv/build-linux-runtime.cjs'); +const { + listElectronShippedLinuxLibraries, + validatePackagedEmbeddedMpv, +} = require('./embedded-mpv-packaging.cjs'); +const { + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, +} = require('./linux-frame-copy-profile.cjs'); +const { + RUNTIME_PROBE_MAX_BUFFER_BYTES, + RUNTIME_PROBE_TIMEOUT_MS, +} = require('../embedded-mpv/runtime-probe-contract.cjs'); + +const scriptPath = fileURLToPath(import.meta.url); +const LIBMPV_DEPENDENCY_PATTERN = /^libmpv\.so(?:\.|$)/; +const SNAP_METADATA_MAX_BYTES = 256 * 1024; +// Keep this set identical to the packaged helper sanitizer in +// embedded-mpv-frame-copy-runtime/helper-environment.ts. Feature/debug +// selectors such as LIBGL_ALWAYS_SOFTWARE remain intentionally available. +const UNSAFE_RUNTIME_PROBE_ENVIRONMENT_VARIABLES = [ + 'BASH_ENV', + 'ENV', + 'BASHOPTS', + 'SHELLOPTS', + 'PS4', + 'BASH_XTRACEFD', + 'CDPATH', + 'LD_AUDIT', + 'LD_LIBRARY_PATH', + 'LD_ORIGIN_PATH', + 'LD_PRELOAD', + '__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS', + '__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES', + '__EGL_VENDOR_LIBRARY_DIRS', + '__EGL_VENDOR_LIBRARY_FILENAMES', + 'GBM_BACKEND', + 'GBM_BACKENDS_PATH', + 'LIBGL_DRIVERS_PATH', + 'MESA_LOADER_DRIVER_OVERRIDE', + 'LIBVA_DRIVER_NAME', + 'LIBVA_DRIVERS_PATH', + 'VDPAU_DRIVER_PATH', + 'VK_DRIVER_FILES', + 'VK_ICD_FILENAMES', + 'VK_ADD_DRIVER_FILES', + 'VK_ADD_LAYER_PATH', + 'VK_IMPLICIT_LAYER_PATH', + 'VK_ADD_IMPLICIT_LAYER_PATH', + 'VK_LAYER_PATH', +]; + +export function runVerifierCommand(command, args, options = {}) { + const spawnOptions = { + cwd: options.cwd, + env: options.env, + encoding: 'utf8', + stdio: 'pipe', + timeout: options.timeout, + killSignal: options.killSignal, + windowsHide: true, + }; + if (options.maxBuffer !== undefined) { + spawnOptions.maxBuffer = options.maxBuffer; + } + return spawnSync(command, args, spawnOptions); +} + +function assertCommandSucceeded(command, args, result) { + if (result?.error) { + throw new Error( + `Unable to run ${command}: ${ + result.error instanceof Error + ? result.error.message + : String(result.error) + }` + ); + } + if (result?.status !== 0) { + const details = [result?.stdout, result?.stderr] + .filter(Boolean) + .join('\n') + .trim(); + throw new Error( + `${command} ${args.join(' ')} failed with status ${ + result?.status ?? 'unknown' + }.${details ? `\n${details}` : ''}` + ); + } + return result; +} + +export function detectLinuxArtifactFormat(artifactPath) { + const fileName = path.basename(artifactPath); + if (/\.AppImage$/i.test(fileName)) { + return 'appimage'; + } + if (/\.deb$/i.test(fileName)) { + return 'deb'; + } + if (/\.rpm$/i.test(fileName)) { + return 'rpm'; + } + if (/\.(?:pacman|pkg\.tar(?:\.[A-Za-z0-9]+)*)$/i.test(fileName)) { + return 'pacman'; + } + if (/\.snap$/i.test(fileName)) { + return 'snap'; + } + if (/\.flatpak$/i.test(fileName)) { + return 'flatpak'; + } + throw new Error(`Unsupported Linux package artifact: ${artifactPath}`); +} + +export function parseVerifierArguments(argv) { + const normalizedArgs = argv[0] === '--' ? argv.slice(1) : [...argv]; + let artifactValue; + let profileValue; + for (let index = 0; index < normalizedArgs.length; index += 1) { + const argument = normalizedArgs[index]; + if (argument === '--artifact') { + if (artifactValue !== undefined) { + throw new Error('Received duplicate --artifact argument.'); + } + artifactValue = normalizedArgs[++index]; + continue; + } + if (argument === '--profile') { + if (profileValue !== undefined) { + throw new Error('Received duplicate --profile argument.'); + } + profileValue = normalizedArgs[++index]; + continue; + } + throw new Error(`Unsupported verifier argument: ${argument}`); + } + if (!artifactValue) { + throw new Error('--artifact is required.'); + } + if (!profileValue) { + throw new Error('--profile is required.'); + } + const artifactPath = path.resolve(artifactValue); + const stat = fs.lstatSync(artifactPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Linux package artifact must be a regular file: ${artifactPath}` + ); + } + const profile = resolveLinuxFrameCopyProfile(profileValue); + detectLinuxArtifactFormat(artifactPath); + return { + artifactPath, + profileName: profile.name, + }; +} + +export function findAppImageSquashfsOffsets(artifactPath) { + const magic = Buffer.from('hsqs'); + const chunkSize = 1024 * 1024; + const descriptor = fs.openSync(artifactPath, 'r'); + const offsets = []; + let position = 0; + let overlap = Buffer.alloc(0); + try { + while (true) { + const chunk = Buffer.alloc(chunkSize); + const bytesRead = fs.readSync( + descriptor, + chunk, + 0, + chunk.length, + position + ); + if (bytesRead === 0) { + break; + } + const contents = Buffer.concat([ + overlap, + chunk.subarray(0, bytesRead), + ]); + const contentsStart = position - overlap.length; + let searchOffset = 0; + while (searchOffset <= contents.length - magic.length) { + const matchOffset = contents.indexOf(magic, searchOffset); + if (matchOffset === -1) { + break; + } + const absoluteOffset = contentsStart + matchOffset; + if (offsets.at(-1) !== absoluteOffset) { + offsets.push(absoluteOffset); + } + searchOffset = matchOffset + 1; + } + overlap = contents.subarray( + Math.max(0, contents.length - (magic.length - 1)) + ); + position += bytesRead; + } + } finally { + fs.closeSync(descriptor); + } + return offsets; +} + +export function extractLinuxArtifact({ + artifactPath, + format, + destination, + runCommand = runVerifierCommand, +}) { + fs.rmSync(destination, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const run = (command, args, options = {}) => + assertCommandSucceeded( + command, + args, + runCommand(command, args, options) + ); + + switch (format) { + case 'appimage': { + const squashfsOffsets = findAppImageSquashfsOffsets(artifactPath); + if (squashfsOffsets.length === 0) { + throw new Error( + `AppImage contains no SquashFS payload: ${artifactPath}` + ); + } + const failures = []; + for (const offset of squashfsOffsets) { + fs.rmSync(destination, { recursive: true, force: true }); + const args = [ + '-no-progress', + '-offset', + String(offset), + '-dest', + destination, + artifactPath, + ]; + const result = runCommand('unsquashfs', args); + if (!result?.error && result?.status === 0) { + return destination; + } + failures.push( + [result?.stdout, result?.stderr] + .filter(Boolean) + .join('\n') + .trim() + ); + } + throw new Error( + [ + `Unable to extract the AppImage SquashFS payload at any candidate offset: ${artifactPath}`, + ...failures.filter(Boolean), + ].join('\n') + ); + } + case 'deb': + fs.mkdirSync(destination, { recursive: true }); + run('dpkg-deb', ['--extract', artifactPath, destination]); + return destination; + case 'rpm': + case 'pacman': + fs.mkdirSync(destination, { recursive: true }); + run('bsdtar', [ + '--extract', + '--file', + artifactPath, + '--directory', + destination, + ]); + return destination; + case 'snap': + run('unsquashfs', [ + '-no-progress', + '-dest', + destination, + artifactPath, + ]); + return destination; + case 'flatpak': { + fs.mkdirSync(destination, { recursive: true }); + const repository = path.join(destination, '.ostree-repository'); + const checkout = path.join(destination, 'checkout'); + fs.mkdirSync(repository, { recursive: true }); + run('ostree', [ + `--repo=${repository}`, + 'init', + '--mode=archive-z2', + ]); + run('flatpak', ['build-import-bundle', repository, artifactPath]); + const refsResult = run('ostree', ['refs', `--repo=${repository}`]); + const refs = String(refsResult.stdout ?? '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith('app/')); + if (refs.length !== 1) { + throw new Error( + `Flatpak bundle must import exactly one application ref; received ${ + refs.length > 0 ? refs.join(', ') : '' + }.` + ); + } + run('ostree', [ + 'checkout', + '-U', + `--repo=${repository}`, + refs[0], + checkout, + ]); + return checkout; + } + default: + throw new Error(`Unsupported Linux package format: ${format}`); + } +} + +export function findExtractedResourceDir(extractionRoot) { + const candidates = []; + + function visit(directoryPath) { + let entries; + try { + entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + } catch (error) { + throw new Error( + `Unable to inspect extracted package directory ${directoryPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + continue; + } + const entryPath = path.join(directoryPath, entry.name); + if (entry.name === 'resources') { + const nativeDir = path.join( + entryPath, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + let nativeStat; + try { + nativeStat = fs.lstatSync(nativeDir); + } catch { + nativeStat = null; + } + if (nativeStat?.isDirectory() && !nativeStat.isSymbolicLink()) { + candidates.push(entryPath); + continue; + } + } + visit(entryPath); + } + } + + visit(extractionRoot); + if (candidates.length !== 1) { + throw new Error( + `Expected exactly one embedded MPV native payload in ${extractionRoot}; found ${candidates.length}.` + ); + } + return candidates[0]; +} + +function stripYamlTrailingComment(value) { + let quote = null; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (quote === "'") { + if (character === "'" && value[index + 1] === "'") { + index += 1; + } else if (character === "'") { + quote = null; + } + continue; + } + if (quote === '"') { + if (character === '\\') { + index += 1; + } else if (character === '"') { + quote = null; + } + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if ( + character === '#' && + (index === 0 || /[ \t]/.test(value[index - 1])) + ) { + return value.slice(0, index).trimEnd(); + } + } + return value; +} + +function yamlMappingEntries(lines, key, indent, start = 0, end = lines.length) { + const prefix = `${' '.repeat(indent)}${key}:`; + return lines + .map((line, index) => ({ index, line })) + .filter( + ({ index, line }) => + index >= start && + index < end && + line.startsWith(prefix) && + line.slice(prefix.length).match(/^(?:\s|$)/) && + line.length - line.trimStart().length === indent + ) + .map(({ index, line }) => { + let blockEnd = end; + for ( + let candidateIndex = index + 1; + candidateIndex < end; + candidateIndex += 1 + ) { + const candidate = lines[candidateIndex]; + if ( + !candidate.trim() || + candidate.trimStart().startsWith('#') + ) { + continue; + } + const candidateIndent = + candidate.length - candidate.trimStart().length; + if (candidateIndent <= indent) { + blockEnd = candidateIndex; + break; + } + } + return { + index, + end: blockEnd, + value: stripYamlTrailingComment( + line.slice(prefix.length).trim() + ), + }; + }); +} + +function singleYamlMappingEntry(lines, key, indent, start, end) { + const entries = yamlMappingEntries(lines, key, indent, start, end); + return entries.length === 1 ? entries[0] : null; +} + +function directYamlMappingEntries(lines, parent, indent) { + const entries = []; + for (let index = parent.index + 1; index < parent.end; index += 1) { + const line = lines[index]; + if (!line.trim() || line.trimStart().startsWith('#')) { + continue; + } + const lineIndent = line.length - line.trimStart().length; + if (lineIndent !== indent) { + continue; + } + const match = line + .slice(indent) + .match(/^([A-Za-z0-9][A-Za-z0-9_-]*):(?:\s*(.*))?$/); + if (!match) { + return null; + } + let blockEnd = parent.end; + for ( + let candidateIndex = index + 1; + candidateIndex < parent.end; + candidateIndex += 1 + ) { + const candidate = lines[candidateIndex]; + if (!candidate.trim() || candidate.trimStart().startsWith('#')) { + continue; + } + const candidateIndent = + candidate.length - candidate.trimStart().length; + if (candidateIndent <= indent) { + blockEnd = candidateIndex; + break; + } + } + entries.push({ + key: match[1], + value: stripYamlTrailingComment(match[2] ?? ''), + index, + end: blockEnd, + }); + } + return entries; +} + +function directYamlAbsolutePathMappingEntries(lines, parent, indent) { + const entries = []; + for (let index = parent.index + 1; index < parent.end; index += 1) { + const line = lines[index]; + if (!line.trim() || line.trimStart().startsWith('#')) { + continue; + } + const lineIndent = line.length - line.trimStart().length; + if (lineIndent !== indent) { + continue; + } + const match = line + .slice(indent) + .match(/^(\/[A-Za-z0-9._/-]+):(?:\s*(.*))?$/); + if (!match) { + return null; + } + let blockEnd = parent.end; + for ( + let candidateIndex = index + 1; + candidateIndex < parent.end; + candidateIndex += 1 + ) { + const candidate = lines[candidateIndex]; + if (!candidate.trim() || candidate.trimStart().startsWith('#')) { + continue; + } + const candidateIndent = + candidate.length - candidate.trimStart().length; + if (candidateIndent <= indent) { + blockEnd = candidateIndex; + break; + } + } + entries.push({ + key: match[1], + value: stripYamlTrailingComment(match[2] ?? ''), + index, + end: blockEnd, + }); + } + return entries; +} + +function yamlScalarEquals(value, expected) { + return ( + value === expected || + value === JSON.stringify(expected) || + value === `'${expected.replaceAll("'", "''")}'` + ); +} + +function yamlSequenceIncludes(lines, entry, expected) { + if (entry.value) { + if (!entry.value.startsWith('[') || !entry.value.endsWith(']')) { + return false; + } + const values = entry.value + .slice(1, -1) + .split(',') + .map((value) => value.trim()); + return ( + values.length > 0 && + values.every( + (value) => + value.length > 0 && + !/[:[\]{}&*]/.test(value) && + !value.startsWith('-') + ) && + values.some((value) => yamlScalarEquals(value, expected)) + ); + } + const sequenceLines = lines + .slice(entry.index + 1, entry.end) + .filter((line) => line.trim() && !line.trimStart().startsWith('#')); + const itemIndent = 6; + const values = sequenceLines.map((line) => { + const lineIndent = line.length - line.trimStart().length; + if (lineIndent !== itemIndent) { + return null; + } + const match = line.trim().match(/^-\s+([^:[\]{}&*]+)$/); + return match ? stripYamlTrailingComment(match[1].trim()) : null; + }); + return ( + values.length > 0 && + values.every((value) => value !== null) && + values.some((value) => yamlScalarEquals(value, expected)) + ); +} + +function yamlSyntaxOutsideQuotedScalars(line) { + let result = ''; + let quote = null; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (quote === "'") { + if (character === "'" && line[index + 1] === "'") { + result += ' '; + index += 1; + } else { + result += ' '; + if (character === "'") { + quote = null; + } + } + continue; + } + if (quote === '"') { + result += ' '; + if (character === '\\') { + result += ' '; + index += 1; + } else if (character === '"') { + quote = null; + } + continue; + } + if (character === '#') { + break; + } + if (character === "'" || character === '"') { + quote = character; + result += ' '; + continue; + } + result += character; + } + return result; +} + +function containsUnsupportedYamlSemantics(lines) { + let blockScalarParentIndent = null; + for (const line of lines) { + if (!line.trim()) { + continue; + } + const lineIndent = line.length - line.trimStart().length; + if ( + blockScalarParentIndent !== null && + lineIndent > blockScalarParentIndent + ) { + continue; + } + blockScalarParentIndent = null; + const syntax = yamlSyntaxOutsideQuotedScalars(line); + if ( + /(?:^|[\s:[\]{},])(?:[&*][^\s,[\]{}]+|![^\s,[\]{}]+)(?=$|[\s,\]}])/.test( + syntax + ) || + /(?:^|\s)<<\s*:/.test(syntax) + ) { + return true; + } + if (/:\s*[>|][+-]?\d?\s*$/.test(syntax)) { + blockScalarParentIndent = lineIndent; + } + } + return false; +} + +export function validateExtractedSnapMetadata(extractionRoot) { + const snapYamlPath = path.join(extractionRoot, 'meta', 'snap.yaml'); + let stat; + try { + stat = fs.lstatSync(snapYamlPath); + } catch { + return [`Missing extracted Snap metadata: ${snapYamlPath}`]; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + return [ + `Extracted Snap metadata must be a regular file: ${snapYamlPath}`, + ]; + } + if (stat.size > SNAP_METADATA_MAX_BYTES) { + return [ + `Extracted Snap metadata exceeds the ${String( + SNAP_METADATA_MAX_BYTES + )}-byte size limit: ${snapYamlPath}`, + ]; + } + + let contents; + try { + contents = fs.readFileSync(snapYamlPath, 'utf8'); + } catch (error) { + return [ + `Unable to read extracted Snap metadata at ${snapYamlPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ]; + } + if (contents.includes('\t')) { + return [ + `Extracted Snap metadata must use space indentation: ${snapYamlPath}`, + ]; + } + const lines = contents.split(/\r?\n/); + if (containsUnsupportedYamlSemantics(lines)) { + return [ + 'Extracted Snap metadata must not use YAML anchors, aliases, merge keys, or custom tags.', + ]; + } + const errors = []; + const graphicsMountPath = path.join(extractionRoot, 'graphics'); + let graphicsMountStat; + try { + graphicsMountStat = fs.lstatSync(graphicsMountPath); + } catch { + errors.push( + `Extracted Snap must contain an empty graphics content mount directory: ${graphicsMountPath}` + ); + } + if ( + graphicsMountStat && + (!graphicsMountStat.isDirectory() || graphicsMountStat.isSymbolicLink()) + ) { + errors.push( + `Extracted Snap graphics content mount must be a real directory: ${graphicsMountPath}` + ); + } else if (graphicsMountStat) { + if ((graphicsMountStat.mode & 0o777) !== 0o755) { + errors.push( + `Extracted Snap graphics content mount directory must have mode 0755: ${graphicsMountPath}` + ); + } + try { + if (fs.readdirSync(graphicsMountPath).length > 0) { + errors.push( + `Extracted Snap graphics content mount directory must be empty: ${graphicsMountPath}` + ); + } + } catch (error) { + errors.push( + `Unable to inspect extracted Snap graphics content mount at ${graphicsMountPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + const base = singleYamlMappingEntry(lines, 'base', 0, 0, lines.length); + if (!base || !yamlScalarEquals(base.value, 'core22')) { + errors.push('Extracted Snap metadata must declare base: core22.'); + } + const confinement = singleYamlMappingEntry( + lines, + 'confinement', + 0, + 0, + lines.length + ); + if (!confinement || !yamlScalarEquals(confinement.value, 'strict')) { + errors.push( + 'Extracted Snap metadata must declare confinement: strict.' + ); + } + const layout = singleYamlMappingEntry(lines, 'layout', 0, 0, lines.length); + if (!layout || layout.value) { + errors.push( + 'Extracted Snap metadata must declare the exact Snap graphics layout contract.' + ); + } else { + const layoutEntries = directYamlAbsolutePathMappingEntries( + lines, + layout, + 2 + ); + const expectedLayouts = [ + { + path: '/usr/share/libdrm', + field: 'bind', + target: '$SNAP/graphics/libdrm', + label: 'libdrm', + }, + { + path: '/usr/share/drirc.d', + field: 'symlink', + target: '$SNAP/graphics/drirc.d', + label: 'drirc.d', + }, + ]; + if ( + layoutEntries && + new Set(layoutEntries.map(({ key }) => key)).size !== + layoutEntries.length + ) { + errors.push('Extracted Snap layout paths must be unique.'); + } + if ( + !layoutEntries || + layoutEntries.length !== expectedLayouts.length || + expectedLayouts.some( + ({ path: expectedPath }) => + layoutEntries.filter(({ key }) => key === expectedPath) + .length !== 1 + ) + ) { + errors.push( + 'Extracted Snap graphics layout must contain exactly the libdrm and drirc.d entries.' + ); + } + for (const expected of expectedLayouts) { + const entry = layoutEntries?.find( + ({ key }) => key === expected.path + ); + const fields = + entry && !entry.value + ? directYamlMappingEntries(lines, entry, 4) + : null; + if ( + !fields || + fields.length !== 1 || + fields[0].key !== expected.field + ) { + errors.push( + `Extracted Snap ${expected.label} layout must contain exactly ${expected.field}.` + ); + } + if ( + !fields || + fields.filter( + ({ key, value }) => + key === expected.field && + yamlScalarEquals(value, expected.target) + ).length !== 1 + ) { + errors.push( + `Extracted Snap ${expected.label} layout must declare ${expected.field}: ${expected.target}.` + ); + } + } + } + const plugs = singleYamlMappingEntry(lines, 'plugs', 0, 0, lines.length); + const sharedMemory = + plugs && + singleYamlMappingEntry( + lines, + 'shared-memory', + 2, + plugs.index + 1, + plugs.end + ); + const graphicsCore22 = + plugs && + singleYamlMappingEntry( + lines, + 'graphics-core22', + 2, + plugs.index + 1, + plugs.end + ); + if (!plugs || plugs.value || !sharedMemory || sharedMemory.value) { + errors.push( + 'Extracted Snap metadata must declare exactly one top-level shared-memory plug.' + ); + } else { + const fields = directYamlMappingEntries(lines, sharedMemory, 4); + const interfaceEntries = + fields?.filter(({ key }) => key === 'interface') ?? []; + const privateEntries = + fields?.filter(({ key }) => key === 'private') ?? []; + const interfaceEntry = interfaceEntries[0]; + const privateEntry = privateEntries[0]; + if ( + !fields || + fields.length !== 2 || + interfaceEntries.length !== 1 || + privateEntries.length !== 1 + ) { + errors.push( + 'Extracted Snap private shared-memory plug must contain exactly interface and private.' + ); + } + if ( + !interfaceEntry || + !yamlScalarEquals(interfaceEntry.value, 'shared-memory') + ) { + errors.push( + 'Extracted Snap top-level shared-memory plug must declare interface: shared-memory.' + ); + } + if (!privateEntry || privateEntry.value !== 'true') { + errors.push( + 'Extracted Snap top-level shared-memory plug must declare private: true.' + ); + } + + const plugEntries = directYamlMappingEntries(lines, plugs, 2); + if (!plugEntries || plugEntries.some(({ value }) => value.length > 0)) { + errors.push( + 'Extracted Snap plug declarations must use block mappings.' + ); + } + if ( + plugEntries && + new Set(plugEntries.map(({ key }) => key)).size !== + plugEntries.length + ) { + errors.push('Extracted Snap direct plug keys must be unique.'); + } + const sharedMemoryInterfaces = + plugEntries?.filter((plugEntry) => { + const plugFields = directYamlMappingEntries( + lines, + plugEntry, + 4 + ); + const interfaceFields = + plugFields?.filter(({ key }) => key === 'interface') ?? []; + return ( + interfaceFields.length === 1 && + yamlScalarEquals(interfaceFields[0].value, 'shared-memory') + ); + }) ?? []; + if ( + !plugEntries || + sharedMemoryInterfaces.length !== 1 || + sharedMemoryInterfaces[0].key !== 'shared-memory' + ) { + errors.push( + 'Extracted Snap metadata must declare exactly one plug with the shared-memory interface.' + ); + } + } + + if (!plugs || plugs.value || !graphicsCore22 || graphicsCore22.value) { + errors.push( + 'Extracted Snap metadata must declare exactly one top-level graphics-core22 plug.' + ); + } else { + const fields = directYamlMappingEntries(lines, graphicsCore22, 4); + const interfaceEntries = + fields?.filter(({ key }) => key === 'interface') ?? []; + const targetEntries = + fields?.filter(({ key }) => key === 'target') ?? []; + const providerEntries = + fields?.filter(({ key }) => key === 'default-provider') ?? []; + const interfaceEntry = interfaceEntries[0]; + const targetEntry = targetEntries[0]; + const providerEntry = providerEntries[0]; + if ( + !fields || + fields.length !== 3 || + interfaceEntries.length !== 1 || + targetEntries.length !== 1 || + providerEntries.length !== 1 + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must contain exactly interface, target, and default-provider.' + ); + } + if ( + !interfaceEntry || + !yamlScalarEquals(interfaceEntry.value, 'content') + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must declare interface: content.' + ); + } + if ( + !targetEntry || + !yamlScalarEquals(targetEntry.value, '$SNAP/graphics') + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must declare target: $SNAP/graphics.' + ); + } + if ( + !providerEntry || + !yamlScalarEquals(providerEntry.value, 'mesa-core22') + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must declare default-provider: mesa-core22.' + ); + } + + const plugEntries = directYamlMappingEntries(lines, plugs, 2); + const graphicsContracts = + plugEntries?.filter((plugEntry) => { + const plugFields = directYamlMappingEntries( + lines, + plugEntry, + 4 + ); + if (!plugFields || plugFields.length !== 3) { + return false; + } + return [ + ['interface', 'content'], + ['target', '$SNAP/graphics'], + ['default-provider', 'mesa-core22'], + ].every( + ([key, expected]) => + plugFields.filter( + ({ key: fieldKey, value }) => + fieldKey === key && + yamlScalarEquals(value, expected) + ).length === 1 + ); + }) ?? []; + if ( + !plugEntries || + graphicsContracts.length !== 1 || + graphicsContracts[0].key !== 'graphics-core22' + ) { + errors.push( + 'Extracted Snap metadata must declare exactly one plug with the graphics-core22 contract.' + ); + } + const graphicsTargets = + plugEntries?.filter((plugEntry) => { + const plugFields = directYamlMappingEntries( + lines, + plugEntry, + 4 + ); + return ( + plugFields?.some( + ({ key, value }) => + key === 'target' && + yamlScalarEquals(value, '$SNAP/graphics') + ) ?? false + ); + }) ?? []; + if ( + !plugEntries || + graphicsTargets.length !== 1 || + graphicsTargets[0].key !== 'graphics-core22' + ) { + errors.push( + 'Extracted Snap metadata must declare exactly one plug with target $SNAP/graphics.' + ); + } + } + + const slotsEntries = yamlMappingEntries(lines, 'slots', 0, 0, lines.length); + let hasSharedMemorySlot = slotsEntries.length > 1; + let invalidSlotsShape = slotsEntries.some(({ value }) => value.length > 0); + for (const slots of slotsEntries) { + const slotEntries = directYamlMappingEntries(lines, slots, 2); + if (!slotEntries) { + invalidSlotsShape = true; + continue; + } + invalidSlotsShape ||= slotEntries.some(({ value }) => value.length > 0); + hasSharedMemorySlot ||= slotEntries.some((slotEntry) => { + if (slotEntry.key === 'shared-memory') { + return true; + } + const slotFields = directYamlMappingEntries(lines, slotEntry, 4); + return ( + slotFields?.some( + ({ key, value }) => + key === 'interface' && + yamlScalarEquals(value, 'shared-memory') + ) ?? false + ); + }); + } + if (invalidSlotsShape) { + errors.push( + 'Extracted Snap slots must use block mappings with scalar fields.' + ); + } + if (hasSharedMemorySlot) { + errors.push( + 'Extracted Snap metadata must not declare a shared-memory slot.' + ); + } + + const apps = singleYamlMappingEntry(lines, 'apps', 0, 0, lines.length); + const app = + apps && + singleYamlMappingEntry(lines, 'iptvnator', 2, apps.index + 1, apps.end); + const appPlugs = + app && + singleYamlMappingEntry(lines, 'plugs', 4, app.index + 1, app.end); + const appEnvironment = + app && + singleYamlMappingEntry(lines, 'environment', 4, app.index + 1, app.end); + const snapDesktopRuntime = + appEnvironment && + singleYamlMappingEntry( + lines, + 'SNAP_DESKTOP_RUNTIME', + 6, + appEnvironment.index + 1, + appEnvironment.end + ); + if ( + !apps || + apps.value || + !app || + app.value || + !appPlugs || + !yamlSequenceIncludes(lines, appPlugs, 'shared-memory') + ) { + errors.push( + 'Extracted Snap iptvnator app scalar sequence must contain the shared-memory plug.' + ); + } + if ( + !apps || + apps.value || + !app || + app.value || + !appPlugs || + !yamlSequenceIncludes(lines, appPlugs, 'graphics-core22') + ) { + errors.push( + 'Extracted Snap iptvnator app scalar sequence must contain the graphics-core22 plug.' + ); + } + if ( + !appEnvironment || + appEnvironment.value || + !snapDesktopRuntime || + !yamlScalarEquals(snapDesktopRuntime.value, '$SNAP/gnome-platform') + ) { + errors.push( + 'Extracted Snap iptvnator app environment must declare SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform.' + ); + } + return errors; +} + +export function readElfArchitecture(binaryPath) { + const descriptor = fs.openSync(binaryPath, 'r'); + try { + const header = Buffer.alloc(20); + const bytesRead = fs.readSync(descriptor, header, 0, header.length, 0); + if ( + bytesRead !== header.length || + !header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + ) { + throw new Error(`Not an ELF binary: ${binaryPath}`); + } + const byteOrder = header[5]; + const machine = + byteOrder === 1 + ? header.readUInt16LE(18) + : byteOrder === 2 + ? header.readUInt16BE(18) + : null; + const architecture = new Map([ + [62, 'x64'], + [183, 'arm64'], + [40, 'armv7l'], + ]).get(machine); + if (!architecture) { + throw new Error( + `Unsupported ELF machine ${String(machine)} in ${binaryPath}.` + ); + } + return architecture; + } finally { + fs.closeSync(descriptor); + } +} + +function normalizePackageArchitecture(value) { + const normalized = String(value ?? '') + .trim() + .toLowerCase(); + const architecture = { + amd64: 'x64', + x86_64: 'x64', + arm64: 'arm64', + aarch64: 'arm64', + armhf: 'armv7l', + armv7h: 'armv7l', + armv7hl: 'armv7l', + }[normalized]; + if (!architecture) { + throw new Error( + `Unsupported Linux package architecture: ${ + normalized || '' + }.` + ); + } + return architecture; +} + +function splitNonEmptyLines(value) { + return String(value ?? '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function findPackageInfoFiles(extractionRoot) { + const packageInfoPaths = []; + + function visit(directoryPath) { + for (const entry of fs.readdirSync(directoryPath, { + withFileTypes: true, + })) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory() && !entry.isSymbolicLink()) { + visit(entryPath); + } else if (entry.isFile() && entry.name === '.PKGINFO') { + packageInfoPaths.push(entryPath); + } + } + } + + visit(extractionRoot); + return packageInfoPaths; +} + +export function readLinuxArtifactMetadata({ + artifactPath, + format, + extractionRoot, + runCommand = runVerifierCommand, +}) { + const run = (command, args) => + assertCommandSucceeded(command, args, runCommand(command, args)); + if (format === 'deb') { + const architectureResult = run('dpkg-deb', [ + '--field', + artifactPath, + 'Architecture', + ]); + const dependencyResult = run('dpkg-deb', [ + '--field', + artifactPath, + 'Depends', + ]); + return { + declaredArch: normalizePackageArchitecture( + architectureResult.stdout + ), + dependencies: String(dependencyResult.stdout ?? '') + .split(',') + .map((dependency) => dependency.trim()) + .filter(Boolean), + }; + } + if (format === 'rpm') { + const architectureResult = run('rpm', [ + '-qp', + '--queryformat', + '%{ARCH}\\n', + artifactPath, + ]); + const dependencyResult = run('rpm', [ + '-qp', + '--requires', + artifactPath, + ]); + return { + declaredArch: normalizePackageArchitecture( + architectureResult.stdout + ), + dependencies: splitNonEmptyLines(dependencyResult.stdout), + }; + } + if (format === 'pacman') { + const packageInfoPaths = findPackageInfoFiles(extractionRoot); + if (packageInfoPaths.length !== 1) { + throw new Error( + `Pacman payload must contain exactly one .PKGINFO; found ${packageInfoPaths.length}.` + ); + } + const fields = fs + .readFileSync(packageInfoPaths[0], 'utf8') + .split(/\r?\n/) + .map((line) => line.match(/^([^=]+?)\s*=\s*(.*)$/)) + .filter(Boolean) + .map((match) => ({ + name: match[1].trim(), + value: match[2].trim(), + })); + const architectures = fields + .filter(({ name }) => name === 'arch') + .map(({ value }) => value); + if (architectures.length !== 1) { + throw new Error( + `Pacman .PKGINFO must declare exactly one architecture; found ${architectures.length}.` + ); + } + return { + declaredArch: normalizePackageArchitecture(architectures[0]), + dependencies: fields + .filter(({ name }) => name === 'depend') + .map(({ value }) => value), + }; + } + return { + declaredArch: null, + dependencies: [], + }; +} + +function dependencyMatches(dependency, expected) { + const escapedExpected = expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escapedExpected}(?:$|\\s|[<>=])`).test( + dependency.trim() + ); +} + +export function validateSystemPackageDependencies(format, dependencies) { + const expectedDependencies = LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format]; + if (!expectedDependencies) { + return []; + } + if (!Array.isArray(dependencies)) { + return [ + `Linux ${format} package dependency metadata must be an array.`, + ]; + } + return expectedDependencies.flatMap((expectedDependency) => + dependencies.some((dependency) => + dependencyMatches(String(dependency), expectedDependency) + ) + ? [] + : [ + `Linux x64 ${format} package must declare ${expectedDependency}.`, + ] + ); +} + +function validateForeignPackageDependencies(format, dependencies) { + const forbiddenDependencies = + LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format] ?? []; + return forbiddenDependencies.flatMap((forbiddenDependency) => + dependencies.some((dependency) => + dependencyMatches(String(dependency), forbiddenDependency) + ) + ? [ + `Linux foreign-architecture ${format} package must not declare frame-copy dependency ${forbiddenDependency}.`, + ] + : [] + ); +} + +function dependencyFileName(dependencyName) { + return String(dependencyName).replaceAll('\\', '/').split('/').at(-1) ?? ''; +} + +function validateElectronIsolation(resourceDir, artifactFormat, elfInspector) { + const errors = []; + const electronPath = path.join(path.dirname(resourceDir), 'iptvnator.bin'); + const binaries = [ + { label: 'Electron binary', binaryPath: electronPath }, + ...listElectronShippedLinuxLibraries(resourceDir, { + artifactFormat, + }).map((binaryPath) => ({ + label: 'Electron library', + binaryPath, + })), + ]; + for (const { label, binaryPath } of binaries) { + let stat; + try { + stat = fs.lstatSync(binaryPath); + } catch { + errors.push(`Missing ${label}: ${binaryPath}`); + continue; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + errors.push(`${label} must be a regular file: ${binaryPath}`); + continue; + } + let dynamic; + try { + dynamic = elfInspector(binaryPath); + } catch (error) { + errors.push( + `Unable to inspect ${label} at ${binaryPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + continue; + } + if (!dynamic || !Array.isArray(dynamic.needed)) { + errors.push( + `ELF inspection for ${label} must provide a needed array: ${binaryPath}` + ); + continue; + } + const libmpvDependencies = dynamic.needed.filter((dependencyName) => + LIBMPV_DEPENDENCY_PATTERN.test(dependencyFileName(dependencyName)) + ); + if (libmpvDependencies.length > 0) { + errors.push( + `${label} must not link libmpv; found ${libmpvDependencies.join( + ', ' + )} in ${binaryPath}.` + ); + } + } + return errors; +} + +function defaultElfInspector(binaryPath) { + const result = assertCommandSucceeded( + 'readelf', + ['-d', binaryPath], + runVerifierCommand('readelf', ['-d', binaryPath]) + ); + return parseReadelfDynamic(result.stdout); +} + +function validateProbeResult(result) { + if (result?.error) { + return [ + `Unable to execute Linux frame-copy runtime probe: ${ + result.error instanceof Error + ? result.error.message + : String(result.error) + }`, + ]; + } + if (result?.signal) { + return [ + `Linux frame-copy runtime probe terminated by signal ${result.signal}.`, + ]; + } + if (result?.status !== 0) { + const details = [result?.stdout, result?.stderr] + .filter(Boolean) + .join('\n') + .trim(); + return [ + `Linux frame-copy runtime probe failed with status ${ + result?.status ?? 'unknown' + }.${details ? `\n${details}` : ''}`, + ]; + } + const stdout = result.stdout; + if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) { + return [ + 'Linux frame-copy runtime probe must emit exactly one newline-terminated JSON line.', + ]; + } + let payload; + try { + payload = JSON.parse(stdout.slice(0, -1)); + } catch (error) { + return [ + `Linux frame-copy runtime probe emitted invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ]; + } + if ( + !payload || + typeof payload !== 'object' || + Array.isArray(payload) || + JSON.stringify(Object.keys(payload).sort()) !== + JSON.stringify(['libmpv', 'protocol', 'renderApi', 'usable']) || + payload.protocol !== 1 || + payload.usable !== true || + typeof payload.libmpv !== 'string' || + payload.libmpv.trim() === '' || + payload.renderApi !== 'egl' + ) { + return [ + 'Linux frame-copy runtime probe did not return protocol 1 usable EGL capability.', + ]; + } + return []; +} + +export function createRuntimeProbeEnvironment({ + environment, + nativeDir, + runtimeMode, +}) { + const probeEnvironment = { ...(environment ?? {}) }; + for (const variableName of UNSAFE_RUNTIME_PROBE_ENVIRONMENT_VARIABLES) { + delete probeEnvironment[variableName]; + } + for (const variableName of Object.keys(probeEnvironment)) { + if (variableName.startsWith('BASH_FUNC_')) { + delete probeEnvironment[variableName]; + } + } + if (runtimeMode === 'bundled') { + probeEnvironment.LD_LIBRARY_PATH = path.join(nativeDir, 'lib'); + } + return probeEnvironment; +} + +function validateNoEmbeddedMpvNativeArchiveEntries( + resourceDir, + asarListPackage +) { + const asarPath = path.join(resourceDir, 'app.asar'); + if (!fs.existsSync(asarPath)) { + return []; + } + + let nativeEntries; + try { + nativeEntries = collectEmbeddedMpvNativeArchiveEntries( + asarListPackage(asarPath) + ); + } catch (error) { + return [ + `Unable to inspect embedded MPV archive ownership in ${asarPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ]; + } + if (nativeEntries.length === 0) { + return []; + } + return [ + [ + `Packaged app.asar must not contain embedded MPV native payloads; afterPack exclusively owns the profile-specific unpacked directory in ${asarPath}.`, + ...nativeEntries.map((entry) => `- ${entry}`), + ].join('\n'), + ]; +} + +export function verifyExtractedLinuxFrameCopyRuntime({ + resourceDir, + artifactFormat, + profileName, + packageDependencies = [], + declaredArch = null, + elfInspector = defaultElfInspector, + probeRunner = runVerifierCommand, + environment = process.env, + asarListPackage = listAsarPackageEntries, +}) { + const errors = []; + let profile; + try { + profile = resolveLinuxFrameCopyProfile(profileName); + } catch (error) { + return [error instanceof Error ? error.message : String(error)]; + } + if (!profile.targets.includes(artifactFormat)) { + return [ + `Linux frame-copy profile "${profile.name}" does not include target "${artifactFormat}".`, + ]; + } + errors.push( + ...validateNoEmbeddedMpvNativeArchiveEntries( + resourceDir, + asarListPackage + ) + ); + + const electronPath = path.join(path.dirname(resourceDir), 'iptvnator.bin'); + let packageArch; + try { + packageArch = readElfArchitecture(electronPath); + } catch (error) { + return [ + ...errors, + error instanceof Error ? error.message : String(error), + ]; + } + const foreignArch = packageArch !== 'x64'; + if (declaredArch && declaredArch !== packageArch) { + errors.push( + `Package metadata architecture ${declaredArch} does not match Electron ELF architecture ${packageArch}.` + ); + } + if (profile.runtimeMode === 'system') { + errors.push( + ...(foreignArch + ? validateForeignPackageDependencies( + artifactFormat, + packageDependencies + ) + : validateSystemPackageDependencies( + artifactFormat, + packageDependencies + )) + ); + } + + errors.push( + ...validatePackagedEmbeddedMpv(resourceDir, { + platform: 'linux', + required: true, + foreignArch, + targetArch: packageArch, + profile: profile.name, + targetNames: profile.targets, + artifactFormat, + hostPlatform: 'linux', + executableName: 'iptvnator', + elfInspector, + }) + ); + errors.push( + ...validateElectronIsolation(resourceDir, artifactFormat, elfInspector) + ); + if (foreignArch || errors.length > 0) { + return errors; + } + + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const probeEnvironment = createRuntimeProbeEnvironment({ + environment, + nativeDir, + runtimeMode: profile.runtimeMode, + }); + const probeResult = probeRunner( + path.join(nativeDir, 'iptvnator_mpv_helper'), + ['--runtime-probe'], + { + encoding: 'utf8', + env: probeEnvironment, + killSignal: 'SIGKILL', + maxBuffer: RUNTIME_PROBE_MAX_BUFFER_BYTES, + timeout: RUNTIME_PROBE_TIMEOUT_MS, + windowsHide: true, + } + ); + errors.push(...validateProbeResult(probeResult)); + return errors; +} + +export function verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName, + runCommand = runVerifierCommand, + extractArtifact = extractLinuxArtifact, + metadataReader = readLinuxArtifactMetadata, + payloadVerifier = verifyExtractedLinuxFrameCopyRuntime, + elfInspector = defaultElfInspector, + probeRunner = runVerifierCommand, + environment = process.env, +}) { + const resolvedArtifactPath = path.resolve(artifactPath); + const artifactStat = fs.lstatSync(resolvedArtifactPath); + if (!artifactStat.isFile() || artifactStat.isSymbolicLink()) { + throw new Error( + `Linux package artifact must be a regular file: ${resolvedArtifactPath}` + ); + } + const profile = resolveLinuxFrameCopyProfile(profileName); + const format = detectLinuxArtifactFormat(resolvedArtifactPath); + if (!profile.targets.includes(format)) { + throw new Error( + `Linux frame-copy profile "${profile.name}" does not include target "${format}".` + ); + } + + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-package-verifier-') + ); + try { + const extractionDestination = path.join(temporaryRoot, 'payload'); + const extractionRoot = extractArtifact({ + artifactPath: resolvedArtifactPath, + format, + destination: extractionDestination, + runCommand, + }); + if (format === 'snap') { + const snapMetadataErrors = + validateExtractedSnapMetadata(extractionRoot); + if (snapMetadataErrors.length > 0) { + throw new Error( + [ + `Linux frame-copy package verification failed for ${resolvedArtifactPath}:`, + ...snapMetadataErrors.map((error) => `- ${error}`), + ].join('\n') + ); + } + } + const resourceDir = findExtractedResourceDir(extractionRoot); + const metadata = metadataReader({ + artifactPath: resolvedArtifactPath, + format, + extractionRoot, + runCommand, + }); + const errors = payloadVerifier({ + resourceDir, + artifactFormat: format, + profileName: profile.name, + packageDependencies: metadata.dependencies, + declaredArch: metadata.declaredArch, + elfInspector, + probeRunner, + environment, + }); + if (errors.length > 0) { + throw new Error( + [ + `Linux frame-copy package verification failed for ${resolvedArtifactPath}:`, + ...errors.map((error) => `- ${error}`), + ].join('\n') + ); + } + const electronPath = path.join( + path.dirname(resourceDir), + 'iptvnator.bin' + ); + return { + artifactPath: resolvedArtifactPath, + format, + profileName: profile.name, + architecture: readElfArchitecture(electronPath), + }; + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function main() { + const options = parseVerifierArguments(process.argv.slice(2)); + const result = verifyLinuxFrameCopyArtifact(options); + process.stdout.write( + `Verified ${result.format} ${result.architecture} Linux frame-copy package (${result.profileName}): ${result.artifactPath}\n` + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs new file mode 100644 index 000000000..ad2b7154e --- /dev/null +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -0,0 +1,1860 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + createRuntimeProbeEnvironment, + detectLinuxArtifactFormat, + extractLinuxArtifact, + findAppImageSquashfsOffsets, + findExtractedResourceDir, + parseVerifierArguments, + readLinuxArtifactMetadata, + readElfArchitecture, + runVerifierCommand, + validateSystemPackageDependencies, + validateExtractedSnapMetadata, + verifyExtractedLinuxFrameCopyRuntime, + verifyLinuxFrameCopyArtifact, +} from './verify-linux-frame-copy-runtime.mjs'; + +const runtimeProbeContractUrl = new URL( + '../embedded-mpv/runtime-probe-contract.cjs', + import.meta.url +); +const runtimeProbeContractTypesUrl = new URL( + '../embedded-mpv/runtime-probe-contract.d.cts', + import.meta.url +); +const verifierUrl = new URL( + './verify-linux-frame-copy-runtime.mjs', + import.meta.url +); +const backendRuntimeContractsUrl = new URL( + '../../apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts', + import.meta.url +); +const backendRuntimeProbeUrl = new URL( + '../../apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts', + import.meta.url +); +const electronBackendProjectUrl = new URL( + '../../apps/electron-backend/project.json', + import.meta.url +); +const SYSTEM_TARGETS = ['deb', 'pacman', 'rpm']; +const SYSTEM_MANIFEST = { + schemaVersion: 1, + origin: 'system-libmpv-frame-copy', + generatedAt: '2026-07-17T00:00:00.000Z', + platform: 'linux', + arch: 'x64', + profile: 'system', + runtimeMode: 'system', + targets: SYSTEM_TARGETS, + artifacts: { + addon: { + name: 'embedded_mpv.node', + regularFile: true, + readable: true, + }, + frameReader: { + name: 'embedded_mpv_frame_reader.node', + regularFile: true, + readable: true, + }, + helper: { + name: 'iptvnator_mpv_helper', + regularFile: true, + readable: true, + executable: true, + }, + }, + processIsolation: { + addonLoadsLibmpv: false, + readerLoadsLibmpv: false, + electronLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + libmpvSoname: 'libmpv.so.2', + packageDependencies: { + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-glx', 'mesa-libgbm'], + pacman: ['mpv', 'libglvnd', 'mesa'], + }, + runtimeFiles: [], + runtimeTotalBytes: 0, +}; +const DEB_SYSTEM_PACKAGE_DEPENDENCIES = [ + 'libmpv2', + 'libegl1', + 'libgl1', + 'libgbm1', +]; + +test('uses the shared frozen runtime probe resource contract', async () => { + assert.equal( + fs.existsSync(runtimeProbeContractUrl), + true, + 'the shared runtime probe contract must exist' + ); + assert.equal( + fs.existsSync(runtimeProbeContractTypesUrl), + true, + 'the shared runtime probe contract types must exist' + ); + + const { default: runtimeProbeContract } = await import( + runtimeProbeContractUrl.href + ); + assert.equal(Object.isFrozen(runtimeProbeContract), true); + assert.equal(runtimeProbeContract.RUNTIME_PROBE_TIMEOUT_MS, 3000); + assert.equal( + runtimeProbeContract.RUNTIME_PROBE_MAX_BUFFER_BYTES, + 16 * 1024 * 1024 + ); + assert.match( + fs.readFileSync(runtimeProbeContractTypesUrl, 'utf8'), + /readonly RUNTIME_PROBE_TIMEOUT_MS: 3000/ + ); + assert.match( + fs.readFileSync(runtimeProbeContractTypesUrl, 'utf8'), + /readonly RUNTIME_PROBE_MAX_BUFFER_BYTES: 16777216/ + ); + + const verifierSource = fs.readFileSync(verifierUrl, 'utf8'); + assert.match( + verifierSource, + /require\(['"]\.\.\/embedded-mpv\/runtime-probe-contract\.cjs['"]\)/ + ); + assert.doesNotMatch( + verifierSource, + /const RUNTIME_PROBE_TIMEOUT_MS\s*=\s*3000/ + ); + + const backendContractsSource = fs.readFileSync( + backendRuntimeContractsUrl, + 'utf8' + ); + assert.match( + backendContractsSource, + /import runtimeProbeContract = require\(['"][^'"]*\/runtime-probe-contract\.cjs['"]\)/ + ); + assert.match( + backendContractsSource, + /export const \{\s*RUNTIME_PROBE_MAX_BUFFER_BYTES,\s*RUNTIME_PROBE_TIMEOUT_MS,?\s*\}\s*=\s*runtimeProbeContract/ + ); + assert.doesNotMatch( + backendContractsSource, + /RUNTIME_PROBE_TIMEOUT_MS\s*=\s*3000/ + ); + for (const sourceUrl of [verifierUrl, backendRuntimeProbeUrl]) { + assert.match( + fs.readFileSync(sourceUrl, 'utf8'), + /maxBuffer:\s*RUNTIME_PROBE_MAX_BUFFER_BYTES/ + ); + } +}); + +test('preserves the child-process default unless an explicit capture bound is requested', () => { + const outputScript = 'process.stdout.write("x".repeat(2 * 1024 * 1024))'; + const defaultCapture = runVerifierCommand(process.execPath, [ + '-e', + outputScript, + ]); + assert.equal(defaultCapture.error?.code, 'ENOBUFS'); + + const explicitCapture = runVerifierCommand( + process.execPath, + ['-e', outputScript], + { maxBuffer: 3 * 1024 * 1024 } + ); + assert.equal(explicitCapture.error, undefined); + assert.equal(explicitCapture.status, 0); + assert.equal(explicitCapture.stdout.length, 2 * 1024 * 1024); +}); + +test('tracks the shared probe contract in cached backend targets and runtime lint', () => { + const backendProject = JSON.parse( + fs.readFileSync(electronBackendProjectUrl, 'utf8') + ); + const contractInputs = [ + '{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs', + '{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts', + ]; + + for (const targetName of ['build', 'build-e2e', 'test', 'lint']) { + const inputs = backendProject.targets[targetName]?.inputs ?? []; + for (const contractInput of contractInputs) { + assert.ok( + inputs.includes(contractInput), + `${targetName} must track ${contractInput}` + ); + } + } + + assert.match( + backendProject.targets.lint.command, + /embedded-mpv-frame-copy-runtime\.ts/ + ); + assert.match( + backendProject.targets.lint.command, + /embedded-mpv-frame-copy-runtime\/\*\*\/\*\.ts/ + ); +}); + +function elfHeader(architecture) { + const machines = { + x64: 62, + arm64: 183, + armv7l: 40, + }; + const image = Buffer.alloc(64); + image.set([0x7f, 0x45, 0x4c, 0x46, 2, 1], 0); + image.writeUInt16LE(machines[architecture], 18); + return image; +} + +function createSystemPayload({ architecture = 'x64' } = {}) { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-layout-') + ); + const appDir = path.join(root, 'opt', 'IPTVnator'); + const resourceDir = path.join(appDir, 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + fs.writeFileSync( + path.join(appDir, 'iptvnator.bin'), + elfHeader(architecture) + ); + + if (architecture === 'x64') { + fs.writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', { + mode: 0o644, + }); + fs.writeFileSync( + path.join(nativeDir, 'embedded_mpv_frame_reader.node'), + 'reader', + { mode: 0o644 } + ); + fs.writeFileSync( + path.join(nativeDir, 'iptvnator_mpv_helper'), + 'helper', + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-runtime.json'), + `${JSON.stringify(SYSTEM_MANIFEST, null, 2)}\n`, + { mode: 0o644 } + ); + } else { + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-unavailable.txt'), + `Unavailable for ${architecture}\n` + ); + } + + return { root, appDir, resourceDir, nativeDir }; +} + +function validElfInspector(binaryPath) { + const name = path.basename(binaryPath); + if (name === 'iptvnator_mpv_helper') { + return { + soname: null, + needed: ['libc.so.6', 'libmpv.so.2'], + rpath: [], + runpath: ['$ORIGIN/lib'], + }; + } + return { + soname: null, + needed: ['libc.so.6'], + rpath: [], + runpath: [], + }; +} + +function successfulProbeRunner() { + return { + status: 0, + signal: null, + stdout: '{"protocol":1,"usable":true,"libmpv":"0.41.0","renderApi":"egl"}\n', + stderr: '', + }; +} + +test('detects every supported Linux package payload format', () => { + assert.equal(detectLinuxArtifactFormat('IPTVnator.AppImage'), 'appimage'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.deb'), 'deb'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.rpm'), 'rpm'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.pacman'), 'pacman'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.pkg.tar.zst'), 'pacman'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.snap'), 'snap'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.flatpak'), 'flatpak'); + assert.throws( + () => detectLinuxArtifactFormat('IPTVnator.tar.gz'), + /Unsupported Linux package artifact/ + ); +}); + +test('parses the required artifact and profile arguments without evaluating paths', () => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-args-') + ); + const artifactPath = path.join(directory, 'package $(touch owned).deb'); + fs.writeFileSync(artifactPath, 'fixture'); + + try { + assert.deepEqual( + parseVerifierArguments([ + '--artifact', + artifactPath, + '--profile', + 'system', + ]), + { + artifactPath: path.resolve(artifactPath), + profileName: 'system', + } + ); + assert.throws( + () => parseVerifierArguments(['--artifact', artifactPath]), + /--profile/ + ); + assert.throws( + () => + parseVerifierArguments([ + '--artifact', + artifactPath, + '--profile', + 'standard', + ]), + /Unsupported Linux frame-copy profile/ + ); + assert.throws( + () => + parseVerifierArguments([ + '--artifact', + artifactPath, + '--artifact', + artifactPath, + '--profile', + 'system', + ]), + /duplicate --artifact/ + ); + assert.throws( + () => + parseVerifierArguments([ + '--artifact', + artifactPath, + '--profile', + 'system', + '--profile', + 'system', + ]), + /duplicate --profile/ + ); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test('extracts every payload format with argument arrays and no shell', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-extract-') + ); + const invocations = []; + const runCommand = (command, args, options = {}) => { + invocations.push({ command, args: [...args], cwd: options.cwd }); + if (command === 'ostree' && args[0] === 'refs') { + return { + status: 0, + stdout: 'app/com.fourgray.iptvnator/x86_64/stable\n', + stderr: '', + }; + } + return { status: 0, stdout: '', stderr: '' }; + }; + + try { + for (const [format, fileName] of [ + ['appimage', 'IPTVnator.AppImage'], + ['deb', 'IPTVnator.deb'], + ['rpm', 'IPTVnator.rpm'], + ['pacman', 'IPTVnator.pacman'], + ['snap', 'IPTVnator.snap'], + ['flatpak', 'IPTVnator.flatpak'], + ]) { + const artifactPath = path.join(root, fileName); + const destination = path.join(root, `${format}-payload`); + fs.writeFileSync( + artifactPath, + format === 'appimage' + ? Buffer.concat([Buffer.alloc(4096), Buffer.from('hsqs')]) + : 'fixture' + ); + fs.mkdirSync(destination); + extractLinuxArtifact({ + artifactPath, + format, + destination, + runCommand, + }); + } + + assert.deepEqual( + invocations.map(({ command }) => command), + [ + 'unsquashfs', + 'dpkg-deb', + 'bsdtar', + 'bsdtar', + 'unsquashfs', + 'ostree', + 'flatpak', + 'ostree', + 'ostree', + ] + ); + assert.equal( + invocations.some( + ({ command, args }) => + ['sh', 'bash'].includes(command) || args.includes('-c') + ), + false + ); + assert.deepEqual(invocations[1].args.slice(0, 2), [ + '--extract', + path.join(root, 'IPTVnator.deb'), + ]); + assert.deepEqual(invocations[2].args.slice(0, 2), [ + '--extract', + '--file', + ]); + assert.deepEqual(invocations[4].args.slice(0, 2), [ + '-no-progress', + '-dest', + ]); + assert.equal(invocations[5].args[1], 'init'); + assert.ok(invocations[5].args.includes('--mode=archive-z2')); + assert.equal(invocations[8].args[0], 'checkout'); + assert.ok(invocations[8].args.includes('-U')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('gives unsquashfs a fresh destination for every AppImage and Snap extraction', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-unsquashfs-') + ); + const appImagePath = path.join(root, 'IPTVnator.AppImage'); + const snapPath = path.join(root, 'IPTVnator.snap'); + const appImageDestination = path.join(root, 'appimage-payload'); + const snapDestination = path.join(root, 'snap-payload'); + fs.writeFileSync( + appImagePath, + Buffer.concat([ + Buffer.alloc(128), + Buffer.from('hsqs'), + Buffer.alloc(64), + Buffer.from('hsqs'), + ]) + ); + fs.writeFileSync(snapPath, 'snap fixture'); + fs.mkdirSync(appImageDestination); + fs.mkdirSync(snapDestination); + let appImageAttempt = 0; + + try { + extractLinuxArtifact({ + artifactPath: appImagePath, + format: 'appimage', + destination: appImageDestination, + runCommand: (command) => { + assert.equal(command, 'unsquashfs'); + assert.equal(fs.existsSync(appImageDestination), false); + appImageAttempt += 1; + fs.mkdirSync(appImageDestination); + return { + status: appImageAttempt === 1 ? 1 : 0, + stdout: '', + stderr: '', + }; + }, + }); + assert.equal(appImageAttempt, 2); + + extractLinuxArtifact({ + artifactPath: snapPath, + format: 'snap', + destination: snapDestination, + runCommand: (command) => { + assert.equal(command, 'unsquashfs'); + assert.equal(fs.existsSync(snapDestination), false); + fs.mkdirSync(snapDestination); + return { status: 0, stdout: '', stderr: '' }; + }, + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('initializes a user-checkout OSTree repository before importing Flatpak bundles', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-flatpak-repo-') + ); + const artifactPath = path.join(root, 'IPTVnator.flatpak'); + const destination = path.join(root, 'flatpak-payload'); + const invocations = []; + let initializedRepository = null; + fs.writeFileSync(artifactPath, 'flatpak fixture'); + + try { + extractLinuxArtifact({ + artifactPath, + format: 'flatpak', + destination, + runCommand: (command, args) => { + invocations.push([command, ...args]); + if (command === 'ostree' && args.includes('init')) { + initializedRepository = args + .find((argument) => argument.startsWith('--repo=')) + ?.slice('--repo='.length); + assert.ok(args.includes('--mode=archive-z2')); + return { status: 0, stdout: '', stderr: '' }; + } + if (command === 'flatpak') { + assert.equal(args[0], 'build-import-bundle'); + assert.equal(args[1], initializedRepository); + if (!initializedRepository) { + return { + status: 1, + stdout: '', + stderr: 'error: opening repo: opendir(objects): No such file or directory', + }; + } + return { status: 0, stdout: '', stderr: '' }; + } + if (command === 'ostree' && args[0] === 'refs') { + return { + status: 0, + stdout: 'app/com.fourgray.iptvnator/x86_64/stable\n', + stderr: '', + }; + } + if (command === 'ostree' && args[0] === 'checkout') { + assert.ok(args.includes('-U')); + return { status: 0, stdout: '', stderr: '' }; + } + throw new Error(`Unexpected command: ${command}`); + }, + }); + + assert.deepEqual( + invocations.map(([command, ...args]) => [ + command, + args.find((argument) => + [ + 'init', + 'build-import-bundle', + 'refs', + 'checkout', + ].includes(argument) + ), + ]), + [ + ['ostree', 'init'], + ['flatpak', 'build-import-bundle'], + ['ostree', 'refs'], + ['ostree', 'checkout'], + ] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('locates AppImage SquashFS payloads without executing a foreign-arch runtime', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-appimage-') + ); + const artifactPath = path.join(root, 'arm64.AppImage'); + fs.writeFileSync( + artifactPath, + Buffer.concat([ + Buffer.alloc(128, 0x41), + Buffer.from('hsqs'), + Buffer.alloc(64), + Buffer.from('hsqs'), + ]) + ); + try { + assert.deepEqual(findAppImageSquashfsOffsets(artifactPath), [128, 196]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('finds one packaged native payload under arbitrary format roots', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-payload-') + ); + const resourceDir = path.join(root, 'opt', 'IPTVnator', 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + + try { + assert.equal(findExtractedResourceDir(root), resourceDir); + const duplicateNativeDir = path.join( + root, + 'duplicate', + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(duplicateNativeDir, { recursive: true }); + assert.throws( + () => findExtractedResourceDir(root), + /exactly one embedded MPV native payload/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('reads x64, arm64, and armv7 ELF architectures without host execution', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-elf-') + ); + try { + for (const architecture of ['x64', 'arm64', 'armv7l']) { + const binaryPath = path.join(root, architecture); + fs.writeFileSync(binaryPath, elfHeader(architecture)); + assert.equal(readElfArchitecture(binaryPath), architecture); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('requires every direct helper runtime dependency for DEB, RPM, and Pacman', () => { + assert.deepEqual( + validateSystemPackageDependencies('deb', [ + 'libmpv2 (>= 0.35)', + 'libegl1', + 'libgl1', + 'libgbm1', + 'libc6', + ]), + [] + ); + assert.deepEqual( + validateSystemPackageDependencies('rpm', [ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-glx', + 'mesa-libgbm', + 'glibc', + ]), + [] + ); + assert.deepEqual( + validateSystemPackageDependencies('pacman', [ + 'mpv>=0.35', + 'libglvnd', + 'mesa', + 'glibc', + ]), + [] + ); + assert.match( + validateSystemPackageDependencies('deb', [ + 'libmpv2', + 'libegl1', + 'libgbm1', + ])[0], + /libgl1/ + ); +}); + +test('reads DEB and RPM metadata without shell pipelines', () => { + const invocations = []; + const runCommand = (command, args) => { + invocations.push({ command, args: [...args] }); + if (command === 'dpkg-deb' && args.at(-1) === 'Architecture') { + return { status: 0, stdout: 'amd64\n', stderr: '' }; + } + if (command === 'dpkg-deb') { + return { + status: 0, + stdout: 'libmpv2 (>= 0.35), libc6\n', + stderr: '', + }; + } + if (command === 'rpm' && args.includes('%{ARCH}\\n')) { + return { status: 0, stdout: 'x86_64\n', stderr: '' }; + } + return { + status: 0, + stdout: 'mpv-libs\nglibc\n', + stderr: '', + }; + }; + + assert.deepEqual( + readLinuxArtifactMetadata({ + artifactPath: '/tmp/package name.deb', + format: 'deb', + extractionRoot: '/tmp/payload', + runCommand, + }), + { + declaredArch: 'x64', + dependencies: ['libmpv2 (>= 0.35)', 'libc6'], + } + ); + assert.deepEqual( + readLinuxArtifactMetadata({ + artifactPath: '/tmp/package name.rpm', + format: 'rpm', + extractionRoot: '/tmp/payload', + runCommand, + }), + { + declaredArch: 'x64', + dependencies: ['mpv-libs', 'glibc'], + } + ); + assert.equal( + invocations.some( + ({ command, args }) => + ['sh', 'bash'].includes(command) || args.includes('-c') + ), + false + ); +}); + +test('reads Pacman architecture and dependencies from the extracted .PKGINFO', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-pacman-') + ); + fs.writeFileSync( + path.join(root, '.PKGINFO'), + [ + 'pkgname = iptvnator', + 'arch = x86_64', + 'depend = mpv>=0.35', + 'depend = glibc', + '', + ].join('\n') + ); + try { + assert.deepEqual( + readLinuxArtifactMetadata({ + artifactPath: '/tmp/package.pacman', + format: 'pacman', + extractionRoot: root, + }), + { + declaredArch: 'x64', + dependencies: ['mpv>=0.35', 'glibc'], + } + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('validates an x64 system payload and executes one bounded helper probe', () => { + const fixture = createSystemPayload(); + const probeCalls = []; + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: [ + 'libmpv2 (>= 0.35)', + 'libegl1', + 'libgl1', + 'libgbm1', + ], + elfInspector: validElfInspector, + probeRunner(command, args, options) { + probeCalls.push({ command, args, options }); + return successfulProbeRunner(); + }, + environment: { + PATH: '/usr/bin', + LD_AUDIT: '/host/can-inject-audit.so', + LD_LIBRARY_PATH: '/host/can-mask-missing-dependencies', + LD_PRELOAD: '/host/can-inject.so', + }, + }); + assert.deepEqual(errors, []); + assert.equal(probeCalls.length, 1); + assert.equal( + probeCalls[0].command, + path.join(fixture.nativeDir, 'iptvnator_mpv_helper') + ); + assert.deepEqual(probeCalls[0].args, ['--runtime-probe']); + assert.deepEqual(probeCalls[0].options, { + encoding: 'utf8', + env: { PATH: '/usr/bin' }, + killSignal: 'SIGKILL', + maxBuffer: 16 * 1024 * 1024, + timeout: 3000, + windowsHide: true, + }); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('rejects any stale embedded MPV native payload hidden inside app.asar', () => { + const fixture = createSystemPayload(); + fs.writeFileSync(path.join(fixture.resourceDir, 'app.asar'), 'fixture'); + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: validElfInspector, + probeRunner: successfulProbeRunner, + asarListPackage: () => [ + '/electron-backend/main.js', + '/electron-backend/native/iptvnator_mpv_helper', + '/electron-backend/native/lib/libmpv.so.2', + ], + }); + assert.match( + errors.join('\n'), + /app\.asar must not contain embedded MPV native payloads.*iptvnator_mpv_helper.*libmpv\.so\.2/s + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('excludes only Snap template library roots from Electron isolation checks', () => { + const fixture = createSystemPayload({ architecture: 'arm64' }); + const packageLibraryDirs = [ + path.join(fixture.appDir, 'lib', 'x86_64-linux-gnu'), + path.join(fixture.appDir, 'usr', 'lib', 'x86_64-linux-gnu'), + ]; + const electronLibrary = path.join(fixture.appDir, 'libelectron-extra.so'); + for (const [index, packageLibraryDir] of packageLibraryDirs.entries()) { + const packageLibraryTarget = path.join( + packageLibraryDir, + `libpackage-${index}.so.0.12.2` + ); + fs.mkdirSync(packageLibraryDir, { recursive: true }); + fs.writeFileSync(packageLibraryTarget, 'package-managed library'); + fs.symlinkSync( + path.basename(packageLibraryTarget), + path.join(packageLibraryDir, `libpackage-${index}.so.0`) + ); + } + fs.writeFileSync(electronLibrary, 'electron library'); + + const inspectedPaths = []; + const isPackageLibraryPath = (binaryPath) => + packageLibraryDirs.some((packageLibraryDir) => + path + .resolve(binaryPath) + .startsWith(`${path.resolve(packageLibraryDir)}${path.sep}`) + ); + const ownershipScopedElfInspector = (binaryPath) => { + inspectedPaths.push(binaryPath); + if (isPackageLibraryPath(binaryPath)) { + throw new Error( + 'package-manager library tree crossed the Electron ownership boundary' + ); + } + return validElfInspector(binaryPath); + }; + + try { + assert.deepEqual( + verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector: ownershipScopedElfInspector, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }), + [] + ); + assert.ok(inspectedPaths.includes(electronLibrary)); + assert.equal(inspectedPaths.some(isPackageLibraryPath), false); + + const isolationErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector(binaryPath) { + if (binaryPath === electronLibrary) { + return { + soname: null, + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return ownershipScopedElfInspector(binaryPath); + }, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }); + assert.match( + isolationErrors.join('\n'), + /Electron library must not link libmpv.*libelectron-extra\.so/ + ); + + const nestedElectronLibrary = path.join( + fixture.appDir, + 'future-electron-runtime', + 'libfuture-electron.so' + ); + fs.mkdirSync(path.dirname(nestedElectronLibrary), { + recursive: true, + }); + fs.writeFileSync(nestedElectronLibrary, 'nested electron library'); + const nestedIsolationErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector(binaryPath) { + if (binaryPath === nestedElectronLibrary) { + return { + soname: null, + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return ownershipScopedElfInspector(binaryPath); + }, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }); + assert.match( + nestedIsolationErrors.join('\n'), + /Electron library must not link libmpv.*libfuture-electron\.so/ + ); + + fs.symlinkSync( + path.basename(electronLibrary), + path.join(fixture.appDir, 'libEGL.so') + ); + const symlinkErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector: ownershipScopedElfInspector, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }); + assert.match( + symlinkErrors.join('\n'), + /Electron library must be a regular file: .*libEGL\.so/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('rejects helper probes terminated by a signal or hard timeout', () => { + for (const probeResult of [ + { + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + }, + { + error: Object.assign(new Error('spawnSync helper ETIMEDOUT'), { + code: 'ETIMEDOUT', + }), + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + }, + ]) { + const fixture = createSystemPayload(); + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: validElfInspector, + probeRunner() { + return probeResult; + }, + }); + assert.match( + errors.join('\n'), + /(?:runtime probe terminated by signal SIGKILL|Unable to execute .*ETIMEDOUT)/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('requires exact Snap graphics layouts and plugs used by the app', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-snap-metadata-') + ); + const snapYamlPath = path.join(root, 'meta', 'snap.yaml'); + fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + fs.mkdirSync(path.join(root, 'graphics')); + + const validSnapYaml = [ + 'name: iptvnator', + 'base: core22', + 'confinement: strict', + 'summary: "*literal &anchor !tag <<: is quoted"', + '# *commented-alias &commented-anchor !commented-tag', + 'apps:', + ' iptvnator:', + ' command: iptvnator', + ' environment:', + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform', + ' plugs:', + ' - desktop', + ' - shared-memory', + ' - graphics-core22', + 'plugs:', + ' shared-memory:', + ' interface: shared-memory', + ' private: true', + ' graphics-core22:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + 'layout:', + ' /usr/share/libdrm:', + ' bind: $SNAP/graphics/libdrm', + ' /usr/share/drirc.d:', + ' symlink: $SNAP/graphics/drirc.d', + '', + ].join('\n'); + + try { + fs.writeFileSync(snapYamlPath, validSnapYaml); + assert.deepEqual(validateExtractedSnapMetadata(root), []); + + fs.truncateSync(snapYamlPath, 256 * 1024 + 1); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /Snap metadata exceeds.*size limit/i + ); + fs.writeFileSync(snapYamlPath, validSnapYaml); + + fs.rmSync(path.join(root, 'graphics'), { recursive: true }); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /empty graphics content mount directory/i + ); + fs.mkdirSync(path.join(root, 'graphics')); + fs.writeFileSync(path.join(root, 'graphics', 'unexpected'), 'data'); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /graphics content mount directory must be empty/i + ); + fs.rmSync(path.join(root, 'graphics'), { recursive: true }); + const outsideGraphics = path.join(root, 'outside-graphics'); + fs.mkdirSync(outsideGraphics); + fs.symlinkSync(outsideGraphics, path.join(root, 'graphics'), 'dir'); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /graphics content mount.*real directory/i + ); + fs.rmSync(path.join(root, 'graphics')); + fs.mkdirSync(path.join(root, 'graphics')); + fs.chmodSync(path.join(root, 'graphics'), 0o700); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /graphics content mount directory must have mode 0755/i + ); + fs.chmodSync(path.join(root, 'graphics'), 0o755); + + for (const [mutate, expected] of [ + [ + (contents) => contents.replace('base: core22', 'base: core20'), + /base: core22/i, + ], + [ + (contents) => + contents.replace( + 'confinement: strict', + 'confinement: classic' + ), + /confinement: strict/i, + ], + [ + (contents) => + contents.replace( + 'layout:\n /usr/share/libdrm:\n bind: $SNAP/graphics/libdrm\n /usr/share/drirc.d:\n symlink: $SNAP/graphics/drirc.d\n', + '' + ), + /exact Snap graphics layout contract/i, + ], + [ + (contents) => + contents.replace( + ' /usr/share/drirc.d:\n', + ' /usr/share/extra:\n bind: $SNAP/graphics/extra\n /usr/share/drirc.d:\n' + ), + /exactly the libdrm and drirc.d entries/i, + ], + [ + (contents) => + contents.replace( + ' /usr/share/drirc.d:\n', + ' /usr/share/libdrm:\n bind: $SNAP/graphics/libdrm\n /usr/share/drirc.d:\n' + ), + /layout paths must be unique/i, + ], + [ + (contents) => + contents.replace( + ' bind: $SNAP/graphics/libdrm', + ' bind: $SNAP/graphics/wrong-libdrm' + ), + /libdrm.*bind: \$SNAP\/graphics\/libdrm/i, + ], + [ + (contents) => + contents.replace( + ' symlink: $SNAP/graphics/drirc.d', + ' symlink: $SNAP/graphics/wrong-drirc.d' + ), + /drirc.d.*symlink: \$SNAP\/graphics\/drirc.d/i, + ], + [ + (contents) => + contents.replace( + ' bind: $SNAP/graphics/libdrm\n', + ' bind: $SNAP/graphics/libdrm\n type: directory\n' + ), + /libdrm layout.*exactly bind/i, + ], + [ + (contents) => + contents.replace( + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform\n', + '' + ), + /SNAP_DESKTOP_RUNTIME.*\$SNAP\/gnome-platform/i, + ], + [ + (contents) => + contents.replace( + '$SNAP/gnome-platform', + '$SNAP/hostile-platform' + ), + /SNAP_DESKTOP_RUNTIME.*\$SNAP\/gnome-platform/i, + ], + [ + (contents) => + contents.replace(' private: true', ' private: false'), + /private: true/, + ], + [ + (contents) => contents.replace(' - shared-memory\n', ''), + /app.*shared-memory plug/i, + ], + [ + (contents) => contents.replace(' - graphics-core22\n', ''), + /app.*graphics-core22 plug/i, + ], + [ + (contents) => + contents.replace( + ' shared-memory:\n interface: shared-memory\n private: true\n', + '' + ), + /top-level shared-memory plug/i, + ], + [ + (contents) => + contents.replace( + ' graphics-core22:\n interface: content\n target: $SNAP/graphics\n default-provider: mesa-core22\n', + '' + ), + /top-level graphics-core22 plug/i, + ], + [ + (contents) => + contents.replace( + ' interface: content', + ' interface: opengl' + ), + /graphics-core22.*interface: content/i, + ], + [ + (contents) => + contents.replace( + ' target: $SNAP/graphics', + ' target: $SNAP/wrong-graphics' + ), + /graphics-core22.*target: \$SNAP\/graphics/i, + ], + [ + (contents) => + contents.replace( + ' default-provider: mesa-core22', + ' default-provider: wrong-provider' + ), + /graphics-core22.*default-provider: mesa-core22/i, + ], + [ + (contents) => + contents.replace( + ' default-provider: mesa-core22\n', + ' default-provider: mesa-core22\n source: graphics-core22\n' + ), + /graphics-core22 plug.*exactly interface, target, and default-provider/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + [ + '', + 'plugs:', + ' duplicate-graphics:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + '', + ].join('\n') + ), + /exactly one plug.*graphics-core22 contract/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + [ + '', + 'plugs:', + ' competing-graphics:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: hostile-provider', + ' content: alternate-graphics', + '', + ].join('\n') + ), + /exactly one plug.*target \$SNAP\/graphics/i, + ], + [ + (contents) => + contents.replace( + ' private: true\n', + ' private: true\n shared-memory: named-area\n' + ), + /private shared-memory plug.*exactly interface and private/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + [ + '', + 'plugs:', + ' second-shared-memory:', + ' interface: shared-memory', + ' private: true', + '', + ].join('\n') + ), + /exactly one plug.*shared-memory interface/i, + ], + [ + (contents) => + `${contents}slots:\n shared-memory:\n interface: shared-memory\n`, + /must not declare.*shared-memory slot/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + '\nplugs:\n other: { interface: shared-memory }\n' + ), + /plug declarations.*block mappings/i, + ], + [ + (contents) => + `${contents}slots: { leak: { interface: shared-memory } }\n`, + /slots.*block mappings/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + '\nplugs:\n network:\n interface: network\n network:\n interface: network\n' + ), + /plug keys.*unique/i, + ], + [ + (contents) => + [ + 'shared-interface: &shared.interface shared-memory', + contents.replace( + '\nplugs:\n', + '\nplugs:\n alias-plug:\n interface: *shared.interface\n' + ), + ].join('\n'), + /anchors, aliases, merge keys, or custom tags/i, + ], + [ + (contents) => + [ + 'shared-interface: &sharedInterface shared-memory', + `${contents}slots:\n alias-slot:\n interface: *sharedInterface\n`, + ].join('\n'), + /anchors, aliases, merge keys, or custom tags/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + '\nplugs:\n tagged:\n interface: !shared-memory shared-memory\n' + ), + /anchors, aliases, merge keys, or custom tags/i, + ], + [ + (contents) => + [ + 'shared-plug: &sharedPlug', + ' interface: network', + contents.replace( + '\nplugs:\n', + '\nplugs:\n merged:\n <<: *sharedPlug\n' + ), + ].join('\n'), + /anchors, aliases, merge keys, or custom tags/i, + ], + [ + (contents) => + contents.replace( + [ + ' plugs:', + ' - desktop', + ' - shared-memory', + ].join('\n'), + [ + ' plugs:', + ' nested:', + ' - shared-memory', + ].join('\n') + ), + /scalar sequence.*shared-memory/i, + ], + ]) { + fs.writeFileSync(snapYamlPath, mutate(validSnapYaml)); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + expected + ); + } + + fs.rmSync(snapYamlPath); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /Missing extracted Snap metadata/ + ); + + fs.writeFileSync(path.join(root, 'outside.yaml'), validSnapYaml); + fs.symlinkSync( + path.join(root, 'outside.yaml'), + snapYamlPath, + process.platform === 'win32' ? 'file' : undefined + ); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /regular file/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +const SNAP_METADATA_WITH_LITERAL_HASHES = [ + 'name: iptvnator', + 'base: core22', + 'confinement: strict', + 'summary: "quoted # is scalar data"', + 'description: | # block scalar header comment', + ' Block scalar # stays literal.', + 'apps:', + ' iptvnator:', + ' command: iptvnator', + ' environment:', + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform', + ' plugs:', + ' - shared-memory', + ' - graphics-core22', + 'plugs:', + ' shared-memory:', + ' interface: shared-memory', + ' private: true', + ' graphics-core22:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + 'layout:', + ' /usr/share/libdrm:', + ' bind: $SNAP/graphics/libdrm', + ' /usr/share/drirc.d:', + ' symlink: $SNAP/graphics/drirc.d', + '', +].join('\n'); + +for (const [kind, mutate, expected] of [ + [ + 'plug', + (contents) => + contents.replace( + '\nplugs:\n', + [ + '', + 'plugs:', + ' hidden-extra-plug:', + ' interface: shared-memory # trailing comment', + '', + ].join('\n') + ), + /exactly one plug.*shared-memory interface/i, + ], + [ + 'slot', + (contents) => + `${contents}slots:\n hidden-slot:\n interface: shared-memory # trailing comment\n`, + /must not declare.*shared-memory slot/i, + ], +]) { + test(`rejects an extra Snap shared-memory ${kind} with a trailing comment`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-snap-comments-') + ); + const snapYamlPath = path.join(root, 'meta', 'snap.yaml'); + fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + fs.mkdirSync(path.join(root, 'graphics')); + + try { + fs.writeFileSync(snapYamlPath, SNAP_METADATA_WITH_LITERAL_HASHES); + assert.deepEqual(validateExtractedSnapMetadata(root), []); + + fs.writeFileSync( + snapYamlPath, + mutate(SNAP_METADATA_WITH_LITERAL_HASHES) + ); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + expected + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +test('artifact verification enforces Snap metadata for x64 and ARM payloads', () => { + for (const architecture of ['x64', 'arm64', 'armv7l']) { + const fixture = createSystemPayload({ architecture }); + const artifactPath = path.join(fixture.root, 'package.snap'); + const snapYamlPath = path.join(fixture.root, 'meta', 'snap.yaml'); + fs.writeFileSync(artifactPath, 'fixture'); + fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + fs.mkdirSync(path.join(fixture.root, 'graphics')); + fs.writeFileSync( + snapYamlPath, + [ + 'name: iptvnator', + 'base: core22', + 'confinement: strict', + 'apps:', + ' iptvnator:', + ' command: iptvnator', + ' environment:', + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform', + ' plugs:', + ' - desktop', + ' - graphics-core22', + 'plugs:', + ' shared-memory:', + ' interface: shared-memory', + ' private: true', + ' graphics-core22:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + 'layout:', + ' /usr/share/libdrm:', + ' bind: $SNAP/graphics/libdrm', + ' /usr/share/drirc.d:', + ' symlink: $SNAP/graphics/drirc.d', + '', + ].join('\n') + ); + let payloadVerifierCalls = 0; + + const verify = () => + verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName: 'portable', + extractArtifact() { + return fixture.root; + }, + metadataReader() { + return { declaredArch: architecture, dependencies: [] }; + }, + payloadVerifier() { + payloadVerifierCalls += 1; + return []; + }, + }); + + try { + assert.throws(verify, /app.*shared-memory plug/i); + assert.equal(payloadVerifierCalls, 0); + + fs.writeFileSync( + snapYamlPath, + fs + .readFileSync(snapYamlPath, 'utf8') + .replace(' - desktop\n', ' - shared-memory\n') + ); + assert.deepEqual(verify(), { + artifactPath, + format: 'snap', + profileName: 'portable', + architecture, + }); + assert.equal(payloadVerifierCalls, 1); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('bundled probes remove ambient loader paths and use only packaged libraries', () => { + assert.deepEqual( + createRuntimeProbeEnvironment({ + environment: { + PATH: '/usr/bin', + BASH_ENV: '/host/hostile-bash-env', + ENV: '/host/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/host/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/host/hostile-cdpath', + 'BASH_FUNC_dirname%%': + '() { printf /host/hostile-provider-root; }', + LD_AUDIT: '/host/can-inject-audit.so', + LD_LIBRARY_PATH: '/host/can-mask-missing-dependencies', + LD_ORIGIN_PATH: '/host/can-change-origin', + LD_PRELOAD: '/host/can-inject.so', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/host/egl/external-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/host/egl/external-platform.json', + __EGL_VENDOR_LIBRARY_DIRS: '/host/egl/vendor', + __EGL_VENDOR_LIBRARY_FILENAMES: '/host/egl/vendor/host.json', + GBM_BACKEND: 'host-gbm', + GBM_BACKENDS_PATH: '/host/gbm', + LIBGL_DRIVERS_PATH: '/host/dri', + MESA_LOADER_DRIVER_OVERRIDE: 'host-dri', + LIBVA_DRIVER_NAME: 'host-va', + LIBVA_DRIVERS_PATH: '/host/va', + VDPAU_DRIVER_PATH: '/host/vdpau', + VK_DRIVER_FILES: '/host/vulkan/driver.json', + VK_ICD_FILENAMES: '/host/vulkan/icd.json', + VK_ADD_DRIVER_FILES: '/host/vulkan/add-driver.json', + VK_ADD_LAYER_PATH: '/host/vulkan/add-layer', + VK_IMPLICIT_LAYER_PATH: '/host/vulkan/implicit-layer', + VK_ADD_IMPLICIT_LAYER_PATH: '/host/vulkan/add-implicit-layer', + VK_LAYER_PATH: '/host/vulkan/layer', + LIBGL_ALWAYS_SOFTWARE: '1', + }, + nativeDir: '/package/resources/native', + runtimeMode: 'bundled', + }), + { + PATH: '/usr/bin', + LIBGL_ALWAYS_SOFTWARE: '1', + LD_LIBRARY_PATH: '/package/resources/native/lib', + } + ); +}); + +test('rejects helper probe framing and fields that runtime capability rejects', () => { + const validPayload = + '{"protocol":1,"usable":true,"libmpv":"0.41.0","renderApi":"egl"}'; + for (const stdout of [ + validPayload, + `${validPayload}\n\n`, + `${validPayload.slice(0, -1)},"extra":true}\n`, + ]) { + const fixture = createSystemPayload(); + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: validElfInspector, + probeRunner() { + return { + status: 0, + signal: null, + stdout, + stderr: '', + }; + }, + }); + assert.match( + errors.join('\n'), + /runtime probe (?:must emit|did not return)/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('reports missing helper, wrong mode, wrong profile, isolation, and loader failures', () => { + const cases = [ + { + mutate({ nativeDir }) { + fs.rmSync(path.join(nativeDir, 'iptvnator_mpv_helper')); + }, + expected: /Missing embedded MPV frame-copy helper/, + }, + { + mutate({ nativeDir }) { + fs.chmodSync( + path.join(nativeDir, 'iptvnator_mpv_helper'), + 0o644 + ); + }, + expected: /must have mode 0755/, + }, + { + profileName: 'portable', + expected: /does not include target "deb"/, + }, + { + elfInspector(binaryPath) { + if (path.basename(binaryPath) === 'embedded_mpv.node') { + return { + soname: null, + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return validElfInspector(binaryPath); + }, + expected: /addon must not link libmpv/, + }, + { + probeRunner() { + return { + status: 127, + signal: null, + stdout: '', + stderr: 'error while loading shared libraries: libmpv.so.2', + }; + }, + expected: /runtime probe failed with status 127.*libmpv\.so\.2/s, + }, + ]; + + for (const testCase of cases) { + const fixture = createSystemPayload(); + try { + testCase.mutate?.(fixture); + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: testCase.profileName ?? 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: testCase.elfInspector ?? validElfInspector, + probeRunner: testCase.probeRunner ?? successfulProbeRunner, + }); + assert.match(errors.join('\n'), testCase.expected); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('rejects an x64 package manifest produced from a target subset', () => { + const fixture = createSystemPayload(); + const manifestPath = path.join( + fixture.nativeDir, + 'embedded-mpv-runtime.json' + ); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + manifest.targets = ['deb']; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: validElfInspector, + probeRunner: successfulProbeRunner, + }); + assert.match( + errors.join('\n'), + /manifest targets.*must equal \["deb","pacman","rpm"\]/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('requires marker-only foreign packages, scans Electron, and never probes', () => { + const fixture = createSystemPayload({ architecture: 'arm64' }); + let probeCalls = 0; + try { + assert.deepEqual( + verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'appimage', + profileName: 'portable', + packageDependencies: [], + elfInspector: validElfInspector, + probeRunner() { + probeCalls += 1; + return successfulProbeRunner(); + }, + }), + [] + ); + assert.equal(probeCalls, 0); + + const isolationErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'appimage', + profileName: 'portable', + packageDependencies: [], + elfInspector(binaryPath) { + if (path.basename(binaryPath) === 'iptvnator.bin') { + return { + soname: null, + needed: ['/tmp/libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return validElfInspector(binaryPath); + }, + probeRunner() { + probeCalls += 1; + return successfulProbeRunner(); + }, + }); + assert.match( + isolationErrors.join('\n'), + /Electron binary must not link libmpv/ + ); + assert.equal(probeCalls, 0); + + for (const forbiddenDependency of DEB_SYSTEM_PACKAGE_DEPENDENCIES) { + const dependencyErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: [forbiddenDependency, 'libc6'], + declaredArch: 'arm64', + elfInspector: validElfInspector, + probeRunner: successfulProbeRunner, + }); + assert.match( + dependencyErrors.join('\n'), + new RegExp( + `must not declare frame-copy dependency ${forbiddenDependency}` + ) + ); + } + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('always removes its temporary extraction root after a verifier failure', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-cleanup-parent-') + ); + const artifactPath = path.join(root, 'package.deb'); + fs.writeFileSync(artifactPath, 'fixture'); + let extractionDestination; + + try { + assert.throws( + () => + verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName: 'system', + extractArtifact({ destination }) { + extractionDestination = destination; + fs.writeFileSync( + path.join(path.dirname(destination), 'sentinel'), + 'temporary' + ); + throw new Error('intentional extraction failure'); + }, + }), + /intentional extraction failure/ + ); + assert.equal( + fs.existsSync(path.dirname(extractionDestination)), + false, + 'temporary verifier root must be removed' + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/vendor/embedded-mpv/README.md b/vendor/embedded-mpv/README.md index 568b2cdb4..6943b7936 100644 --- a/vendor/embedded-mpv/README.md +++ b/vendor/embedded-mpv/README.md @@ -11,10 +11,37 @@ Generated architecture folders are expected at: - `vendor/embedded-mpv/linux-x64/` Each generated folder must contain `include/mpv/client.h` and -`runtime-manifest.json`. macOS and Windows folders also contain platform -runtime/build inputs under `lib/` or `bin/`. The binary runtime directories are -ignored by git by default; generate, stage, or restore them in release packaging -jobs before building the Electron backend. +`runtime-manifest.json`. Platform runtime/build inputs live under `lib/` or +`bin/`. In particular, `linux-x64/lib/` contains the pinned, dynamically linked +LGPL-compatible libmpv closure used to link the out-of-process frame-copy +helper. `linux-x64/notices/` contains the generated +`embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and exact +`licenses//**` tree. The generated runtime directories are ignored by +git; generate, stage, or restore them before building the Electron backend. -Linux uses this directory for MPV headers and build metadata only. Linux -packages launch the system `mpv` executable and must not bundle `libmpv.so`. +Linux package profiles consume that one staged x64 source runtime differently: + +- DEB/RPM/Pacman remove the private closure and declare the system libmpv + dependency. +- AppImage/Snap/Flatpak retain the manifest-declared closure under + `app.asar.unpacked/electron-backend/native/lib/` and flatten the validated + notice manifest, aggregate notice, and `licenses/**` tree beside it. +- The strict Snap resolves the helper's remaining graphics interfaces through + the external `mesa-core22` content provider at `$SNAP/graphics`; that shared + provider is not staged below `vendor/embedded-mpv/`, bundled into IPTVnator, + or included in IPTVnator's source/notices archive. +- DEB/RPM/Pacman and marker-only packages do not retain bundled-runtime + notices or license files. +- Non-x64 Linux packages retain no native artifacts and ship only the + unavailable marker. +- Electron Builder excludes the staged native tree from `app.asar`; only + `afterPack` writes the profile-specific unpacked payload, and package + verification rejects archived native entries. + +The DEB dependency is specifically `libmpv2` (verified on Ubuntu 24.04+). +Ubuntu 22.04 provides `libmpv1`; use the x64 AppImage there. + +Only `iptvnator_mpv_helper` may link libmpv. Electron, +`embedded_mpv.node`, and `embedded_mpv_frame_reader.node` must remain free of +direct libmpv dependencies. See `tools/embedded-mpv/README.md` and +`docs/architecture/embedded-mpv-native.md`. From c266eaa680179de052ad647bd7124a200e361cae Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:42:45 +0200 Subject: [PATCH 02/26] fix(packaging): preserve Flatpak Electron ELF for Zypak (#1205) Fixes the launcher/Zypak regression reported in #1203. The additional GPU/video.js behavior remains tracked separately in that issue. --- .github/workflows/build-and-make.yaml | 38 +- AGENTS.md | 4 + CLAUDE.md | 8 +- docs/architecture/embedded-mpv-native.md | 12 +- .../2026-07-18-flatpak-zypak-launcher.md | 738 ++++++++++++++++++ ...mbedded-mpv-frame-copy-packaging-design.md | 10 +- ...026-07-18-flatpak-zypak-launcher-design.md | 69 ++ tools/embedded-mpv/README.md | 9 +- .../configure-linux-frame-copy-build.test.mjs | 46 ++ tools/packaging/electron-after-pack.cjs | 4 +- .../electron-package-identity.test.mjs | 92 +++ tools/packaging/embedded-mpv-arch.test.mjs | 10 + tools/packaging/embedded-mpv-packaging.cjs | 33 +- .../packaging/flatpak-launcher-validation.cjs | 125 +++ .../flatpak-launcher-validation.test.mjs | 108 +++ tools/packaging/linux-after-pack.cjs | 23 +- tools/packaging/linux-after-pack.test.mjs | 187 +++++ tools/packaging/linux-launcher-layout.cjs | 56 ++ tools/packaging/project.json | 5 +- .../verify-electron-package-layout.mjs | 34 +- .../verify-linux-frame-copy-runtime.mjs | 15 +- .../verify-linux-frame-copy-runtime.test.mjs | 91 ++- 22 files changed, 1679 insertions(+), 38 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-18-flatpak-zypak-launcher.md create mode 100644 docs/superpowers/specs/2026-07-18-flatpak-zypak-launcher-design.md create mode 100644 tools/packaging/flatpak-launcher-validation.cjs create mode 100644 tools/packaging/flatpak-launcher-validation.test.mjs create mode 100644 tools/packaging/linux-after-pack.test.mjs create mode 100644 tools/packaging/linux-launcher-layout.cjs diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 96395ba6e..8df8b6e81 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -1104,14 +1104,38 @@ jobs: LAUNCHER_PATH="$(readlink -f /app/bin/iptvnator)" test -f "${LAUNCHER_PATH}" - test -f "${LAUNCHER_PATH}.bin" - grep -q '\''readlink -f "$SCRIPT_PATH"'\'' "${LAUNCHER_PATH}" - grep -q '\''exec "$SCRIPT_DIR/iptvnator.bin"'\'' "${LAUNCHER_PATH}" + test -x "${LAUNCHER_PATH}" + if [ -e "${LAUNCHER_PATH}.bin" ] || [ -L "${LAUNCHER_PATH}.bin" ]; then + echo "::error::Flatpak must not contain ${LAUNCHER_PATH}.bin" + exit 1 + fi + ELF_MAGIC="$(od -An -tx1 -N4 "${LAUNCHER_PATH}" | tr -d "[:space:]")" + test "${ELF_MAGIC}" = "7f454c46" ' - xvfb-run -a dbus-run-session -- flatpak run \ - --env=LIBGL_ALWAYS_SOFTWARE=1 \ - com.fourgray.iptvnator \ - --embedded-mpv-runtime-probe + set +e + PROBE_OUTPUT="$( + xvfb-run -a dbus-run-session -- flatpak run \ + --env=LIBGL_ALWAYS_SOFTWARE=1 \ + com.fourgray.iptvnator \ + --embedded-mpv-runtime-probe 2>&1 + )" + PROBE_STATUS=$? + set -e + + PROBE_OUTPUT_LIMIT=16384 + printf '%s\n' "${PROBE_OUTPUT:0:PROBE_OUTPUT_LIMIT}" + if [ "${#PROBE_OUTPUT}" -gt "${PROBE_OUTPUT_LIMIT}" ]; then + echo "::warning::Flatpak runtime probe output was truncated to ${PROBE_OUTPUT_LIMIT} characters." + fi + if [[ "${PROBE_OUTPUT}" == *"not an ELF file"* ]] || + [[ "${PROBE_OUTPUT}" == *"Zypak needs to be called directly"* ]]; then + echo "::error::Flatpak launched a wrapper instead of the Electron ELF." + exit 1 + fi + if [ "${PROBE_STATUS}" -ne 0 ]; then + echo "::error::Flatpak application runtime probe failed with status ${PROBE_STATUS}." + exit "${PROBE_STATUS}" + fi - name: Upload artifacts (macOS) if: matrix.os == 'macos' diff --git a/AGENTS.md b/AGENTS.md index a81b7cba1..7371074f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,6 +229,10 @@ Key files: Pacman=`mpv,libglvnd,mesa` - `portable`: AppImage/Snap with the pinned LGPL-compatible closure - `flatpak`: Flatpak with the same pinned closure +- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real + Electron ELF so Electron Builder's `electron-wrapper` passes it directly to + Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and + `iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation. - The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04 provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the package dependency or advertising frame-copy without a compatible runtime. diff --git a/CLAUDE.md b/CLAUDE.md index 7b404d099..42cd4b7fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -639,8 +639,12 @@ engine` (restart required) or Official x64 packages use three separate profiles: DEB/RPM/Pacman depend on system libmpv plus the helper's direct EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and - Flatpak bundles the same closure. Exact system dependencies are - DEB=`libmpv2,libegl1,libgl1,libgbm1`, + Flatpak bundles the same closure. Flatpak is an isolated packaging pass and + keeps `iptvnator` as the real Electron ELF so Electron Builder's + `electron-wrapper` passes it directly to Zypak. Other Linux targets retain the + conditional `iptvnator` wrapper and `iptvnator.bin`. Mixed + Flatpak/non-Flatpak target sets fail before mutation. Exact system + dependencies are DEB=`libmpv2,libegl1,libgl1,libgbm1`, RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+; Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`. diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index b0833d40b..19feed60d 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -695,11 +695,13 @@ Linux release profiles: source-built closure and manifest origin. Its app-level probe reconstructs only the exact Freedesktop 24.08 EGL external-platform search path inside the trusted `/app` payload. -- The three profiles are separate packaging passes; mixing target sets fails - closed. Linux packages for other architectures (arm64, armv7l) must not ship - x64 native artifacts. `afterPack` replaces the native directory with - `embedded-mpv-unavailable.txt`, and package verification requires that - marker. +- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real + Electron ELF so Electron Builder's `electron-wrapper` passes it directly to + Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and + `iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation. +- Linux packages for other architectures (arm64, armv7l) must not ship x64 + native artifacts. `afterPack` replaces the native directory with + `embedded-mpv-unavailable.txt`, and package verification requires that marker. - Every packaged manifest names its exact artifacts, profile/targets, libmpv SONAME, loader closure, byte sizes, SHA-256 hashes, package dependencies, and native-view fallback. Artifact modes and ELF dependency isolation are diff --git a/docs/superpowers/plans/2026-07-18-flatpak-zypak-launcher.md b/docs/superpowers/plans/2026-07-18-flatpak-zypak-launcher.md new file mode 100644 index 000000000..bfb47f0af --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-flatpak-zypak-launcher.md @@ -0,0 +1,738 @@ +# Flatpak Zypak Launcher Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the Flatpak `iptvnator` entry as the real Electron ELF so +Electron Builder passes it directly to Zypak, while preserving the existing +Linux sandbox wrapper for every other package target. + +**Architecture:** A small CommonJS launcher-layout contract resolves the +Electron binary name from normalized target names and rejects mixed Flatpak +passes. The afterPack hook, unpacked-layout validators, final-artifact +validator, and CI all consume the same target-dependent contract. + +**Tech Stack:** Node.js CommonJS/ESM, `node:test`, Nx packaging targets, +Electron Builder 26, Flatpak/Zypak, GitHub Actions. + +--- + +### Task 1: Add the shared launcher contract and fix afterPack + +**Files:** + +- Create: `tools/packaging/linux-launcher-layout.cjs` +- Create: `tools/packaging/linux-after-pack.test.mjs` +- Modify: `tools/packaging/linux-after-pack.cjs` +- Modify: `tools/packaging/electron-after-pack.cjs` +- Modify: `tools/packaging/project.json` + +- [ ] **Step 1: Write the failing isolated-Flatpak hook test** + +Create a temporary executable with ELF magic, call the real hook, and assert +that Flatpak retains the exact original file: + +```js +test('preserves the Electron ELF for an isolated Flatpak target', async (t) => { + const fixture = createLauncherFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + + await linuxAfterPack(createAfterPackParams(fixture.appOutDir, ['flatpak'])); + + assert.deepEqual( + fs.readFileSync(fixture.executablePath), + fixture.executableBytes + ); + assert.equal(fs.existsSync(`${fixture.executablePath}.bin`), false); +}); +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +node --test tools/packaging/linux-after-pack.test.mjs +``` + +Expected: FAIL because the current hook replaces `iptvnator` with a Bash +script and creates `iptvnator.bin`. + +- [ ] **Step 3: Add the pure launcher-layout resolver** + +Implement a strict resolver with this public contract: + +```js +function resolveLinuxLauncherLayout(targets, executableName = 'iptvnator') { + if (!Array.isArray(targets) || targets.length === 0) { + throw new Error( + 'Linux launcher layout requires at least one Electron Builder target.' + ); + } + + const targetNames = targets.map((target) => { + const value = typeof target === 'string' ? target : target?.name; + const name = String(value ?? '') + .trim() + .toLowerCase(); + if (!name) { + throw new Error( + 'Linux launcher targets must expose a non-empty name.' + ); + } + return name; + }); + + if (new Set(targetNames).size !== targetNames.length) { + throw new Error('Linux launcher targets must be unique.'); + } + + const flatpak = targetNames.includes('flatpak'); + if (flatpak && targetNames.length !== 1) { + throw new Error( + 'Flatpak must be packaged in an isolated Electron Builder pass so Zypak receives the Electron ELF directly.' + ); + } + + return { + targetNames, + electronBinaryName: flatpak ? executableName : `${executableName}.bin`, + wrapperRequired: !flatpak, + }; +} +``` + +Export `resolveLinuxLauncherLayout`. + +- [ ] **Step 4: Make the Linux hook preserve isolated Flatpak** + +Resolve the layout before any filesystem mutation. For Flatpak, log that the +ELF is preserved and return. For other targets, rename to the resolved +`electronBinaryName` and create the unchanged sandbox wrapper: + +```js +async function afterPackHook(params, { targetNames = params.targets } = {}) { + if (params.electronPlatformName !== 'linux') { + return; + } + + const layout = resolveLinuxLauncherLayout( + targetNames, + params.packager.executableName + ); + if (!layout.wrapperRequired) { + log('preserving Flatpak Electron ELF for direct Zypak launch'); + return; + } + + const executable = path.join( + params.appOutDir, + params.packager.executableName + ); + const electronBinary = path.join( + params.appOutDir, + layout.electronBinaryName + ); + + try { + await fs.rename(executable, electronBinary); + await fs.writeFile( + executable, + createLoaderScript({ + executableName: params.packager.executableName, + productName: params.packager.appInfo.productName, + }) + ); + await fs.chmod(executable, 0o755); + } catch (error) { + log(`failed to create launcher wrapper: ${error.message}`); + throw new Error('Failed to create launcher wrapper'); + } + + log('Linux launcher sandbox fix applied'); +} +``` + +Pass `linuxPackagingContext?.targetNames` from `electron-after-pack.cjs` so +the hook consumes the already validated afterPack target names: + +```js +await linuxAfterPack(params, { + targetNames: linuxPackagingContext?.targetNames, +}); +``` + +- [ ] **Step 5: Add non-Flatpak and mixed-target regression cases** + +Use the real hook to prove: + +```js +test('keeps the sandbox wrapper for non-Flatpak targets', async (t) => { + for (const targetName of ['appimage', 'deb', 'rpm', 'pacman', 'snap']) { + const fixture = createLauncherFixture(); + t.after(() => + fs.rmSync(fixture.root, { recursive: true, force: true }) + ); + await linuxAfterPack( + createAfterPackParams(fixture.appOutDir, [targetName]) + ); + assert.deepEqual( + fs.readFileSync(`${fixture.executablePath}.bin`), + fixture.executableBytes + ); + assert.match( + fs.readFileSync(fixture.executablePath, 'utf8'), + /exec "\$SCRIPT_DIR\/iptvnator\.bin"/ + ); + } +}); + +test('rejects a mixed Flatpak pass before mutating the executable', async (t) => { + const fixture = createLauncherFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + await assert.rejects( + linuxAfterPack( + createAfterPackParams(fixture.appOutDir, ['flatpak', 'appimage']) + ), + /Flatpak must be packaged in an isolated Electron Builder pass/ + ); + assert.deepEqual( + fs.readFileSync(fixture.executablePath), + fixture.executableBytes + ); + assert.equal(fs.existsSync(`${fixture.executablePath}.bin`), false); +}); +``` + +- [ ] **Step 6: Register and run the focused test** + +Add `linux-after-pack.test.mjs` to the explicit `packaging:test` command and +inputs in `tools/packaging/project.json`. + +Run: + +```bash +node --test tools/packaging/linux-after-pack.test.mjs +``` + +Expected: all launcher tests PASS. + +- [ ] **Step 7: Commit Task 1** + +```bash +git add tools/packaging/linux-launcher-layout.cjs \ + tools/packaging/linux-after-pack.cjs \ + tools/packaging/linux-after-pack.test.mjs \ + tools/packaging/electron-after-pack.cjs \ + tools/packaging/project.json +git commit -m "fix(packaging): preserve Flatpak Electron ELF" +``` + +### Task 2: Make unpacked-layout validation target-aware + +**Files:** + +- Modify: `tools/packaging/embedded-mpv-packaging.cjs` +- Modify: `tools/packaging/embedded-mpv-arch.test.mjs` +- Modify: `tools/packaging/verify-electron-package-layout.mjs` +- Modify: `tools/packaging/electron-package-identity.test.mjs` + +- [ ] **Step 1: Change the Flatpak fixture and verify RED** + +In `prepares portable and Flatpak manifests with the exact bundled closure`, +rename only the Flatpak fixture's Electron binary: + +```js +fs.renameSync( + join(flatpak.appOutDir, 'iptvnator.bin'), + join(flatpak.appOutDir, 'iptvnator') +); +``` + +Teach the test ELF inspector about both legitimate basenames: + +```js +['iptvnator', { needed: ['libc.so.6'], rpath: [], runpath: [] }], +['iptvnator.bin', { needed: ['libc.so.6'], rpath: [], runpath: [] }], +``` + +Run: + +```bash +node --test --test-name-pattern='prepares portable and Flatpak manifests' \ + tools/packaging/embedded-mpv-arch.test.mjs +``` + +Expected: FAIL with a missing `iptvnator.bin` validation error. + +- [ ] **Step 2: Resolve the pristine Electron ELF through the shared contract** + +Require `resolveLinuxLauncherLayout` in +`embedded-mpv-packaging.cjs`. In `inspectLinuxElfIsolation`, resolve from +`options.targetNames` and use its `electronBinaryName`: + +```js +let launcherLayout; +try { + launcherLayout = resolveLinuxLauncherLayout( + options.targetNames, + options.executableName ?? 'iptvnator' + ); +} catch (error) { + errors.push( + `Unable to resolve Linux launcher layout: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return; +} + +const inspectedPaths = { + electron: path.join( + path.dirname(resourceDir), + launcherLayout.electronBinaryName + ), + addon: path.join(nativeDir, linuxFrameCopyArtifacts.addon.name), + reader: path.join(nativeDir, linuxFrameCopyArtifacts.frameReader.name), + helper: path.join(nativeDir, linuxFrameCopyArtifacts.helper.name), +}; +for (const [index, libraryPath] of listElectronShippedLinuxLibraries( + resourceDir, + { artifactFormat: options.artifactFormat } +).entries()) { + inspectedPaths[`electronLibrary:${index}`] = libraryPath; +} +``` + +Pass the normalized `targetNames` already calculated by +`validateLinuxPackagedEmbeddedMpv` into the inspection options. + +- [ ] **Step 3: Make the general package-layout verifier profile-aware** + +Require the same resolver in `verify-electron-package-layout.mjs`, change +`verifyLinuxLauncher` to accept `targetNames`, and call it with +`linuxTargetNames`. + +For Flatpak: + +```js +if (!layout.wrapperRequired) { + if (fileExists(`${launcherPath}.bin`)) { + errors.push( + `Flatpak must not contain the Linux sandbox wrapper binary: ${launcherPath}.bin` + ); + } + if (!fileHasElfMagic(launcherPath)) { + errors.push( + `Flatpak launcher target must be an ELF executable: ${launcherPath}` + ); + } + return; +} + +const launcherBinaryPath = path.join(appDir, layout.electronBinaryName); +if (!fileExists(launcherBinaryPath)) { + errors.push( + `Missing Linux launcher binary in ${appDir}: ${path.basename(launcherBinaryPath)}` + ); + return; +} +if (!fileExists(launcherPath)) { + errors.push( + `Missing Linux launcher wrapper in ${appDir}: ${path.basename(launcherPath)}` + ); + return; +} + +const launcherScript = fs.readFileSync(launcherPath, 'utf8'); +const requiredMarkers = [ + 'SCRIPT_PATH="${BASH_SOURCE[0]}"', + 'readlink -f "$SCRIPT_PATH"', + `exec "$SCRIPT_DIR/${linuxExecutableName}.bin"`, +]; +const missingMarkers = requiredMarkers.filter( + (marker) => !launcherScript.includes(marker) +); +if (missingMarkers.length > 0) { + errors.push( + [ + `Linux launcher wrapper is missing symlink-safe logic in ${launcherPath}.`, + 'Missing markers:', + ...missingMarkers.map((marker) => `- ${marker}`), + ].join('\n') + ); +} +``` + +Implement `fileHasElfMagic` with one four-byte `fs.readSync` call and always +close the descriptor: + +```js +function fileHasElfMagic(filePath) { + const descriptor = fs.openSync(filePath, 'r'); + try { + const magic = Buffer.alloc(4); + return ( + fs.readSync(descriptor, magic, 0, magic.length, 0) === + magic.length && + magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + ); + } finally { + fs.closeSync(descriptor); + } +} +``` + +- [ ] **Step 4: Extend the package-identity source contract test** + +Assert that the general verifier imports the shared resolver, calls +`verifyLinuxLauncher(resourceDir, linuxTargetNames, errors)`, checks ELF magic, +and does not unconditionally set `launcherBinaryPath` before resolving target +layout. + +- [ ] **Step 5: Run targeted validation** + +```bash +node --test --test-name-pattern='prepares portable and Flatpak manifests' \ + tools/packaging/embedded-mpv-arch.test.mjs +node --test --test-name-pattern='package layout verifier uses' \ + tools/packaging/electron-package-identity.test.mjs +``` + +Expected: both commands PASS. + +- [ ] **Step 6: Commit Task 2** + +```bash +git add tools/packaging/embedded-mpv-packaging.cjs \ + tools/packaging/embedded-mpv-arch.test.mjs \ + tools/packaging/verify-electron-package-layout.mjs \ + tools/packaging/electron-package-identity.test.mjs +git commit -m "fix(packaging): validate Flatpak launcher ELF" +``` + +### Task 3: Update final-artifact verification, CI, and documentation + +**Files:** + +- Modify: `tools/packaging/verify-linux-frame-copy-runtime.mjs` +- Modify: `tools/packaging/verify-linux-frame-copy-runtime.test.mjs` +- Modify: `.github/workflows/build-and-make.yaml` +- Modify: `tools/packaging/configure-linux-frame-copy-build.test.mjs` +- Modify: `docs/architecture/embedded-mpv-native.md` +- Modify: `tools/embedded-mpv/README.md` +- Modify: `docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md` +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Add a failing extracted-Flatpak regression** + +Allow the fixture helper to select the Electron filename: + +```js +function createSystemPayload({ + architecture = 'x64', + electronBinaryName = 'iptvnator.bin', +} = {}) { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-layout-') + ); + const appDir = path.join(root, 'opt', 'IPTVnator'); + const resourceDir = path.join(appDir, 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + fs.writeFileSync( + path.join(appDir, electronBinaryName), + elfHeader(architecture) + ); + + if (architecture === 'x64') { + fs.writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', { + mode: 0o644, + }); + fs.writeFileSync( + path.join(nativeDir, 'embedded_mpv_frame_reader.node'), + 'reader', + { mode: 0o644 } + ); + fs.writeFileSync( + path.join(nativeDir, 'iptvnator_mpv_helper'), + 'helper', + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-runtime.json'), + `${JSON.stringify(SYSTEM_MANIFEST, null, 2)}\n`, + { mode: 0o644 } + ); + } else { + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-unavailable.txt'), + `Unavailable for ${architecture}\n` + ); + } + + return { root, appDir, resourceDir, nativeDir }; +} +``` + +Use a foreign-architecture marker fixture so this test isolates launcher +selection without needing a bundled x64 manifest: + +```js +test('validates a marker-only Flatpak with an unwrapped Electron ELF', () => { + const fixture = createSystemPayload({ + architecture: 'arm64', + electronBinaryName: 'iptvnator', + }); + try { + assert.deepEqual( + verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'flatpak', + profileName: 'flatpak', + packageDependencies: [], + elfInspector: validElfInspector, + probeRunner() { + assert.fail( + 'foreign Flatpak must not run the helper probe' + ); + }, + }), + [] + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); +``` + +Run: + +```bash +node --test --test-name-pattern='marker-only Flatpak with an unwrapped' \ + tools/packaging/verify-linux-frame-copy-runtime.test.mjs +``` + +Expected: FAIL because the verifier reads `iptvnator.bin`. + +- [ ] **Step 2: Resolve every final-artifact Electron path consistently** + +Require `resolveLinuxLauncherLayout` and add: + +```js +function resolveElectronBinaryPath(resourceDir, artifactFormat) { + const layout = resolveLinuxLauncherLayout([artifactFormat]); + return path.join(path.dirname(resourceDir), layout.electronBinaryName); +} +``` + +Use it in: + +- `validateElectronIsolation`; +- `verifyExtractedLinuxFrameCopyRuntime` architecture detection; +- the architecture returned from `verifyLinuxFrameCopyArtifact`. + +Keep Snap/AppImage/DEB/RPM/Pacman expectations on `iptvnator.bin`. + +- [ ] **Step 3: Add an outer artifact-verifier Flatpak regression** + +Create a temporary `.flatpak` file, inject an extractor that writes +`iptvnator` ELF and the marker-only native directory under its supplied +destination, and assert: + +```js +assert.deepEqual( + verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName: 'flatpak', + extractArtifact({ destination }) { + const appDir = path.join( + destination, + 'files', + 'lib', + 'com.fourgray.iptvnator' + ); + const resourceDir = path.join(appDir, 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + fs.writeFileSync( + path.join(appDir, 'iptvnator'), + elfHeader('arm64') + ); + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-unavailable.txt'), + 'Unavailable for arm64\n' + ); + return destination; + }, + metadataReader: () => ({ + declaredArch: 'arm64', + dependencies: [], + }), + elfInspector: validElfInspector, + probeRunner() { + assert.fail('foreign Flatpak must not probe'); + }, + }), + { + artifactPath: path.resolve(artifactPath), + format: 'flatpak', + profileName: 'flatpak', + architecture: 'arm64', + } +); +``` + +- [ ] **Step 4: Invert the installed-Flatpak CI layout assertion** + +Inside the existing sandbox shell check: + +```bash +LAUNCHER_PATH="$(readlink -f /app/bin/iptvnator)" +test -f "${LAUNCHER_PATH}" +test ! -e "${LAUNCHER_PATH}.bin" +ELF_MAGIC="$(od -An -tx1 -N4 "${LAUNCHER_PATH}" | tr -d "[:space:]")" +test "${ELF_MAGIC}" = "7f454c46" +``` + +Capture the application-level probe output and fail on the historical Zypak +diagnostics: + +```bash +PROBE_OUTPUT="$( + xvfb-run -a dbus-run-session -- flatpak run \ + --env=LIBGL_ALWAYS_SOFTWARE=1 \ + com.fourgray.iptvnator \ + --embedded-mpv-runtime-probe 2>&1 +)" +printf '%s\n' "${PROBE_OUTPUT}" +if printf '%s\n' "${PROBE_OUTPUT}" | + grep -Eq 'not an ELF file|Zypak needs to be called directly'; then + echo "::error::Flatpak launched a wrapper instead of the Electron ELF." + exit 1 +fi +``` + +Update `configure-linux-frame-copy-build.test.mjs` to require the ELF magic +check, `.bin` rejection, warning guard, and absence of the old wrapper-marker +greps. + +- [ ] **Step 5: Update canonical launcher documentation** + +Document the exact invariant in all listed documentation: + +```text +Flatpak is an isolated packaging pass and keeps `iptvnator` as the real +Electron ELF so Electron Builder's `electron-wrapper` passes it directly to +Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and +`iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation. +``` + +In the earlier frame-copy design, replace the unconditional +`Electron executable (iptvnator.bin)` wording with +`iptvnator for Flatpak; iptvnator.bin for other Linux targets`. + +- [ ] **Step 6: Run targeted tests and formatting** + +```bash +node --test --test-name-pattern='Flatpak|Linux CI verifies' \ + tools/packaging/verify-linux-frame-copy-runtime.test.mjs \ + tools/packaging/configure-linux-frame-copy-build.test.mjs +pnpm prettier --check \ + tools/packaging/verify-linux-frame-copy-runtime.mjs \ + tools/packaging/verify-linux-frame-copy-runtime.test.mjs \ + tools/packaging/configure-linux-frame-copy-build.test.mjs \ + .github/workflows/build-and-make.yaml \ + docs/architecture/embedded-mpv-native.md \ + tools/embedded-mpv/README.md \ + docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md \ + AGENTS.md CLAUDE.md +``` + +Expected: tests and formatting PASS. + +- [ ] **Step 7: Commit Task 3** + +```bash +git add tools/packaging/verify-linux-frame-copy-runtime.mjs \ + tools/packaging/verify-linux-frame-copy-runtime.test.mjs \ + .github/workflows/build-and-make.yaml \ + tools/packaging/configure-linux-frame-copy-build.test.mjs \ + docs/architecture/embedded-mpv-native.md \ + tools/embedded-mpv/README.md \ + docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md \ + AGENTS.md CLAUDE.md +git commit -m "test(packaging): enforce direct Flatpak Zypak launch" +``` + +### Task 4: Verify the integrated fix + +**Files:** + +- Verify only; do not add unrelated changes. + +- [ ] **Step 1: Run the complete packaging tests** + +```bash +pnpm nx test packaging --skip-nx-cache +``` + +Expected: all tests PASS, including the new launcher tests. + +- [ ] **Step 2: Run packaging lint** + +```bash +pnpm nx lint packaging --skip-nx-cache +``` + +Expected: zero ESLint errors. + +- [ ] **Step 3: Run repository formatting checks for changed files** + +```bash +pnpm prettier --check \ + tools/packaging/linux-launcher-layout.cjs \ + tools/packaging/linux-after-pack.cjs \ + tools/packaging/linux-after-pack.test.mjs \ + tools/packaging/electron-after-pack.cjs \ + tools/packaging/embedded-mpv-packaging.cjs \ + tools/packaging/embedded-mpv-arch.test.mjs \ + tools/packaging/verify-electron-package-layout.mjs \ + tools/packaging/electron-package-identity.test.mjs \ + tools/packaging/verify-linux-frame-copy-runtime.mjs \ + tools/packaging/verify-linux-frame-copy-runtime.test.mjs \ + tools/packaging/configure-linux-frame-copy-build.test.mjs \ + tools/packaging/project.json \ + .github/workflows/build-and-make.yaml \ + docs/architecture/embedded-mpv-native.md \ + tools/embedded-mpv/README.md \ + docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md \ + docs/superpowers/specs/2026-07-18-flatpak-zypak-launcher-design.md \ + docs/superpowers/plans/2026-07-18-flatpak-zypak-launcher.md \ + AGENTS.md CLAUDE.md +``` + +Expected: all changed files use repository formatting. + +- [ ] **Step 4: Inspect the final diff** + +```bash +git diff 8fdac824..HEAD --check +git status --short +``` + +Expected: no whitespace errors and only scoped launcher, validator, CI, test, +plan, and documentation changes. diff --git a/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md index 94745ccaf..096e50531 100644 --- a/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md +++ b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md @@ -55,6 +55,11 @@ that the selected target set matches the runtime mode. It must fail closed if an official x64 package is requested with an absent, incomplete, or ambiguous profile. +Flatpak is an isolated packaging pass and keeps `iptvnator` as the real +Electron ELF so Electron Builder's `electron-wrapper` passes it directly to +Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and +`iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation. + System package dependencies are: - DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1` @@ -131,8 +136,9 @@ the complete non-system dependency closure. ELF dependencies inside that closure and the helper use only SONAMEs plus `$ORIGIN`-relative RPATH/RUNPATH; they may not retain build-prefix paths. -`embedded_mpv.node`, the Electron executable (`iptvnator.bin`), and Electron's -shipped libraries must not have a direct `DT_NEEDED` entry for libmpv. +`embedded_mpv.node`, the Electron executable (`iptvnator` for Flatpak; +`iptvnator.bin` for other Linux targets), and Electron's shipped libraries must +not have a direct `DT_NEEDED` entry for libmpv. `iptvnator_mpv_helper` must have one. Process isolation is an invariant, not a profile-specific choice. The source `electron-backend/native{,/**/*}` tree is excluded from `app.asar`; `afterPack` is the sole owner of the normalized diff --git a/docs/superpowers/specs/2026-07-18-flatpak-zypak-launcher-design.md b/docs/superpowers/specs/2026-07-18-flatpak-zypak-launcher-design.md new file mode 100644 index 000000000..96b996a1d --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-flatpak-zypak-launcher-design.md @@ -0,0 +1,69 @@ +# Flatpak Zypak Launcher Design + +## Goal + +Package Flatpak so Electron Builder's generated `electron-wrapper` passes the +real IPTVnator ELF executable directly to Zypak. Preserve the existing +conditional Linux sandbox wrapper for AppImage, DEB, RPM, Pacman, and Snap. + +## Root Cause + +The common Linux `afterPack` hook currently renames the Electron executable +from `iptvnator` to `iptvnator.bin` and writes a shell script at `iptvnator`. +Electron Builder's Flatpak launcher calls `zypak-wrapper iptvnator`, so Zypak +receives the shell script instead of an ELF executable. Zypak rejects that +layout; the shell script can then mask the failure by adding `--no-sandbox` on +hosts with restricted user namespaces. + +## Launcher Contract + +Introduce one packaging-owned launcher-layout helper: + +- an isolated Flatpak target uses `iptvnator` as the Electron ELF and does not + apply the custom Linux sandbox wrapper; +- every other supported Linux target keeps the existing `iptvnator` shell + wrapper and `iptvnator.bin` Electron ELF; +- a target set containing Flatpak and any other target fails before filesystem + mutation because Electron Builder shares one unpacked application tree + across those targets; +- target matching is case-insensitive and uses Electron Builder's documented + `AfterPackContext.targets[].name` values, not output paths or environment + heuristics. + +The launcher hook, pristine-layout validation, and extracted-artifact +validation must all resolve the Electron ELF path through this contract. + +## Validation + +Regression coverage will prove the following: + +1. The Linux launcher hook preserves the original executable bytes and creates + no `.bin` file for an isolated Flatpak target. +2. The hook retains the existing wrapper layout for a non-Flatpak Linux target. +3. A mixed Flatpak/non-Flatpak target set fails before renaming the executable. +4. Pristine Flatpak validation inspects `iptvnator`, while other profiles + inspect `iptvnator.bin`. +5. Extracted Flatpak verification reads architecture and checks process + isolation from `iptvnator`. +6. CI asserts that the installed Flatpak target is an ELF, rejects a sibling + `.bin` layout, and fails on the known Zypak wrapper warnings. + +The existing application-level `--embedded-mpv-runtime-probe` remains the +sandboxed launch check. Once the custom wrapper is absent, it can no longer +silently add `--no-sandbox`; reaching the Electron main-process probe therefore +also verifies the corrected Zypak entry path. + +## Documentation + +Update the canonical Linux Embedded MPV packaging documentation and the living +`AGENTS.md`/`CLAUDE.md` summaries to state the launcher split explicitly. +Correct the earlier Linux frame-copy design document's claim that every Linux +Electron executable is named `iptvnator.bin`. + +## Non-Goals + +- Changing the Chromium GPU/Video.js behavior reported in issue #1203. +- Removing the conditional sandbox wrapper from non-Flatpak Linux packages. +- Adding `--no-sandbox`, changing `chrome-sandbox` permissions, or bypassing + Zypak. +- Changing the bundled Embedded MPV runtime or Flatpak permissions. diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index 677122365..ccbd1b39b 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -190,9 +190,12 @@ controlled status `1`, then reconnects the provider and requires a successful diagnostic. This keeps the canonical layouts and missing-provider fallback in the same regression contract. -Profiles cannot share one Electron Builder pass because its targets reuse the -same unpacked application directory. A missing or unsupported profile, or a -target from another profile, fails packaging. +Flatpak is an isolated packaging pass and keeps `iptvnator` as the real +Electron ELF so Electron Builder's `electron-wrapper` passes it directly to +Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and +`iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation. A +missing or unsupported profile, or a target from another profile, fails +packaging. Linux frame-copy release artifacts are x64-only. Non-x64 packages are always marker-only even if environment variables point at the x64 staged runtime. diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index 4f3ec6b81..943b046ed 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -555,6 +555,52 @@ test('Flatpak application runtime probe runs under an isolated D-Bus session', ( ); }); +test('Flatpak CI verifies the direct Zypak ELF and preserves probe status', () => { + const flatpakVerificationStep = workflowStep( + 'Verify Flatpak payload, launcher, and sandboxed runtime' + ); + + assert.match( + flatpakVerificationStep, + /LAUNCHER_PATH="\$\(readlink -f \/app\/bin\/iptvnator\)"/ + ); + assert.match( + flatpakVerificationStep, + /test -f "\$\{LAUNCHER_PATH\}"[\s\S]*test -x "\$\{LAUNCHER_PATH\}"/ + ); + assert.match( + flatpakVerificationStep, + /if \[ -e "\$\{LAUNCHER_PATH\}\.bin" \] \|\| \[ -L "\$\{LAUNCHER_PATH\}\.bin" \]; then/ + ); + assert.match( + flatpakVerificationStep, + /ELF_MAGIC="\$\(od -An -tx1 -N4 "\$\{LAUNCHER_PATH\}" \| tr -d "\[:space:\]"\)"[\s\S]*test "\$\{ELF_MAGIC\}" = "7f454c46"/ + ); + assert.doesNotMatch( + flatpakVerificationStep, + /grep -q .*readlink -f "\$SCRIPT_PATH"/ + ); + assert.doesNotMatch( + flatpakVerificationStep, + /grep -q .*exec "\$SCRIPT_DIR\/iptvnator\.bin"/ + ); + + assert.match( + flatpakVerificationStep, + /set \+e[\s\S]*PROBE_OUTPUT="\$\([\s\S]*xvfb-run -a dbus-run-session -- flatpak run[\s\S]*--embedded-mpv-runtime-probe 2>&1[\s\S]*\)"[\s\S]*PROBE_STATUS=\$\?[\s\S]*set -e/ + ); + assert.match( + flatpakVerificationStep, + /PROBE_OUTPUT_LIMIT=16384[\s\S]*"\$\{PROBE_OUTPUT:0:PROBE_OUTPUT_LIMIT\}"/ + ); + assert.match(flatpakVerificationStep, /not an ELF file/); + assert.match(flatpakVerificationStep, /Zypak needs to be called directly/); + assert.match( + flatpakVerificationStep, + /if \[ "\$\{PROBE_STATUS\}" -ne 0 \]; then[\s\S]*exit "\$\{PROBE_STATUS\}"/ + ); +}); + test('foreign DEB CI explicitly selects both marker-only ARM architectures', () => { const foreignDebStep = workflowStep( 'Make marker-only foreign-architecture DEB packages' diff --git a/tools/packaging/electron-after-pack.cjs b/tools/packaging/electron-after-pack.cjs index ee6f9d640..5f7800cc3 100644 --- a/tools/packaging/electron-after-pack.cjs +++ b/tools/packaging/electron-after-pack.cjs @@ -198,7 +198,9 @@ async function afterPackHook(params) { } ); - await linuxAfterPack(params); + await linuxAfterPack(params, { + targetNames: linuxPackagingContext?.targetNames, + }); if (linuxPackagingContext) { const graphicsMountPath = ensureSnapGraphicsContentMount( params.appOutDir, diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index 64d934b8d..cbdc9c0cc 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -56,6 +56,10 @@ const packageLayoutVerifier = fs.readFileSync( join(currentDir, 'verify-electron-package-layout.mjs'), 'utf8' ); +const flatpakLauncherValidationSource = fs.readFileSync( + join(currentDir, 'flatpak-launcher-validation.cjs'), + 'utf8' +); const electronAfterPackSource = fs.readFileSync( join(currentDir, 'electron-after-pack.cjs'), 'utf8' @@ -308,6 +312,94 @@ test('package layout verifier uses canonical helpers and direct dependencies', ( assert.match(packageLayoutVerifier, /dirArch !== 'x64'/); assert.doesNotMatch(packageLayoutVerifier, /getEmbeddedMpvAddonArch/); assert.match(electronAfterPackSource, /targetArch !== 'x64'/); + assert.match( + packageLayoutVerifier, + /const\s*{\s*resolveLinuxLauncherLayout\s*}\s*=\s*require\(['"]\.\/linux-launcher-layout\.cjs['"]\)/ + ); + assert.match( + packageLayoutVerifier, + /const\s*{\s*validateFlatpakLauncher\s*,?\s*}\s*=\s*require\(['"]\.\/flatpak-launcher-validation\.cjs['"]\)/ + ); + assert.match( + packageLayoutVerifier, + /function verifyLinuxLauncher\(\s*resourceDir,\s*targetNames,\s*errors\s*\)/ + ); + assert.match( + packageLayoutVerifier, + /resolveLinuxLauncherLayout\(\s*targetNames,\s*linuxExecutableName\s*\)/ + ); + assert.match( + packageLayoutVerifier, + /verifyLinuxLauncher\(\s*resourceDir,\s*linuxTargetNames,\s*errors\s*\)/ + ); + + const launcherVerifier = packageLayoutVerifier.match( + /function verifyLinuxLauncher\([\s\S]*?\n}\n\nfunction verifyFlatpakPermissions/ + )?.[0]; + assert.ok(launcherVerifier); + assert.ok( + launcherVerifier.indexOf('resolveLinuxLauncherLayout(') < + launcherVerifier.indexOf('const launcherBinaryPath') + ); + assert.match( + launcherVerifier, + /if \(!launcherLayout\.wrapperRequired\) \{\s*errors\.push\(\s*\.\.\.validateFlatpakLauncher\(\s*appDir,\s*linuxExecutableName\s*\)\s*\);\s*return;\s*\}\s*const launcherBinaryPath[\s\S]*?fs\.readFileSync\(launcherPath,\s*['"]utf8['"]\)/ + ); +}); + +test('Flatpak launcher validation locks descriptor-based ELF inspection', () => { + assert.match( + flatpakLauncherValidationSource, + /const expectedElfMagic = Buffer\.from\(\[\s*0x7f,\s*0x45,\s*0x4c,\s*0x46,?\s*\]\)/ + ); + assert.match( + flatpakLauncherValidationSource, + /descriptor = fs\.openSync\(\s*launcherPath,\s*fs\.constants\.O_RDONLY\s*\|\s*fs\.constants\.O_NOFOLLOW\s*\)/ + ); + assert.match( + flatpakLauncherValidationSource, + /launcherStat = fs\.fstatSync\(descriptor\)/ + ); + assert.match( + flatpakLauncherValidationSource, + /const elfMagic = Buffer\.alloc\(expectedElfMagic\.length\)/ + ); + assert.match( + flatpakLauncherValidationSource, + /bytesRead = fs\.readSync\(\s*descriptor,\s*elfMagic,\s*0,\s*elfMagic\.length,\s*0\s*\)/ + ); + assert.match( + flatpakLauncherValidationSource, + /bytesRead !== expectedElfMagic\.length/ + ); + assert.match( + flatpakLauncherValidationSource, + /finally\s*{\s*try\s*{\s*fs\.closeSync\(descriptor\)/ + ); + + const openOffset = flatpakLauncherValidationSource.indexOf( + 'descriptor = fs.openSync(' + ); + const statOffset = flatpakLauncherValidationSource.indexOf( + 'launcherStat = fs.fstatSync(descriptor)' + ); + const readOffset = flatpakLauncherValidationSource.indexOf( + 'bytesRead = fs.readSync(' + ); + const finallyOffset = flatpakLauncherValidationSource.indexOf( + '} finally {', + readOffset + ); + const closeOffset = flatpakLauncherValidationSource.indexOf( + 'fs.closeSync(descriptor)', + finallyOffset + ); + assert.ok( + openOffset < statOffset && + statOffset < readOffset && + readOffset < finallyOffset && + finallyOffset < closeOffset + ); }); test('nx-electron packaging does not copy duplicate root package metadata', () => { diff --git a/tools/packaging/embedded-mpv-arch.test.mjs b/tools/packaging/embedded-mpv-arch.test.mjs index ca37b0d02..1b4f56c17 100644 --- a/tools/packaging/embedded-mpv-arch.test.mjs +++ b/tools/packaging/embedded-mpv-arch.test.mjs @@ -279,6 +279,7 @@ function pureValidationOptions(options = {}) { function validElfInspector(nativeDir, manifest, overrides = {}) { const libDir = join(nativeDir, 'lib'); const records = new Map([ + ['iptvnator', { needed: ['libc.so.6'], rpath: [], runpath: [] }], ['iptvnator.bin', { needed: ['libc.so.6'], rpath: [], runpath: [] }], [ FRAME_COPY_ARTIFACTS.addon, @@ -598,6 +599,10 @@ test('prepares a normalized system profile with no private runtime', (t) => { test('prepares portable and Flatpak manifests with the exact bundled closure', (t) => { const portable = createNativeFixture(); const flatpak = createNativeFixture(); + fs.renameSync( + join(flatpak.appOutDir, 'iptvnator.bin'), + join(flatpak.appOutDir, 'iptvnator') + ); t.after(() => { for (const fixture of [portable, flatpak]) { fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }); @@ -699,6 +704,11 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', ( pureValidationOptions({ profile: 'flatpak', targetNames: ['flatpak'], + hostPlatform: 'linux', + elfInspector: validElfInspector( + flatpak.nativeDir, + flatpakManifest + ), }) ), [] diff --git a/tools/packaging/embedded-mpv-packaging.cjs b/tools/packaging/embedded-mpv-packaging.cjs index 02e2a0711..173c89b09 100644 --- a/tools/packaging/embedded-mpv-packaging.cjs +++ b/tools/packaging/embedded-mpv-packaging.cjs @@ -25,6 +25,7 @@ const { resolveLinuxFrameCopyProfile, validateLinuxProfileTargets, } = require('./linux-frame-copy-profile.cjs'); +const { resolveLinuxLauncherLayout } = require('./linux-launcher-layout.cjs'); const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/']; const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/']; @@ -1380,9 +1381,26 @@ function inspectLinuxElfIsolation( resourceDir, nativeDir, manifest, + targetNames, options, errors ) { + const executableName = options.executableName ?? 'iptvnator'; + let launcherLayout; + try { + launcherLayout = resolveLinuxLauncherLayout( + targetNames, + executableName + ); + } catch (error) { + errors.push( + `Unable to resolve Linux launcher layout: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return; + } + const hostPlatform = options.hostPlatform ?? process.platform; let inspectElf = options.elfInspector; if (!inspectElf) { @@ -1403,9 +1421,11 @@ function inspectLinuxElfIsolation( return; } - const executableName = options.executableName ?? 'iptvnator'; const inspectedPaths = { - electron: path.join(path.dirname(resourceDir), `${executableName}.bin`), + electron: path.join( + path.dirname(resourceDir), + launcherLayout.electronBinaryName + ), addon: path.join(nativeDir, linuxFrameCopyArtifacts.addon.name), reader: path.join(nativeDir, linuxFrameCopyArtifacts.frameReader.name), helper: path.join(nativeDir, linuxFrameCopyArtifacts.helper.name), @@ -1770,7 +1790,14 @@ function validateLinuxPackagedEmbeddedMpv(resourceDir, options) { } else { validateBundledLinuxRuntime(nativeDir, manifest, errors); } - inspectLinuxElfIsolation(resourceDir, nativeDir, manifest, options, errors); + inspectLinuxElfIsolation( + resourceDir, + nativeDir, + manifest, + targetNames, + options, + errors + ); return errors; } diff --git a/tools/packaging/flatpak-launcher-validation.cjs b/tools/packaging/flatpak-launcher-validation.cjs new file mode 100644 index 000000000..933bf6098 --- /dev/null +++ b/tools/packaging/flatpak-launcher-validation.cjs @@ -0,0 +1,125 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const expectedElfMagic = Buffer.from([0x7f, 0x45, 0x4c, 0x46]); +const executableModeBits = 0o111; + +function fsErrorCode(error) { + return error && typeof error === 'object' && typeof error.code === 'string' + ? error.code + : 'UNKNOWN'; +} + +function validateBinarySibling(siblingPath, errors) { + try { + fs.lstatSync(siblingPath); + } catch (error) { + if (fsErrorCode(error) === 'ENOENT') { + return; + } + errors.push( + `Unable to inspect Flatpak Electron launcher binary sibling at ${siblingPath} (${fsErrorCode(error)}).` + ); + return; + } + + errors.push( + `Flatpak Electron layout must not include a launcher binary sibling: ${siblingPath}` + ); +} + +function validateFlatpakLauncher(appDir, executableName) { + const errors = []; + const launcherPath = path.join(appDir, executableName); + const flatpakBinarySiblingPath = `${launcherPath}.bin`; + validateBinarySibling(flatpakBinarySiblingPath, errors); + + let descriptor; + try { + descriptor = fs.openSync( + launcherPath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW + ); + } catch (error) { + const errorCode = fsErrorCode(error); + if (errorCode === 'ENOENT') { + errors.push( + `Missing Flatpak Electron ELF in ${appDir}: ${path.basename(launcherPath)}` + ); + } else if (errorCode === 'ELOOP') { + errors.push( + `Flatpak Electron launcher must be a regular file: ${launcherPath}` + ); + } else { + errors.push( + `Unable to open Flatpak Electron launcher at ${launcherPath} (${errorCode}).` + ); + } + return errors; + } + + try { + let launcherStat; + try { + launcherStat = fs.fstatSync(descriptor); + } catch (error) { + errors.push( + `Unable to stat Flatpak Electron launcher at ${launcherPath} (${fsErrorCode(error)}).` + ); + return errors; + } + + if (!launcherStat.isFile()) { + errors.push( + `Flatpak Electron launcher must be a regular file: ${launcherPath}` + ); + return errors; + } + if ((launcherStat.mode & executableModeBits) === 0) { + errors.push( + `Flatpak Electron launcher must be executable: ${launcherPath}` + ); + } + + const elfMagic = Buffer.alloc(expectedElfMagic.length); + let bytesRead; + try { + bytesRead = fs.readSync( + descriptor, + elfMagic, + 0, + elfMagic.length, + 0 + ); + } catch (error) { + errors.push( + `Unable to read Flatpak Electron launcher at ${launcherPath} (${fsErrorCode(error)}).` + ); + return errors; + } + + if ( + bytesRead !== expectedElfMagic.length || + !elfMagic.equals(expectedElfMagic) + ) { + errors.push( + `Flatpak Electron launcher must be an ELF binary: ${launcherPath}` + ); + } + } finally { + try { + fs.closeSync(descriptor); + } catch (error) { + errors.push( + `Unable to close Flatpak Electron launcher at ${launcherPath} (${fsErrorCode(error)}).` + ); + } + } + return errors; +} + +module.exports = { + validateFlatpakLauncher, +}; diff --git a/tools/packaging/flatpak-launcher-validation.test.mjs b/tools/packaging/flatpak-launcher-validation.test.mjs new file mode 100644 index 000000000..0b8ff95b2 --- /dev/null +++ b/tools/packaging/flatpak-launcher-validation.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + validateFlatpakLauncher, +} = require('./flatpak-launcher-validation.cjs'); + +const executableName = 'iptvnator'; +const electronElf = Buffer.from([ + 0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00, +]); + +async function createFixture(t) { + const appDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'iptvnator-flatpak-launcher-') + ); + t.after(() => fs.rm(appDir, { recursive: true, force: true })); + + return { + appDir, + launcherPath: path.join(appDir, executableName), + siblingPath: path.join(appDir, `${executableName}.bin`), + }; +} + +async function writeLauncher(launcherPath, contents, mode) { + await fs.writeFile(launcherPath, contents); + await fs.chmod(launcherPath, mode); +} + +test('accepts a regular executable Flatpak Electron ELF', async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, electronElf, 0o755); + + assert.deepEqual( + validateFlatpakLauncher(fixture.appDir, executableName), + [] + ); +}); + +test('rejects a symlinked Flatpak Electron launcher', async (t) => { + const fixture = await createFixture(t); + const electronTargetPath = path.join(fixture.appDir, 'electron-target'); + await writeLauncher(electronTargetPath, electronElf, 0o755); + await fs.symlink(electronTargetPath, fixture.launcherPath); + + assert.deepEqual(validateFlatpakLauncher(fixture.appDir, executableName), [ + `Flatpak Electron launcher must be a regular file: ${fixture.launcherPath}`, + ]); +}); + +test('rejects a dangling Flatpak launcher binary sibling', async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, electronElf, 0o755); + await fs.symlink('missing-electron', fixture.siblingPath); + + assert.deepEqual(validateFlatpakLauncher(fixture.appDir, executableName), [ + `Flatpak Electron layout must not include a launcher binary sibling: ${fixture.siblingPath}`, + ]); +}); + +test('rejects a non-executable regular Flatpak Electron ELF', async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, electronElf, 0o644); + + assert.deepEqual(validateFlatpakLauncher(fixture.appDir, executableName), [ + `Flatpak Electron launcher must be executable: ${fixture.launcherPath}`, + ]); +}); + +for (const [description, contents] of [ + ['short ELF magic', Buffer.from([0x7f, 0x45, 0x4c])], + ['incorrect ELF magic', Buffer.from([0x00, 0x45, 0x4c, 0x46])], +]) { + test(`rejects ${description} in the Flatpak Electron launcher`, async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, contents, 0o755); + + assert.deepEqual( + validateFlatpakLauncher(fixture.appDir, executableName), + [ + `Flatpak Electron launcher must be an ELF binary: ${fixture.launcherPath}`, + ] + ); + }); +} + +test('packaging Nx tests register the Flatpak launcher validation suite', async () => { + const project = JSON.parse( + await fs.readFile(new URL('./project.json', import.meta.url), 'utf8') + ); + const moduleFile = + '{workspaceRoot}/tools/packaging/flatpak-launcher-validation.cjs'; + const testFile = + '{workspaceRoot}/tools/packaging/flatpak-launcher-validation.test.mjs'; + + assert.ok(project.targets.test.inputs.includes(moduleFile)); + assert.ok(project.targets.test.inputs.includes(testFile)); + assert.match( + project.targets.test.options.command, + /node --test .*tools\/packaging\/flatpak-launcher-validation\.test\.mjs/ + ); +}); diff --git a/tools/packaging/linux-after-pack.cjs b/tools/packaging/linux-after-pack.cjs index 93e6b8cb4..3cdb829f1 100644 --- a/tools/packaging/linux-after-pack.cjs +++ b/tools/packaging/linux-after-pack.cjs @@ -1,5 +1,6 @@ const fs = require('fs/promises'); const path = require('path'); +const { resolveLinuxLauncherLayout } = require('./linux-launcher-layout.cjs'); function log(message) { console.log(` - ${message}`); @@ -41,17 +42,33 @@ exec "$SCRIPT_DIR/${executableName}.bin" "\${EXEC_ARGS[@]}" "$@" `; } -async function afterPackHook(params) { +async function afterPackHook(params, { targetNames = params.targets } = {}) { if (params.electronPlatformName !== 'linux') { return; } + const launcherLayout = resolveLinuxLauncherLayout( + targetNames, + params.packager.executableName + ); + if (!launcherLayout.wrapperRequired) { + log('preserving the Electron ELF for isolated Flatpak packaging'); + return; + } + log('applying Linux launcher sandbox fix'); - const executable = path.join(params.appOutDir, params.packager.executableName); + const executable = path.join( + params.appOutDir, + params.packager.executableName + ); + const electronBinary = path.join( + params.appOutDir, + launcherLayout.electronBinaryName + ); try { - await fs.rename(executable, `${executable}.bin`); + await fs.rename(executable, electronBinary); await fs.writeFile( executable, createLoaderScript({ diff --git a/tools/packaging/linux-after-pack.test.mjs b/tools/packaging/linux-after-pack.test.mjs new file mode 100644 index 000000000..3e55e00cb --- /dev/null +++ b/tools/packaging/linux-after-pack.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const linuxAfterPack = require('./linux-after-pack.cjs'); +const { createLoaderScript } = linuxAfterPack; + +const electronElf = Buffer.from([ + 0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00, 0x49, 0x50, 0x54, 0x56, +]); + +async function createAfterPackFixture(targets) { + const appOutDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'iptvnator-linux-after-pack-') + ); + const executablePath = path.join(appOutDir, 'iptvnator'); + await fs.writeFile(executablePath, electronElf, { mode: 0o755 }); + + return { + appOutDir, + executablePath, + params: { + appOutDir, + electronPlatformName: 'linux', + targets, + packager: { + executableName: 'iptvnator', + appInfo: { + productName: 'IPTVnator', + }, + }, + }, + }; +} + +function resolveLinuxLauncherLayout(...args) { + return require('./linux-launcher-layout.cjs').resolveLinuxLauncherLayout( + ...args + ); +} + +async function assertPathMissing(filePath) { + await assert.rejects( + fs.stat(filePath), + (error) => error?.code === 'ENOENT' + ); +} + +test('isolated Flatpak preserves the Electron ELF for Zypak', async (t) => { + const fixture = await createAfterPackFixture([{ name: 'flatpak' }]); + t.after(() => + fs.rm(fixture.appOutDir, { + recursive: true, + force: true, + }) + ); + + await linuxAfterPack(fixture.params); + + assert.deepEqual(await fs.readFile(fixture.executablePath), electronElf); + await assertPathMissing(`${fixture.executablePath}.bin`); +}); + +test('resolves normalized string and Target-like launcher layouts', () => { + assert.deepEqual(resolveLinuxLauncherLayout([' FlatPak ']), { + targetNames: ['flatpak'], + electronBinaryName: 'iptvnator', + wrapperRequired: false, + }); + assert.deepEqual( + resolveLinuxLauncherLayout( + [' AppImage ', { name: ' DEB ' }], + 'iptvnator-player' + ), + { + targetNames: ['appimage', 'deb'], + electronBinaryName: 'iptvnator-player.bin', + wrapperRequired: true, + } + ); +}); + +test('rejects missing, empty, and non-array target lists', () => { + for (const targets of [undefined, null, 'flatpak', new Map(), {}]) { + assert.throws( + () => resolveLinuxLauncherLayout(targets), + /Linux launcher targets must be an array/ + ); + } + assert.throws( + () => resolveLinuxLauncherLayout([]), + /Linux launcher targets must contain at least one target/ + ); +}); + +test('rejects empty target names and normalized duplicates', () => { + for (const target of ['', ' ', {}, { name: '' }, null, 42]) { + assert.throws( + () => resolveLinuxLauncherLayout([target]), + /Linux launcher targets must expose a non-empty name/ + ); + } + assert.throws( + () => resolveLinuxLauncherLayout([{ name: ' DEB ' }, { name: 'deb' }]), + /Linux launcher target "deb" is duplicated/ + ); +}); + +test('rejects Flatpak mixed with another target', () => { + assert.throws( + () => + resolveLinuxLauncherLayout([ + { name: 'flatpak' }, + { name: 'AppImage' }, + ]), + /Flatpak must be packaged in an isolated Electron Builder pass so Zypak receives the Electron ELF directly/ + ); +}); + +for (const targetName of ['appimage', 'deb', 'rpm', 'pacman', 'snap']) { + test(`${targetName} retains the launcher wrapper and Electron ELF binary`, async (t) => { + const fixture = await createAfterPackFixture([{ name: targetName }]); + t.after(() => + fs.rm(fixture.appOutDir, { + recursive: true, + force: true, + }) + ); + + await linuxAfterPack(fixture.params); + + assert.equal( + await fs.readFile(fixture.executablePath, 'utf8'), + createLoaderScript({ + executableName: 'iptvnator', + productName: 'IPTVnator', + }) + ); + assert.deepEqual( + await fs.readFile(`${fixture.executablePath}.bin`), + electronElf + ); + assert.equal( + (await fs.stat(fixture.executablePath)).mode & 0o777, + 0o755 + ); + }); +} + +test('mixed Flatpak targets fail before mutating the Electron ELF', async (t) => { + const fixture = await createAfterPackFixture([ + { name: 'flatpak' }, + { name: 'appimage' }, + ]); + t.after(() => + fs.rm(fixture.appOutDir, { + recursive: true, + force: true, + }) + ); + + await assert.rejects( + linuxAfterPack(fixture.params), + /Flatpak must be packaged in an isolated Electron Builder pass so Zypak receives the Electron ELF directly/ + ); + + assert.deepEqual(await fs.readFile(fixture.executablePath), electronElf); + await assertPathMissing(`${fixture.executablePath}.bin`); +}); + +test('packaging Nx tests register the Linux afterPack regression suite', async () => { + const project = JSON.parse( + await fs.readFile(new URL('./project.json', import.meta.url), 'utf8') + ); + const testFile = + '{workspaceRoot}/tools/packaging/linux-after-pack.test.mjs'; + + assert.ok(project.targets.test.inputs.includes(testFile)); + assert.match( + project.targets.test.options.command, + /node --test .*tools\/packaging\/linux-after-pack\.test\.mjs/ + ); +}); diff --git a/tools/packaging/linux-launcher-layout.cjs b/tools/packaging/linux-launcher-layout.cjs new file mode 100644 index 000000000..2564db529 --- /dev/null +++ b/tools/packaging/linux-launcher-layout.cjs @@ -0,0 +1,56 @@ +'use strict'; + +function normalizedTargetName(target) { + const value = + typeof target === 'string' + ? target + : target && typeof target === 'object' + ? target.name + : null; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error('Linux launcher targets must expose a non-empty name.'); + } + return value.trim().toLowerCase(); +} + +function resolveLinuxLauncherLayout(targets, executableName = 'iptvnator') { + if (!Array.isArray(targets)) { + throw new TypeError('Linux launcher targets must be an array.'); + } + if (targets.length === 0) { + throw new Error( + 'Linux launcher targets must contain at least one target.' + ); + } + + const targetNames = []; + for (const target of targets) { + const targetName = normalizedTargetName(target); + if (targetNames.includes(targetName)) { + throw new Error( + `Linux launcher target "${targetName}" is duplicated.` + ); + } + targetNames.push(targetName); + } + + const flatpakSelected = targetNames.includes('flatpak'); + if (flatpakSelected && targetNames.length !== 1) { + throw new Error( + 'Flatpak must be packaged in an isolated Electron Builder pass so Zypak receives the Electron ELF directly.' + ); + } + + const wrapperRequired = !flatpakSelected; + return { + targetNames, + electronBinaryName: wrapperRequired + ? `${executableName}.bin` + : executableName, + wrapperRequired, + }; +} + +module.exports = { + resolveLinuxLauncherLayout, +}; diff --git a/tools/packaging/project.json b/tools/packaging/project.json index 1ac68583b..2c3097c63 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -22,8 +22,11 @@ "{workspaceRoot}/tools/packaging/asar-dependency-closure.test.mjs", "{workspaceRoot}/tools/packaging/embedded-mpv-packaging.cjs", "{workspaceRoot}/tools/packaging/embedded-mpv-arch.test.mjs", + "{workspaceRoot}/tools/packaging/flatpak-launcher-validation.cjs", + "{workspaceRoot}/tools/packaging/flatpak-launcher-validation.test.mjs", "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.mjs", "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.test.mjs", + "{workspaceRoot}/tools/packaging/linux-after-pack.test.mjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.cjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.test.mjs", "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.cjs", @@ -49,7 +52,7 @@ "{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs" ], "options": { - "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/release-snap-assets.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", + "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/flatpak-launcher-validation.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-after-pack.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/release-snap-assets.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", "cwd": "{workspaceRoot}" } }, diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index ab94e048c..40a709c6f 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -18,6 +18,10 @@ const { const { validateLinuxProfileTargets, } = require('./linux-frame-copy-profile.cjs'); +const { resolveLinuxLauncherLayout } = require('./linux-launcher-layout.cjs'); +const { + validateFlatpakLauncher, +} = require('./flatpak-launcher-validation.cjs'); const args = process.argv.slice(2); const normalizedArgs = args[0] === '--' ? args.slice(1) : args; const [platform, arch = ''] = normalizedArgs; @@ -468,10 +472,34 @@ function verifyPackagedPackageMetadata(resourceDir, errors) { } } -function verifyLinuxLauncher(resourceDir, errors) { +function verifyLinuxLauncher(resourceDir, targetNames, errors) { + let launcherLayout; + try { + launcherLayout = resolveLinuxLauncherLayout( + targetNames, + linuxExecutableName + ); + } catch (error) { + errors.push( + `Unable to resolve Linux launcher layout: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return; + } + const appDir = path.dirname(resourceDir); const launcherPath = path.join(appDir, linuxExecutableName); - const launcherBinaryPath = `${launcherPath}.bin`; + + if (!launcherLayout.wrapperRequired) { + errors.push(...validateFlatpakLauncher(appDir, linuxExecutableName)); + return; + } + + const launcherBinaryPath = path.join( + appDir, + launcherLayout.electronBinaryName + ); if (!fileExists(launcherBinaryPath)) { errors.push( @@ -733,7 +761,7 @@ function verifyResourceDir(resourceDir) { verifyLinuxExecutableArgs(errors); verifyFlatpakPermissions(errors); verifySnapPackagingConfig(errors); - verifyLinuxLauncher(resourceDir, errors); + verifyLinuxLauncher(resourceDir, linuxTargetNames, errors); } errors.push( diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs index e7bfb7a3b..68121639d 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -24,6 +24,7 @@ const { LINUX_SYSTEM_PACKAGE_DEPENDENCIES, resolveLinuxFrameCopyProfile, } = require('./linux-frame-copy-profile.cjs'); +const { resolveLinuxLauncherLayout } = require('./linux-launcher-layout.cjs'); const { RUNTIME_PROBE_MAX_BUFFER_BYTES, RUNTIME_PROBE_TIMEOUT_MS, @@ -1350,9 +1351,14 @@ function dependencyFileName(dependencyName) { return String(dependencyName).replaceAll('\\', '/').split('/').at(-1) ?? ''; } +function resolveElectronBinaryPath(resourceDir, artifactFormat) { + const layout = resolveLinuxLauncherLayout([artifactFormat]); + return path.join(path.dirname(resourceDir), layout.electronBinaryName); +} + function validateElectronIsolation(resourceDir, artifactFormat, elfInspector) { const errors = []; - const electronPath = path.join(path.dirname(resourceDir), 'iptvnator.bin'); + const electronPath = resolveElectronBinaryPath(resourceDir, artifactFormat); const binaries = [ { label: 'Electron binary', binaryPath: electronPath }, ...listElectronShippedLinuxLibraries(resourceDir, { @@ -1557,7 +1563,7 @@ export function verifyExtractedLinuxFrameCopyRuntime({ ) ); - const electronPath = path.join(path.dirname(resourceDir), 'iptvnator.bin'); + const electronPath = resolveElectronBinaryPath(resourceDir, artifactFormat); let packageArch; try { packageArch = readElfArchitecture(electronPath); @@ -1709,10 +1715,7 @@ export function verifyLinuxFrameCopyArtifact({ ].join('\n') ); } - const electronPath = path.join( - path.dirname(resourceDir), - 'iptvnator.bin' - ); + const electronPath = resolveElectronBinaryPath(resourceDir, format); return { artifactPath: resolvedArtifactPath, format, diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs index ad2b7154e..0a92857ea 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -219,7 +219,10 @@ function elfHeader(architecture) { return image; } -function createSystemPayload({ architecture = 'x64' } = {}) { +function createSystemPayload({ + architecture = 'x64', + electronBinaryName = 'iptvnator.bin', +} = {}) { const root = fs.mkdtempSync( path.join(os.tmpdir(), 'iptvnator-verifier-layout-') ); @@ -233,7 +236,7 @@ function createSystemPayload({ architecture = 'x64' } = {}) { ); fs.mkdirSync(nativeDir, { recursive: true }); fs.writeFileSync( - path.join(appDir, 'iptvnator.bin'), + path.join(appDir, electronBinaryName), elfHeader(architecture) ); @@ -1574,6 +1577,64 @@ test('artifact verification enforces Snap metadata for x64 and ARM payloads', () } }); +test('verifies an outer Flatpak artifact with an unwrapped Electron ELF', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-flatpak-artifact-') + ); + const artifactPath = path.join(root, 'IPTVnator.flatpak'); + fs.writeFileSync(artifactPath, 'flatpak fixture'); + + try { + assert.deepEqual( + verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName: 'flatpak', + extractArtifact({ destination }) { + const appDir = path.join( + destination, + 'files', + 'lib', + 'com.fourgray.iptvnator' + ); + const resourceDir = path.join(appDir, 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + fs.writeFileSync( + path.join(appDir, 'iptvnator'), + elfHeader('arm64') + ); + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-unavailable.txt'), + 'Unavailable for arm64\n' + ); + return destination; + }, + metadataReader: () => ({ + declaredArch: 'arm64', + dependencies: [], + }), + elfInspector: validElfInspector, + probeRunner() { + assert.fail('foreign Flatpak must not probe'); + }, + }), + { + artifactPath: path.resolve(artifactPath), + format: 'flatpak', + profileName: 'flatpak', + architecture: 'arm64', + } + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test('bundled probes remove ambient loader paths and use only packaged libraries', () => { assert.deepEqual( createRuntimeProbeEnvironment({ @@ -1824,6 +1885,32 @@ test('requires marker-only foreign packages, scans Electron, and never probes', } }); +test('validates a marker-only Flatpak with an unwrapped Electron ELF', () => { + const fixture = createSystemPayload({ + architecture: 'arm64', + electronBinaryName: 'iptvnator', + }); + try { + assert.deepEqual( + verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'flatpak', + profileName: 'flatpak', + packageDependencies: [], + elfInspector: validElfInspector, + probeRunner() { + assert.fail( + 'foreign Flatpak must not run the helper probe' + ); + }, + }), + [] + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + test('always removes its temporary extraction root after a verifier failure', () => { const root = fs.mkdtempSync( path.join(os.tmpdir(), 'iptvnator-verifier-cleanup-parent-') From 2f8aee72df28faa55815338b54b73b4e5ed06098 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 19 Jul 2026 06:38:19 +0200 Subject: [PATCH 03/26] feat(xtream): add catch-up playback to favorites and recent tabs (#1166) Enables Xtream catch-up/timeshift from the Favorites and Recent surfaces (per-playlist and global), not just Live TV, and adds start-over replay of the currently-airing programme. Carries tv_archive/tv_archive_duration through the favorites and recently-viewed DB projections and maps them onto UnifiedCollectionItem; tv_archive_duration is interpreted as days, matching live-stream-layout.controlledArchiveDays. Closes #1138. Co-authored-by: Claude --- .../operations/favorites.operations.spec.ts | 35 +++++ .../operations/favorites.operations.ts | 4 + .../recently-viewed.operations.spec.ts | 18 ++- .../operations/recently-viewed.operations.ts | 4 + .../src/app/workers/epg-database.spec.ts | 19 ++- .../src/app/workers/epg-database.ts | 25 ++-- docs/architecture/m3u-playlist-module.md | 3 +- .../xtream-portal-compatibility.md | 9 ++ .../lib/collection/stream-resolver.service.ts | 37 ++++- .../unified-favorites-data.service.spec.ts | 6 + .../unified-favorites-data.service.ts | 4 + .../unified-recent-data.service.spec.ts | 34 ++++- .../collection/unified-recent-data.service.ts | 6 + .../unified-live-epg-summary.util.ts | 16 +++ .../unified-live-tab.component.spec.ts | 104 ++++++++++++++ .../unified-live-tab.component.ts | 129 +++++++++++++++--- .../src/lib/collection/collection-helpers.ts | 2 + .../unified-collection-item.interface.ts | 6 + .../interfaces/src/lib/playlist.interface.ts | 2 + .../lib/epg-timeline/epg-archive.util.spec.ts | 15 +- .../src/lib/epg-timeline/epg-archive.util.ts | 7 +- .../epg-timeline/epg-timeline-render.util.ts | 2 +- .../epg-timeline-track.component.html | 9 ++ .../epg-timeline-track.component.scss | 3 + .../epg-timeline/epg-timeline.component.ts | 8 +- 25 files changed, 459 insertions(+), 48 deletions(-) diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts index 90b65e4de..81a413a59 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts @@ -25,9 +25,12 @@ jest.mock('drizzle-orm', () => ({ ), })); +import * as schema from '@iptvnator/shared/database/schema'; import type { AppDatabase } from '../database.types'; import { createDbMock } from './operations.test-helpers'; import { + getAllGlobalFavorites, + getFavorites, getGlobalFavorites, reorderGlobalFavorites, } from './favorites.operations'; @@ -93,6 +96,38 @@ describe('favorites.operations', () => { ]); }); + // Regression for issue #1138: archive metadata must survive every + // favorites projection so catch-up stays available outside Live TV. + it('selects archive metadata for playlist favorites', async () => { + const { db, select } = createGlobalFavoritesDbMock([]); + + await getFavorites(db, 'playlist-1'); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, + }) + ); + }); + + it('selects archive metadata for global favorites', async () => { + const { db, select } = createGlobalFavoritesDbMock([]); + + await getGlobalFavorites(db); + await getAllGlobalFavorites(db); + + expect(select).toHaveBeenCalledTimes(2); + for (const [projection] of select.mock.calls) { + expect(projection).toEqual( + expect.objectContaining({ + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, + }) + ); + } + }); + describe('reorderGlobalFavorites', () => { it('short-circuits without touching the db when there are no updates', async () => { const { db, update, transaction } = createDbMock(); diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.ts index 8bb66e98d..251747d5b 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.ts @@ -73,6 +73,8 @@ export async function getFavorites(db: AppDatabase, playlistId: string) { poster_url: schema.content.posterUrl, xtream_id: schema.content.xtreamId, type: schema.content.type, + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, added_at: schema.favorites.addedAt, position: schema.favorites.position, }) @@ -117,6 +119,8 @@ function selectGlobalFavoriteRows( : {}), xtream_id: schema.content.xtreamId, type: schema.content.type, + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, playlist_id: schema.playlists.id, playlist_name: schema.playlists.name, added_at: schema.favorites.addedAt, diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts index 4dce217f1..f959cb911 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts @@ -35,7 +35,7 @@ describe('recently-viewed.operations', () => { { id: 2, title: 'Newest', viewed_at: '2026-07-02 10:00:00' }, { id: 1, title: 'Older', viewed_at: '2026-07-01 10:00:00' }, ]; - const { db, queries } = createDbMock([rows]); + const { db, queries, select } = createDbMock([rows]); await expect(getRecentlyViewed(db)).resolves.toEqual(rows); @@ -47,11 +47,19 @@ describe('recently-viewed.operations', () => { ); expect(queries[0].limit).toHaveBeenCalledWith(100); expect(queries[0].innerJoin).toHaveBeenCalledTimes(3); + // Regression for issue #1138: archive metadata must survive the + // recent projections so catch-up stays available outside Live TV. + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, + }) + ); }); it('scopes playlist history to the playlist, newest-first with a 100 cap', async () => { const rows = [{ id: 5, title: 'Recent Movie' }]; - const { db, queries } = createDbMock([rows]); + const { db, queries, select } = createDbMock([rows]); await expect(getRecentItems(db, 'playlist-1')).resolves.toEqual( rows @@ -65,6 +73,12 @@ describe('recently-viewed.operations', () => { schema.recentlyViewed.viewedAt ); expect(queries[0].limit).toHaveBeenCalledWith(100); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, + }) + ); }); }); diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts index fee6df185..7a2c06657 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts @@ -15,6 +15,8 @@ export async function getRecentlyViewed(db: AppDatabase) { backdrop_url: schema.content.backdropUrl, xtream_id: schema.content.xtreamId, type: schema.content.type, + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, playlist_id: schema.categories.playlistId, playlist_name: schema.playlists.name, viewed_at: schema.recentlyViewed.viewedAt, @@ -58,6 +60,8 @@ export async function getRecentItems( backdrop_url: schema.content.backdropUrl, xtream_id: schema.content.xtreamId, type: schema.content.type, + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, viewed_at: schema.recentlyViewed.viewedAt, }) .from(schema.recentlyViewed) diff --git a/apps/electron-backend/src/app/workers/epg-database.spec.ts b/apps/electron-backend/src/app/workers/epg-database.spec.ts index d448e47f7..2ec863a8b 100644 --- a/apps/electron-backend/src/app/workers/epg-database.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-database.spec.ts @@ -43,7 +43,7 @@ function createEpgDatabaseMock() { } describe('EpgDatabase', () => { - it('refreshes a source without cascading shared channel programs from other sources', () => { + it('refreshes a source selectively, keeping the recent past-programme archive', () => { const sourceUrl = 'https://example.com/playlist-guide.xml'; const { Database, database, statements } = createEpgDatabaseMock(); const epgDb = new EpgDatabase(Database); @@ -62,8 +62,11 @@ describe('EpgDatabase', () => { const preparedSql = database.prepare.mock.calls.map(([sql]) => normalizeSql(sql) ); - const deleteProgramsSql = - 'DELETE FROM epg_programs WHERE source_url = ?'; + const deleteTodayAndFutureSql = normalizeSql(` + DELETE FROM epg_programs + WHERE source_url = ? + AND (start >= date('now') OR start < date('now', '-7 days')) + `); const deleteOrphanChannelsSql = normalizeSql(` DELETE FROM epg_channels WHERE source_url = ? @@ -74,12 +77,18 @@ describe('EpgDatabase', () => { ) `); - expect(preparedSql).toContain(deleteProgramsSql); + expect(preparedSql).toContain(deleteTodayAndFutureSql); expect(preparedSql).toContain(deleteOrphanChannelsSql); + // A refresh must not wipe the whole source: the last 7 days of + // programmes stay behind so catch-up remains browsable (#1138), + // and other sources' shared channels must not cascade away. + expect(preparedSql).not.toContain( + 'DELETE FROM epg_programs WHERE source_url = ?' + ); expect(preparedSql).not.toContain( 'DELETE FROM epg_channels WHERE source_url = ?' ); - expect(statements.get(deleteProgramsSql)).toHaveBeenCalledWith( + expect(statements.get(deleteTodayAndFutureSql)).toHaveBeenCalledWith( sourceUrl ); expect(statements.get(deleteOrphanChannelsSql)).toHaveBeenCalledWith( diff --git a/apps/electron-backend/src/app/workers/epg-database.ts b/apps/electron-backend/src/app/workers/epg-database.ts index 6123619c0..e58d3db98 100644 --- a/apps/electron-backend/src/app/workers/epg-database.ts +++ b/apps/electron-backend/src/app/workers/epg-database.ts @@ -11,8 +11,8 @@ export class EpgDatabase { private readonly knownChannelIds = new Set(); private readonly insertChannelStmt: BetterSqlite3.Statement; private readonly insertProgramStmt: BetterSqlite3.Statement; - private readonly deleteProgramsForSourceStmt: BetterSqlite3.Statement; private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement; + private readonly deleteTodayAndFutureStmt: BetterSqlite3.Statement; constructor(Database: typeof BetterSqlite3) { this.db = new Database(getIptvnatorDatabasePath()); @@ -35,10 +35,6 @@ export class EpgDatabase { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - this.deleteProgramsForSourceStmt = this.db.prepare(` - DELETE FROM epg_programs WHERE source_url = ? - `); - this.deleteOrphanChannelsForSourceStmt = this.db.prepare(` DELETE FROM epg_channels WHERE source_url = ? @@ -48,21 +44,28 @@ export class EpgDatabase { WHERE epg_programs.channel_id = epg_channels.id ) `); + + this.deleteTodayAndFutureStmt = this.db.prepare(` + DELETE FROM epg_programs + WHERE source_url = ? + AND (start >= date('now') OR start < date('now', '-7 days')) + `); } /** - * Insert a batch of channels. When `clearFirst` is true, the existing rows - * for `sourceUrl` are deleted inside the same transaction as the insert so - * old data is preserved if the fetch/parse never produces any channels. + * Insert a batch of channels. When `clearTodayAndFuture` is true, the + * selective delete (today+future and older-than-7-days) runs inside the + * same transaction so a parse failure after the delete atomically rolls + * back both — no gap left in the schedule. */ insertChannels( channels: ParsedChannel[], sourceUrl: string, - clearFirst = false + clearTodayAndFuture = false ): void { const insertMany = this.db.transaction((channels: ParsedChannel[]) => { - if (clearFirst) { - this.deleteProgramsForSourceStmt.run(sourceUrl); + if (clearTodayAndFuture) { + this.deleteTodayAndFutureStmt.run(sourceUrl); this.deleteOrphanChannelsForSourceStmt.run(sourceUrl); this.knownChannelIds.clear(); } diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 54dbaeb90..9ba3d33e6 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -363,7 +363,8 @@ activation, and the details dialog behave identically to the timeline. no-EPG-anywhere states and while loading. Return-to-live is a playback control (`!isLivePlayback()`) and is independent of EPG state. - **State-driven affordances.** Blocks are coloured past / now / future, with a - red "now" playhead. Catch-up "Watch" appears on past blocks only when + red "now" playhead. Catch-up "Watch" appears on past blocks — and as a + start-over replay button on the currently-airing block — only when `archivePlaybackAvailable` (Xtream `tv_archive`, M3U `catchup-*`); Stalker is schedule-only (dimmed past + a notice, no false buttons). The "i" button opens the shared `app-epg-item-description` dialog with a state-aware action. diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 4572bc1cb..1f90dbc2b 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -111,3 +111,12 @@ playable. MPEG-TS is preferred before HLS when the provider allows it because some portals return a valid HLS manifest while the first media segment fails in Chromium/video.js. PWA fallback keeps the REST MPEG-TS URL when no Electron probe API is available. + +Catch-up is offered from the Xtream Live TV tab and from the unified +collection surfaces (per-playlist and global Favorites and Recent). The +`tv_archive` / `tv_archive_duration` columns are carried through the +favorites and recently-viewed DB projections and mapped onto +`UnifiedCollectionItem.tvArchive` / `tvArchiveDuration` so the shared live +tab can gate the timeline's archive window. `tv_archive_duration` is +interpreted as **days** everywhere, matching +`live-stream-layout.controlledArchiveDays` (issue #1138). diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 9c1de9540..6893ee6b6 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -443,7 +443,14 @@ export class StreamResolverService { return this.fetchStalkerShortEpg(playlist, channelId, size); } - private async getXtreamCredentials(playlistId: string) { + private async getXtreamCredentials( + playlistId: string + ): Promise<{ + serverUrl: string; + username: string; + password: string; + serverTimezone?: string; + } | null> { const playlist = (await this.getElectronPlaylist(playlistId)) ?? ((await firstValueFrom( @@ -458,9 +465,37 @@ export class StreamResolverService { serverUrl: playlist.serverUrl, username: playlist.username, password: playlist.password, + serverTimezone: playlist.serverTimezone, }; } + /** + * Resolve an Xtream archive/catch-up URL for a given programme. + * Returns the timeshift playback URL, or null when credentials are + * missing or the provider doesn't support it. + */ + async resolveXtreamCatchupUrl( + item: UnifiedCollectionItem, + startTimestamp: number, + stopTimestamp: number + ): Promise { + const creds = await this.getXtreamCredentials(item.playlistId); + if (!creds || !item.xtreamId) return null; + + try { + return await this.xtreamUrl.resolveCatchupUrl( + item.playlistId, + creds, + item.xtreamId, + startTimestamp, + stopTimestamp, + creds.serverTimezone + ); + } catch { + return null; + } + } + private async loadM3uEpg( item: UnifiedCollectionItem, epgMap: Map, diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index 744f9c8e9..c3d9d1c36 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -209,6 +209,8 @@ describe('UnifiedFavoritesDataService', () => { title: 'Live One', type: 'live', poster_url: 'live.png', + tv_archive: 1, + tv_archive_duration: 7, added_at: '2026-03-26T09:00:00.000Z', position: 0, }, @@ -247,6 +249,10 @@ describe('UnifiedFavoritesDataService', () => { contentType: 'live', logo: 'live.png', posterUrl: null, + // Regression for issue #1138: archive metadata must + // survive the global favorites mapping for catch-up. + tvArchive: 1, + tvArchiveDuration: 7, }), expect.objectContaining({ name: 'Movie One', diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index 4dbf97134..cdac59396 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -609,6 +609,8 @@ export class UnifiedFavoritesDataService { categoryId: row.category_id, tvgId: ct === 'live' ? String(row.xtream_id) : undefined, rating: row.rating ?? undefined, + tvArchive: row.tv_archive ?? null, + tvArchiveDuration: row.tv_archive_duration ?? null, addedAt: normalizeStalkerDate(row.added_at) || new Date(0).toISOString(), position: row.position ?? 0, @@ -635,6 +637,8 @@ export class UnifiedFavoritesDataService { categoryId: item.category_id, tvgId: ct === 'live' ? String(item.xtream_id) : undefined, rating: item.rating ?? undefined, + tvArchive: item.tv_archive ?? null, + tvArchiveDuration: item.tv_archive_duration ?? null, addedAt: normalizeStalkerDate(item.added_at ?? item.added) || new Date(0).toISOString(), diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 72037c787..6cf29a142 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -355,6 +355,8 @@ describe('UnifiedRecentDataService', () => { poster_url: 'https://example.com/live.png', xtream_id: 290, type: 'live', + tv_archive: 1, + tv_archive_duration: 3, viewed_at: '2026-04-21T20:42:27.000Z', }, { @@ -380,6 +382,10 @@ describe('UnifiedRecentDataService', () => { uid: 'xtream::xtream-1::live:290', contentType: 'live', contentId: 3867578, + // Regression for issue #1138: archive metadata must + // survive the recent mapping for catch-up. + tvArchive: 1, + tvArchiveDuration: 3, }), expect.objectContaining({ uid: 'xtream::xtream-1::series:290', @@ -697,10 +703,25 @@ describe('UnifiedRecentDataService', () => { playlist_name: 'Xtream One', viewed_at: '2026-04-21 22:49:02', }, + { + id: 3867578, + category_id: 11, + title: 'Live With Archive', + poster_url: 'https://example.com/live.png', + xtream_id: 290, + type: 'live', + tv_archive: 1, + tv_archive_duration: 3, + playlist_id: 'xtream-1', + playlist_name: 'Xtream One', + viewed_at: '2026-04-21 20:42:27', + }, ]); const items = await service.getRecentItems('all'); - const xtreamItem = items.find((item) => item.sourceType === 'xtream'); + const xtreamItem = items.find( + (item) => item.name === 'Unter Nachbarn - 2011' + ); expect(xtreamItem).toEqual( expect.objectContaining({ @@ -708,5 +729,16 @@ describe('UnifiedRecentDataService', () => { viewedAt: '2026-04-21T22:49:02.000Z', }) ); + // Regression for issue #1138: archive metadata must survive the + // global recent mapping for catch-up. + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Live With Archive', + tvArchive: 1, + tvArchiveDuration: 3, + }), + ]) + ); }); }); diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index 3da7dedd2..3f75a3094 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -389,6 +389,8 @@ export class UnifiedRecentDataService { xtreamId: row.xtream_id, categoryId: row.category_id, tvgId: row.type === 'live' ? String(row.xtream_id) : undefined, + tvArchive: row.tv_archive ?? null, + tvArchiveDuration: row.tv_archive_duration ?? null, contentId: row.id, viewedAt: normalizeStalkerDate(row.viewed_at), })); @@ -428,6 +430,8 @@ export class UnifiedRecentDataService { xtreamId: row.xtream_id, categoryId: row.category_id, tvgId: row.type === 'live' ? String(row.xtream_id) : undefined, + tvArchive: row.tv_archive ?? null, + tvArchiveDuration: row.tv_archive_duration ?? null, contentId: row.id, viewedAt: normalizeStalkerDate(row.viewed_at), })); @@ -485,6 +489,8 @@ export class UnifiedRecentDataService { xtreamId: item.xtream_id, categoryId: item.category_id, tvgId: contentType === 'live' ? String(item.xtream_id) : undefined, + tvArchive: item.tv_archive ?? null, + tvArchiveDuration: item.tv_archive_duration ?? null, contentId: item.id, viewedAt: normalizeStalkerDate(item.viewed_at), }; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts index 0d255a4a1..ff0eef7e2 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts @@ -94,6 +94,22 @@ function findCurrentPortalProgram( ); } +/** + * Convert program start/stop to epoch seconds, preferring the + * numeric timestamp (which is provider-native) over the ISO string. + */ +export function toEpochSeconds( + timestamp: number | null | undefined, + fallbackIso: string +): number | null { + if (timestamp != null && Number.isFinite(timestamp)) { + return timestamp; + } + + const ms = Date.parse(fallbackIso); + return Number.isFinite(ms) ? Math.floor(ms / 1000) : null; +} + function getProgramTimeMs( rawDate: string | null | undefined, rawTimestamp?: number | string | null diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index ba0b2cd10..79bbf9876 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -140,6 +140,7 @@ describe('UnifiedLiveTabComponent', () => { let streamResolver: { resolveLiveDetail: jest.Mock; resolveM3uPlaybackDetail: jest.Mock; + resolveXtreamCatchupUrl: jest.Mock; loadM3uProgramsForItem: jest.Mock; loadEpgForItems: jest.Mock; }; @@ -164,6 +165,7 @@ describe('UnifiedLiveTabComponent', () => { streamResolver = { resolveLiveDetail: jest.fn(), resolveM3uPlaybackDetail: jest.fn(), + resolveXtreamCatchupUrl: jest.fn().mockResolvedValue(null), loadM3uProgramsForItem: jest.fn().mockResolvedValue([]), loadEpgForItems: jest.fn().mockResolvedValue(new Map()), }; @@ -1065,6 +1067,108 @@ describe('UnifiedLiveTabComponent', () => { }); }); + describe('Xtream (portal) catch-up (timeshift) playback', () => { + const selectXtreamArchiveChannel = async ( + archive: Partial = { + tvArchive: 1, + tvArchiveDuration: 5, + } + ) => { + const item = { ...buildLiveItem('xtream'), ...archive }; + streamResolver.resolveLiveDetail.mockResolvedValue({ + epgMode: 'portal', + playback: { + streamUrl: 'https://example.com/xtream.m3u8', + title: 'Xtream Live', + }, + epgItems: [buildEpgItem('Xtream Show')], + }); + recentData.recordLivePlayback.mockResolvedValue(item); + + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + + await component.onChannelSelected(component.channelsForList()[0]); + fixture.detectChanges(); + await fixture.whenStable(); + + return item; + }; + + const timeshiftEvent = (): EpgProgramActivationEvent => ({ + program: buildProgram('Xtream Show'), + type: 'timeshift', + }); + + const queryTimeline = () => + fixture.debugElement.query(By.directive(StubEpgTimelineComponent)) + .componentInstance as StubEpgTimelineComponent; + + it('exposes the provider archive window to the timeline in days', async () => { + // Regression: tv_archive_duration is days (matching + // live-stream-layout.controlledArchiveDays), not hours — a + // 5-day window must not collapse to ceil(5 / 24) = 1 day. + await selectXtreamArchiveChannel({ + tvArchive: 1, + tvArchiveDuration: 5, + }); + + const timeline = queryTimeline(); + expect(timeline.archivePlaybackAvailable()).toBe(true); + expect(timeline.archiveDays()).toBe(5); + }); + + it('keeps the archive gate closed when the provider has no archive', async () => { + await selectXtreamArchiveChannel({ + tvArchive: 0, + tvArchiveDuration: 0, + }); + + const timeline = queryTimeline(); + expect(timeline.archivePlaybackAvailable()).toBe(false); + expect(timeline.archiveDays()).toBe(0); + }); + + it('resolves the catch-up stream with epoch seconds and switches inline playback', async () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + streamResolver.resolveXtreamCatchupUrl.mockResolvedValue( + 'https://example.com/timeshift.m3u8' + ); + const item = await selectXtreamArchiveChannel(); + + component.onTimelineProgramActivated(timeshiftEvent()); + await fixture.whenStable(); + fixture.detectChanges(); + + const program = timeshiftEvent().program; + expect(streamResolver.resolveXtreamCatchupUrl).toHaveBeenCalledWith( + expect.objectContaining({ xtreamId: item.xtreamId }), + Math.floor(Date.parse(program.start) / 1000), + Math.floor(Date.parse(program.stop) / 1000) + ); + expect(component.inlinePlayback()?.streamUrl).toBe( + 'https://example.com/timeshift.m3u8' + ); + expect(component.inlinePlayback()?.isLive).toBe(false); + expect(component.activeTimeshiftProgram()?.title).toBe( + 'Xtream Show' + ); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('shows feedback instead of failing silently when the provider rejects catch-up', async () => { + streamResolver.resolveXtreamCatchupUrl.mockResolvedValue(null); + await selectXtreamArchiveChannel(); + + component.onTimelineProgramActivated(timeshiftEvent()); + await fixture.whenStable(); + + expect(component.activeTimeshift()).toBeNull(); + expect(snackBar.open).toHaveBeenCalledTimes(1); + }); + }); + describe('timeline channel name', () => { it('strips the country prefix when the setting is enabled', () => { stripCountryPrefix.set(true); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 314f3b25c..1d7ea6e35 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -48,6 +48,7 @@ import { import { getLiveEpgPanelSummary, toEpgProgram, + toEpochSeconds, toLiveEpgPanelSummary, } from './unified-live-epg-summary.util'; import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component'; @@ -114,6 +115,10 @@ export class UnifiedLiveTabComponent { readonly activeDetail = signal(null); readonly activeUid = signal(null); + /** Full collection item for the active selection — used to read + * portal archive fields (tvArchive/tvArchiveDuration) and to + * supply credentials for Xtream catch-up URL resolution. */ + readonly activeItem = signal(null); readonly isSelecting = signal(false); readonly epgMap = signal>(new Map()); readonly progressTick = signal(0); @@ -197,22 +202,41 @@ export class UnifiedLiveTabComponent { this.activeDetail()?.playback?.thumbnail ?? '' ); - readonly timelineArchiveAvailable = computed( - () => - this.isM3uSelection() && this.currentM3uArchivePlaybackAvailable() - ); + readonly timelineArchiveAvailable = computed(() => { + if (this.isM3uSelection()) { + return this.currentM3uArchivePlaybackAvailable(); + } + + // Portal archive: tvArchive === 1 means the provider has + // timeshift / archive enabled for this channel. + const item = this.activeItem(); + return ( + Number(item?.tvArchive ?? 0) === 1 && + Number(item?.tvArchiveDuration ?? 0) > 0 + ); + }); /** - * Catch-up window (days) for the active M3U channel, so the timeline can + * Catch-up window (days) for the active channel, so the timeline can * gate "Watch" to programmes inside it. Without this the timeline defaults * `archiveDays` to 0 (treated as unlimited) and offers catch-up on - * programmes older than the channel's real `catchup-days`/`timeshift`/ - * `tvg-rec` window. 0 for portal selections (archive is M3U-only here). + * programmes older than the real archive window. + * + * M3U: reads catchup-days / timeshift / tvg-rec from the channel attrs. + * Portal: tvArchiveDuration is already in days — pass it through, + * matching `live-stream-layout.controlledArchiveDays`. */ - readonly timelineArchiveDays = computed(() => - this.timelineArchiveAvailable() - ? getM3uArchiveDays(this.currentM3uChannel()) - : 0 - ); + readonly timelineArchiveDays = computed(() => { + if (!this.timelineArchiveAvailable()) return 0; + + if (this.isM3uSelection()) { + return getM3uArchiveDays(this.currentM3uChannel()); + } + + return Math.max( + 0, + Number(this.activeItem()?.tvArchiveDuration ?? 0) || 0 + ); + }); readonly activeRadioChannel = computed(() => { const channel = this.activeDetail()?.channel ?? null; return channel?.radio === 'true' ? channel : null; @@ -389,9 +413,20 @@ export class UnifiedLiveTabComponent { return; } + if (this.isM3uSelection()) { + this.activateM3uCatchup(event.program); + } else { + void this.activatePortalCatchup(event.program); + } + } + + /** + * M3U catch-up: resolve via the M3U timeshift URL resolver. + */ + private activateM3uCatchup(program: EpgProgram): void { const playbackUrl = resolveM3uCatchupUrl( this.currentM3uChannel(), - event.program + program ); if (!playbackUrl) { this.snackBar.open( @@ -402,10 +437,70 @@ export class UnifiedLiveTabComponent { return; } - this.activeTimeshift.set({ url: playbackUrl, program: event.program }); + this.activeTimeshift.set({ url: playbackUrl, program }); + + const playback = this.activeDetail()?.playback; + if (!this.shouldUseInlinePlayer() && playback) { + void this.portalPlayer.openResolvedPlayback({ + ...playback, + streamUrl: playbackUrl, + isLive: false, + }); + } + } + + /** + * Portal (Xtream) catch-up: compute start/stop as epoch seconds and + * resolve via the provider's timeshift endpoint. + */ + private async activatePortalCatchup(program: EpgProgram): Promise { + const requestId = this.selectionRequestId; + const item = this.activeItem(); + if (!item?.xtreamId) { + this.snackBar.open( + this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), + undefined, + { duration: 4000 } + ); + return; + } + + const startEpoch = toEpochSeconds( + program.startTimestamp, + program.start + ); + const stopEpoch = toEpochSeconds( + program.stopTimestamp, + program.stop + ); + if (startEpoch == null || stopEpoch == null) { + this.snackBar.open( + this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), + undefined, + { duration: 4000 } + ); + return; + } + + const playbackUrl = await this.streamResolver.resolveXtreamCatchupUrl( + item, + startEpoch, + stopEpoch + ); + if (requestId !== this.selectionRequestId) { + return; // switched channel — discard silently + } + if (!playbackUrl) { + this.snackBar.open( + this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), + undefined, + { duration: 4000 } + ); + return; + } + + this.activeTimeshift.set({ url: playbackUrl, program }); - // No inline player (external MPV/VLC configured) → hand the archive - // stream to the external player, mirroring the live-selection flow. const playback = this.activeDetail()?.playback; if (!this.shouldUseInlinePlayer() && playback) { void this.portalPlayer.openResolvedPlayback({ @@ -441,6 +536,7 @@ export class UnifiedLiveTabComponent { this.isSelecting.set(false); this.activeDetail.set(null); this.activeUid.set(null); + this.activeItem.set(null); this.activeTimeshift.set(null); } @@ -472,6 +568,7 @@ export class UnifiedLiveTabComponent { const requestId = ++this.selectionRequestId; this.activeUid.set(item.uid); + this.activeItem.set(item); this.activeDetail.set(null); this.activeTimeshift.set(null); this.isSelecting.set(true); diff --git a/libs/portal/shared/util/src/lib/collection/collection-helpers.ts b/libs/portal/shared/util/src/lib/collection/collection-helpers.ts index 222f12ab8..60cf6da0e 100644 --- a/libs/portal/shared/util/src/lib/collection/collection-helpers.ts +++ b/libs/portal/shared/util/src/lib/collection/collection-helpers.ts @@ -11,6 +11,8 @@ export interface XtreamFavoriteRow { readonly type: string; readonly poster_url?: string | null; readonly rating?: string | null; + readonly tv_archive?: number | null; + readonly tv_archive_duration?: number | null; readonly added_at?: string | null; readonly position?: number | null; } diff --git a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts index 140612abe..a9656c395 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts @@ -56,6 +56,12 @@ export interface UnifiedCollectionItem { /** Xtream DB content id — used for reorder / remove IPC */ contentId?: number; + /** Whether the Xtream provider has timeshift / archive enabled for this stream */ + tvArchive?: number | null; + /** Timeshift / archive window in days — passed through as-is, matching + * `live-stream-layout.controlledArchiveDays`. */ + tvArchiveDuration?: number | null; + /** Stalker cmd for stream resolution */ stalkerId?: string | number; stalkerCmd?: string; diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index 5453567e3..2721ce84e 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -54,6 +54,8 @@ export interface Playlist { recentlyViewed?: PlaylistRecentlyViewedItem[]; /** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */ isFullStalkerPortal?: boolean; + /** Xtream server timezone string for catch-up URL construction */ + serverTimezone?: string; /** Session token for full stalker portal authentication - persisted for session */ stalkerToken?: string; /** Serial number for stalker portal - generated once and stored for consistency */ diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts index 08fd1e6d9..badef32e9 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts @@ -64,10 +64,19 @@ describe('epg-archive.util', () => { ).toBe(false); }); - it('denies when the programme is not in the past', () => { + it('allows start-over on the currently-airing programme', () => { expect( canCatchUpProgramme('now', inWindowStart, true, 7, NOW) + ).toBe(true); + }); + + it('denies start-over when archive playback is unavailable', () => { + expect( + canCatchUpProgramme('now', inWindowStart, false, 7, NOW) ).toBe(false); + }); + + it('denies future programmes', () => { expect( canCatchUpProgramme('future', NOW + 60_000, true, 7, NOW) ).toBe(false); @@ -91,8 +100,8 @@ describe('epg-archive.util', () => { expect(epgDialogActionFor('now', false)).toBe('live'); }); - it('prefers live over timeshift for an on-air programme', () => { - expect(epgDialogActionFor('now', true)).toBe('live'); + it('prefers start-over timeshift for a catch-up-able on-air programme', () => { + expect(epgDialogActionFor('now', true)).toBe('timeshift'); }); it('maps a catch-up-able past programme to timeshift', () => { diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts index a529ce6d2..b7d491b20 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts @@ -21,7 +21,8 @@ export function isWithinArchiveWindow( return startMs >= nowMs - archiveDays * DAY_MS; } -/** Whether a past programme is playable from the catch-up archive. */ +/** Whether a programme is playable from the catch-up archive — + * both past programmes and the currently-airing programme (start-over). */ export function canCatchUpProgramme( when: TimelineWhen, startMs: number, @@ -31,7 +32,7 @@ export function canCatchUpProgramme( ): boolean { return ( archivePlaybackAvailable && - when === 'past' && + (when === 'past' || when === 'now') && isWithinArchiveWindow(startMs, archiveDays, nowMs) ); } @@ -41,7 +42,7 @@ export function epgDialogActionFor( when: TimelineWhen, canCatchUp: boolean ): EpgItemDialogAction | null { - if (when === 'now') { + if (when === 'now' && !canCatchUp) { return 'live'; } if (canCatchUp) { diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts index 1c730a98a..a7afd196b 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts @@ -109,7 +109,7 @@ function toRenderBlock( nowFillPercent: nowFillFor(block, nowMs), canCatchUp: archivePlaybackAvailable && - block.when === 'past' && + (block.when === 'past' || block.when === 'now') && block.startMs >= archiveWindowStartMs, }; } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html index ce0709cc6..30e604276 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html @@ -80,6 +80,15 @@ {{ 'EPG.TIMELINE.ON_NOW' | translate }} + @if (item.canCatchUp) { + + } } @else if (isPlaying(item)) { {{ 'EPG.TIMELINE.FROM_ARCHIVE' | translate diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss index 801603a49..57cb96ef6 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss @@ -167,6 +167,9 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); position: relative; z-index: 1; flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; } .epg-timeline__block-time { diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts index 9ecc95204..1c7471de2 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts @@ -316,15 +316,15 @@ export class EpgTimelineComponent { onBlockClick(block: TimelineBlock): void { this.selectedKey.set(block.key); - if (block.when === 'now') { - this.returnToLive.emit(); - return; - } if (this.canCatchUp(block)) { this.programActivated.emit({ program: block.program, type: 'timeshift', }); + return; + } + if (block.when === 'now') { + this.returnToLive.emit(); } } From b719ed23cd43d6d48f59318f8c3ce0177ffee3eb Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:48:44 +0200 Subject: [PATCH 04/26] fix(playback): position embedded MPV native view correctly on scaled displays (#1206) * fix(playback): position embedded MPV native view correctly on scaled displays Renderer bounds are measured in CSS pixels, but the native-view engines position OS windows: SetWindowPos (win32) and XMoveResizeWindow (linux) expect physical pixels, NSView setFrame (macOS) expects points. The raw values landed the video toward the window's top-left corner at 1/scale of its size on any display scale or page zoom other than 100%, windowed and fullscreen alike. The main process now converts native-view bounds (x page zoom everywhere, x display scale factor on win32/linux) with edge-based rounding; frame-copy bounds stay unscaled because the adapter owns its render scale. The session controller re-syncs bounds when devicePixelRatio changes, covering moves to a display with a different scale that keep the CSS layout identical. Closes #1145 Co-Authored-By: Claude Fable 5 * fix(playback): keep CSS bounds unrounded until native scaling Review feedback on #1206: measureBounds() rounded the CSS edges in the renderer, before the main-process CSS-to-native conversion, so fractional layout positions could drift by a pixel per scale factor (a 10.49px edge at 200% must land on 21 physical px, not 20). The renderer now sends raw getBoundingClientRect() edges and rounding happens exactly once, after scaling. Also pins process.platform explicitly in the macOS wiring test instead of relying on the suite default. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CLAUDE.md | 2 +- .../services/embedded-mpv-bounds.util.spec.ts | 133 +++++++++++ .../app/services/embedded-mpv-bounds.util.ts | 60 +++++ .../embedded-mpv-native.service.spec.ts | 105 ++++++++- .../services/embedded-mpv-native.service.ts | 50 +++- docs/architecture/embedded-mpv-native.md | 38 +++- .../embedded-mpv-format.utils.spec.ts | 9 +- .../embedded-mpv-format.utils.ts | 15 +- ...mbedded-mpv-session-controller.dpr.spec.ts | 213 ++++++++++++++++++ .../embedded-mpv-session-controller.spec.ts | 4 +- .../embedded-mpv-session-controller.ts | 27 +++ 11 files changed, 637 insertions(+), 19 deletions(-) create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-bounds.util.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-bounds.util.ts create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 42cd4b7fe..16fc9a0e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -616,7 +616,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer - External players: MPV, VLC (via IPC to Electron backend) -- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`. +- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`. - Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or diff --git a/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.spec.ts new file mode 100644 index 000000000..03d3ee95a --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.spec.ts @@ -0,0 +1,133 @@ +import { EmbeddedMpvBounds } from '@iptvnator/shared/interfaces'; +import { + NativeViewBoundsContext, + toNativeViewBounds, +} from './embedded-mpv-bounds.util'; + +const CSS_BOUNDS: EmbeddedMpvBounds = { x: 372, y: 60, width: 578, height: 330 }; + +function context( + overrides: Partial = {} +): NativeViewBoundsContext { + return { + platform: 'linux', + zoomFactor: 1, + displayScaleFactor: 1, + ...overrides, + }; +} + +describe('toNativeViewBounds', () => { + it('returns the input untouched at 100% zoom and 100% display scale', () => { + const result = toNativeViewBounds(CSS_BOUNDS, context()); + + expect(result).toBe(CSS_BOUNDS); + }); + + // Regression for #1145: CSS bounds were handed to XMoveResizeWindow as-is, + // so on a 140%-scaled Linux Mint desktop the mpv window landed at ~71% of + // the expected position and size, toward the window's top-left corner. + it('scales linux bounds by the display scale factor', () => { + const result = toNativeViewBounds( + CSS_BOUNDS, + context({ platform: 'linux', displayScaleFactor: 1.4 }) + ); + + expect(result).toEqual({ x: 521, y: 84, width: 809, height: 462 }); + }); + + it('scales win32 bounds by the display scale factor', () => { + const result = toNativeViewBounds( + { x: 100, y: 50, width: 640, height: 360 }, + context({ platform: 'win32', displayScaleFactor: 1.25 }) + ); + + expect(result).toEqual({ x: 125, y: 63, width: 800, height: 450 }); + }); + + it('combines page zoom with the display scale factor', () => { + const result = toNativeViewBounds( + { x: 100, y: 50, width: 640, height: 360 }, + context({ + platform: 'win32', + zoomFactor: 1.2, + displayScaleFactor: 1.5, + }) + ); + + expect(result).toEqual({ x: 180, y: 90, width: 1152, height: 648 }); + }); + + it('ignores the display scale on macOS (NSView frames are in points)', () => { + const result = toNativeViewBounds( + CSS_BOUNDS, + context({ platform: 'darwin', displayScaleFactor: 2 }) + ); + + expect(result).toBe(CSS_BOUNDS); + }); + + it('applies page zoom on macOS', () => { + const result = toNativeViewBounds( + { x: 100, y: 50, width: 640, height: 360 }, + context({ + platform: 'darwin', + zoomFactor: 1.5, + displayScaleFactor: 2, + }) + ); + + expect(result).toEqual({ x: 150, y: 75, width: 960, height: 540 }); + }); + + it('rounds fractional CSS edges only after scaling', () => { + // A 10.49px CSS edge at 200% renders at 21 physical pixels; edges + // rounded before scaling would send 20 and shift the video by 1px. + const result = toNativeViewBounds( + { x: 10.49, y: 0.5, width: 100.02, height: 50 }, + context({ platform: 'win32', displayScaleFactor: 2 }) + ); + + expect(result).toEqual({ x: 21, y: 1, width: 200, height: 100 }); + }); + + it('keeps vertically adjacent rects seamless under fractional scales', () => { + // 42 × 1.25 and 153 × 1.25 both land on .5/.25 fractions: rounding + // x/y/width/height independently would misplace the shared edge by + // 1px, while edge-based rounding keeps the rects flush. + const scale = context({ displayScaleFactor: 1.25 }); + const upper = toNativeViewBounds( + { x: 0, y: 42, width: 500, height: 111 }, + scale + ); + const lower = toNativeViewBounds( + { x: 0, y: 153, width: 500, height: 90 }, + scale + ); + + expect(upper.y + upper.height).toBe(lower.y); + }); + + it('keeps hidden bounds offscreen and at least 1x1', () => { + const result = toNativeViewBounds( + { x: -100000, y: -100000, width: 1, height: 1 }, + context({ displayScaleFactor: 1.5 }) + ); + + expect(result.x).toBeLessThanOrEqual(-100000); + expect(result.y).toBeLessThanOrEqual(-100000); + expect(result.width).toBeGreaterThanOrEqual(1); + expect(result.height).toBeGreaterThanOrEqual(1); + }); + + it('treats non-finite or non-positive factors as 100%', () => { + for (const zoomFactor of [Number.NaN, 0, -1, Number.POSITIVE_INFINITY]) { + expect( + toNativeViewBounds( + CSS_BOUNDS, + context({ zoomFactor, displayScaleFactor: zoomFactor }) + ) + ).toBe(CSS_BOUNDS); + } + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.ts b/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.ts new file mode 100644 index 000000000..7c7015dd1 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-bounds.util.ts @@ -0,0 +1,60 @@ +import { EmbeddedMpvBounds } from '@iptvnator/shared/interfaces'; + +export interface NativeViewBoundsContext { + platform: NodeJS.Platform; + /** Page zoom factor of the main window's webContents (1 = 100%). */ + zoomFactor: number; + /** Scale factor of the display hosting the main window (1 = 96 dpi). */ + displayScaleFactor: number; +} + +/** + * Converts renderer-measured bounds (CSS pixels from + * getBoundingClientRect()) into the coordinate space the native-view + * engines position their OS windows in: physical pixels for the win32 + * child HWND (SetWindowPos) and the linux child X11 window + * (XMoveResizeWindow), points — device-independent pixels — for the macOS + * NSView (setFrame). CSS pixels match points only at 100% page zoom and + * match physical pixels only at 100% page zoom AND 100% display scale, so + * every platform scales by the zoom factor and win32/linux additionally by + * the display scale factor (#1145). + * + * Bounds arrive with unrounded CSS edges and are rounded exactly once here, + * after scaling: edges first, then width/height derived from them. Rounding + * any earlier (or per-field) lets fractional CSS layouts drift by a pixel + * per scale factor and open 1px seams between the native video window and + * the surrounding DOM UI. + */ +export function toNativeViewBounds( + bounds: EmbeddedMpvBounds, + context: NativeViewBoundsContext +): EmbeddedMpvBounds { + const scale = resolveNativeViewScale(context); + if (scale === 1) { + return bounds; + } + + const left = Math.round(bounds.x * scale); + const top = Math.round(bounds.y * scale); + const right = Math.round((bounds.x + bounds.width) * scale); + const bottom = Math.round((bounds.y + bounds.height) * scale); + + return { + x: left, + y: top, + width: Math.max(1, right - left), + height: Math.max(1, bottom - top), + }; +} + +function resolveNativeViewScale(context: NativeViewBoundsContext): number { + const zoomFactor = sanitizeFactor(context.zoomFactor); + if (context.platform === 'darwin') { + return zoomFactor; + } + return zoomFactor * sanitizeFactor(context.displayScaleFactor); +} + +function sanitizeFactor(value: number): number { + return Number.isFinite(value) && value > 0 ? value : 1; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 1f85402b2..0568a9f0c 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -41,20 +41,29 @@ const appMock = { commandLine: commandLineMock, }; +const screenGetDisplayMatchingMock = jest.fn(); + jest.mock('electron', () => ({ app: appMock, powerSaveBlocker: powerSaveBlockerMock, + screen: { getDisplayMatching: screenGetDisplayMatchingMock }, })); const mainWindowSendMock = jest.fn(); const mainWindowWebContentsOnMock = jest.fn(); +const mainWindowGetZoomFactorMock = jest.fn(); const mainWindowGetNativeWindowHandleMock = jest.fn(() => Buffer.alloc(8) ); const mainWindowMock = { isDestroyed: () => false, getNativeWindowHandle: mainWindowGetNativeWindowHandleMock, - webContents: { send: mainWindowSendMock, on: mainWindowWebContentsOnMock }, + getBounds: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + webContents: { + send: mainWindowSendMock, + on: mainWindowWebContentsOnMock, + getZoomFactor: mainWindowGetZoomFactorMock, + }, }; jest.mock('../app', () => ({ @@ -155,6 +164,10 @@ describe('EmbeddedMpvNativeService power blocker', () => { mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8)); mainWindowSendMock.mockReset(); mainWindowWebContentsOnMock.mockReset(); + mainWindowGetZoomFactorMock.mockReset(); + mainWindowGetZoomFactorMock.mockReturnValue(1); + screenGetDisplayMatchingMock.mockReset(); + screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1 }); appMock.isPackaged = true; tempDirs = []; @@ -440,6 +453,96 @@ describe('EmbeddedMpvNativeService power blocker', () => { }); }); + describe('native view bounds scaling', () => { + afterEach(() => { + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY; + }); + + it('converts CSS bounds to physical pixels for the native engine on scaled displays', () => { + // Regression for #1145: the win32/linux engines position their + // child window in physical pixels, so renderer CSS bounds must + // be multiplied by the display scale before reaching the addon. + Object.defineProperty(process, 'platform', { value: 'linux' }); + screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1.5 }); + addon.createSession.mockReturnValueOnce('s-scaled'); + addon.getSessionSnapshot.mockReturnValue(snapshot('loading')); + + const cssBounds = { x: 100, y: 40, width: 640, height: 360 }; + service.createSession(cssBounds, '', 1); + service.setBounds('s-scaled', cssBounds); + + const physicalBounds = { x: 150, y: 60, width: 960, height: 540 }; + expect(addon.createSession).toHaveBeenCalledWith( + expect.any(Buffer), + physicalBounds, + '', + 1 + ); + expect(addon.setBounds).toHaveBeenCalledWith( + 's-scaled', + physicalBounds + ); + }); + + it('applies page zoom but not the display scale on macOS', () => { + // NSView frames are in points (device-independent pixels): only + // the webContents zoom factor separates them from CSS pixels. + Object.defineProperty(process, 'platform', { value: 'darwin' }); + screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 2 }); + mainWindowGetZoomFactorMock.mockReturnValue(1.25); + addon.createSession.mockReturnValueOnce('s-zoom'); + addon.getSessionSnapshot.mockReturnValue(snapshot('loading')); + + service.createSession({ x: 0, y: 0, width: 100, height: 100 }, '', 1); + + expect(addon.createSession).toHaveBeenCalledWith( + expect.any(Buffer), + { x: 0, y: 0, width: 125, height: 125 }, + '', + 1 + ); + }); + + it('passes frame-copy bounds through unscaled', () => { + // The frame-copy engine paints into a DOM canvas laid out in CSS + // pixels; its adapter applies the display scale to the render + // size itself, so a second scaling pass here would double it. + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + mockIsFrameCopyRuntimeUsable.mockReturnValue(true); + mockGetFrameCopyRuntimeAvailability.mockReturnValue({ + usable: true, + }); + screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1.5 }); + const frameCopyAddon = createMockAddon(); + frameCopyAddon.createSession.mockReturnValueOnce('s-fc-bounds'); + frameCopyAddon.getSessionSnapshot.mockReturnValue( + snapshot('loading') + ); + ( + service as unknown as { frameCopyAdapter: MockAddon } + ).frameCopyAdapter = frameCopyAddon; + + service.createSession(BOUNDS, '', 1); + service.setBounds('s-fc-bounds', BOUNDS); + + expect(frameCopyAddon.createSession).toHaveBeenCalledWith( + Buffer.alloc(0), + BOUNDS, + '', + 1 + ); + expect(frameCopyAddon.setBounds).toHaveBeenCalledWith( + 's-fc-bounds', + BOUNDS + ); + + // Dispose while the frame-copy env is still set so teardown + // dispatches to the adapter that owns the session. + service.disposeSession('s-fc-bounds'); + }); + }); + it('does not acquire a blocker for a loading session', () => { startSession('s1', snapshot('loading')); expect(powerSaveBlockerMock.start).not.toHaveBeenCalled(); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index d30ad596f..8142fb66b 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -27,6 +27,7 @@ import { EMBEDDED_MPV_SESSION_UPDATE, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; +import { toNativeViewBounds } from './embedded-mpv-bounds.util'; import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; import { getFrameCopyRuntimeAvailability, @@ -201,6 +202,36 @@ export class EmbeddedMpvNativeService { } } + private getMainWindowZoomFactor(): number { + try { + if (!App.mainWindow || App.mainWindow.isDestroyed()) { + return 1; + } + return App.mainWindow.webContents.getZoomFactor(); + } catch { + return 1; + } + } + + /** + * Renderer bounds arrive in CSS pixels; the native-view engines position + * OS windows in physical pixels (win32/linux) or points (macOS), so at + * page zoom or display scale ≠ 100% the raw values land the video toward + * the window's top-left corner at a fraction of its size (#1145). The + * frame-copy engine must bypass this: it paints into a DOM canvas laid + * out in CSS pixels, and its adapter already applies the display scale + * to the render size itself. + */ + private scaleBoundsForNativeView( + bounds: EmbeddedMpvBounds + ): EmbeddedMpvBounds { + return toNativeViewBounds(bounds, { + platform: process.platform, + zoomFactor: this.getMainWindowZoomFactor(), + displayScaleFactor: this.getMainWindowScaleFactor(), + }); + } + private detectCapabilities(): EmbeddedMpvCapabilities { if (this.isFrameCopyEngineActive()) { return { @@ -419,14 +450,15 @@ export class EmbeddedMpvNativeService { // embed into the window at all. Derive the skip from the dispatched // addon rather than re-evaluating the engine gate, so the two // decisions cannot disagree. - const windowHandle = - this.frameCopyAdapter && addon === this.frameCopyAdapter - ? Buffer.alloc(0) - : this.getMainWindowHandle(); + const usesFrameCopyAddon = + this.frameCopyAdapter !== null && addon === this.frameCopyAdapter; + const windowHandle = usesFrameCopyAddon + ? Buffer.alloc(0) + : this.getMainWindowHandle(); const startedAt = new Date().toISOString(); const sessionId = addon.createSession( windowHandle, - bounds, + usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds), title, initialVolume ); @@ -478,7 +510,13 @@ export class EmbeddedMpvNativeService { setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void { this.assertEmbeddedMpvEnabled(); - this.getAddon().setBounds(sessionId, bounds); + const addon = this.getAddon(); + const usesFrameCopyAddon = + this.frameCopyAdapter !== null && addon === this.frameCopyAdapter; + addon.setBounds( + sessionId, + usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds) + ); } setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null { diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 19feed60d..458368467 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -150,9 +150,10 @@ The flow is: native-view, it starts `mpv --wid=` in a separate process with a private JSON IPC socket. Frame-copy instead uses the per-session helper described below. -9. Resize, scroll, and fullscreen changes are measured in Angular and sent - through bounds sync. Native-view uses them to align the platform host; - frame-copy uses them to resize helper rendering and the canvas frame source. +9. Resize, scroll, fullscreen, and devicePixelRatio changes are measured in + Angular and sent through bounds sync. Native-view uses them to align the + platform host; frame-copy uses them to resize helper rendering and the + canvas frame source. 10. Playback controls remain IPTVnator-owned Angular UI. Frame-copy uses the shared `app-player-controls` overlay through `EmbeddedMpvControlsAdapter`; native-view keeps its compositor-safe fixed @@ -595,6 +596,37 @@ there is no `HIDDEN_BOUNDS`, popover cutout, or reserved dock height. Dialogs and controls layer naturally over the canvas, while bounds sync still updates the helper's render size. +### Coordinate spaces (CSS → native units) + +The renderer measures bounds in CSS pixels (`getBoundingClientRect()`), but +the native-view engines position OS windows, not DOM nodes: the win32 child +`HWND` (`SetWindowPos`) and the Linux child X11 window (`XMoveResizeWindow`) +live in physical pixels, and the macOS `NSView` (`setFrame`) lives in points +(device-independent pixels). CSS values match points only at 100% page zoom +and match physical pixels only at 100% page zoom AND 100% display scale. +`EmbeddedMpvNativeService` therefore converts every native-view bounds payload +in the main process (`toNativeViewBounds` in `embedded-mpv-bounds.util.ts`): +all platforms scale by the webContents zoom factor, win32/linux additionally +by the scale factor of the display hosting the window. The renderer sends +unrounded CSS edges (`measureBounds` does not round) and the conversion +rounds exactly once, after scaling — edges first, width/height derived from +them — so fractional CSS layouts and fractional scales cannot open 1px +seams against the surrounding DOM UI. Skipping this conversion is issue #1145: on scaled +displays (Windows 125%, Linux fractional scaling, HiDPI TVs) the video landed +toward the window's top-left corner at `1/scale` of its size, in windowed and +fullscreen mode alike. + +Frame-copy bounds bypass the conversion: the canvas is laid out by the DOM in +CSS pixels, and the frame-copy adapter already multiplies the render size by +the display scale factor itself. + +Because a monitor change can rescale this mapping without resizing the host +element (moving the window to a display with a different scale keeps the DIP +layout), the session controller also watches `devicePixelRatio` through a +re-armed `matchMedia('(resolution: …dppx)')` query and re-syncs bounds when +it changes; page zoom changes are covered by the same watch plus the ordinary +resize-driven syncs. + ### Controls ownership by engine `EmbeddedMpvPlayerComponent` selects one control owner from diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.spec.ts index 9b80e2dfb..0c250d405 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.spec.ts @@ -59,7 +59,10 @@ describe('embedded MPV format utilities', () => { expect(volumeLabel(0.755)).toBe('Volume 76%'); }); - it('rounds host bounds and keeps minimum native view dimensions', () => { + it('preserves fractional host edges and keeps minimum native view dimensions', () => { + // Rounding happens once in the main process, after CSS→native + // scaling — pre-rounded edges would drift by up to 1px per scale + // factor on scaled displays. const host = { getBoundingClientRect: () => ({ left: 10.4, @@ -70,8 +73,8 @@ describe('embedded MPV format utilities', () => { } as HTMLElement; expect(measureBounds(host)).toEqual({ - x: 10, - y: 21, + x: 10.4, + y: 20.6, width: 1, height: 1, }); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts index 6678c291b..2e8635b6d 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts @@ -120,12 +120,19 @@ export function persistVolume(value: number): void { localStorage.setItem('volume', String(value)); } +/** + * Measures the host element in CSS pixels without rounding. The main process + * converts these bounds to native units (page zoom × display scale) and + * rounds exactly once, after scaling — pre-rounding here would bake up to + * ±0.5px of CSS error that the scale factor then amplifies into visible + * off-by-one seams (e.g. a 10.49px edge at 200% renders at 21px, not 20px). + */ export function measureBounds(host: HTMLElement): EmbeddedMpvBounds { const rect = host.getBoundingClientRect(); return { - x: Math.round(rect.left), - y: Math.round(rect.top), - width: Math.max(1, Math.round(rect.width)), - height: Math.max(1, Math.round(rect.height)), + x: rect.left, + y: rect.top, + width: Math.max(1, rect.width), + height: Math.max(1, rect.height), }; } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts new file mode 100644 index 000000000..79cf74df6 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts @@ -0,0 +1,213 @@ +import { TestBed } from '@angular/core/testing'; +import { + EmbeddedMpvSession, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; + +/** + * Moving the window to a display with a different scale (or changing the + * page zoom) rescales the CSS→native mapping the backend applies to bounds, + * without necessarily resizing the host element. The controller watches + * devicePixelRatio through a re-armed matchMedia query and re-syncs bounds + * when it changes (#1145). + */ +describe('EmbeddedMpvSessionController devicePixelRatio watch', () => { + class FakeMediaQueryList { + private readonly listeners = new Set<() => void>(); + + constructor(readonly media: string) {} + + addEventListener(_type: 'change', listener: () => void): void { + this.listeners.add(listener); + } + + removeEventListener(_type: 'change', listener: () => void): void { + this.listeners.delete(listener); + } + + fire(): void { + for (const listener of [...this.listeners]) { + listener(); + } + } + + get listenerCount(): number { + return this.listeners.size; + } + } + + let electron: { + platform: string; + getEmbeddedMpvSupport: jest.Mock; + prepareEmbeddedMpv: jest.Mock; + createEmbeddedMpvSession: jest.Mock; + loadEmbeddedMpvPlayback: jest.Mock; + disposeEmbeddedMpvSession: jest.Mock; + setEmbeddedMpvBounds: jest.Mock; + onEmbeddedMpvSessionUpdate: jest.Mock; + }; + let mediaQueries: FakeMediaQueryList[]; + + beforeEach(() => { + electron = { + platform: 'win32', + getEmbeddedMpvSupport: jest + .fn() + .mockResolvedValue({ supported: true, platform: 'win32' }), + prepareEmbeddedMpv: jest + .fn() + .mockResolvedValue({ supported: true, platform: 'win32' }), + createEmbeddedMpvSession: jest + .fn() + .mockResolvedValue(createSession()), + loadEmbeddedMpvPlayback: jest.fn().mockResolvedValue(undefined), + disposeEmbeddedMpvSession: jest.fn().mockResolvedValue(undefined), + setEmbeddedMpvBounds: jest.fn().mockResolvedValue(undefined), + onEmbeddedMpvSessionUpdate: jest.fn(() => jest.fn()), + }; + Object.defineProperty(window, 'electron', { + configurable: true, + value: electron, + }); + + mediaQueries = []; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: (media: string) => { + const query = new FakeMediaQueryList(media); + mediaQueries.push(query); + return query; + }, + }); + Object.defineProperty(window, 'devicePixelRatio', { + configurable: true, + value: 1, + }); + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: class MockResizeObserver { + observe = jest.fn(); + disconnect = jest.fn(); + }, + }); + Object.defineProperty(window, 'requestAnimationFrame', { + configurable: true, + value: (callback: FrameRequestCallback) => + window.setTimeout(() => callback(0), 0), + }); + Object.defineProperty(window, 'cancelAnimationFrame', { + configurable: true, + value: (handle: number) => window.clearTimeout(handle), + }); + + TestBed.configureTestingModule({ + providers: [EmbeddedMpvSessionController], + }); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + delete (window as unknown as { electron?: unknown }).electron; + delete (window as unknown as { matchMedia?: unknown }).matchMedia; + jest.restoreAllMocks(); + }); + + it('re-syncs bounds and re-arms the query when devicePixelRatio changes', async () => { + const controller = TestBed.inject(EmbeddedMpvSessionController); + const teardown = controller.startSession( + createHost(), + createPlayback(), + 0.5 + ); + await waitFor( + () => controller.sessionId() === 'mpv-1', + 'session to start' + ); + + expect(mediaQueries.length).toBe(1); + expect(mediaQueries[0].media).toBe('(resolution: 1dppx)'); + electron.setEmbeddedMpvBounds.mockClear(); + + // Simulate a move to a 150%-scaled display. + Object.defineProperty(window, 'devicePixelRatio', { + configurable: true, + value: 1.5, + }); + mediaQueries[0].fire(); + await waitFor( + () => electron.setEmbeddedMpvBounds.mock.calls.length > 0, + 'bounds re-sync after dPR change' + ); + + // The stale query is released and a new one tracks the new ratio. + expect(mediaQueries[0].listenerCount).toBe(0); + expect(mediaQueries.length).toBe(2); + expect(mediaQueries[1].media).toBe('(resolution: 1.5dppx)'); + + // A second display change must fire through the re-armed query. + electron.setEmbeddedMpvBounds.mockClear(); + mediaQueries[1].fire(); + await waitFor( + () => electron.setEmbeddedMpvBounds.mock.calls.length > 0, + 'bounds re-sync after second dPR change' + ); + + teardown(); + expect(mediaQueries[mediaQueries.length - 1].listenerCount).toBe(0); + }); +}); + +function createHost(): HTMLElement { + return { + getBoundingClientRect: () => ({ + left: 10, + top: 20, + width: 640, + height: 360, + }), + } as HTMLElement; +} + +function createPlayback(): ResolvedPortalPlayback { + return { + streamUrl: 'https://example.com/movie.mp4', + title: 'Example Movie', + }; +} + +function createSession(): EmbeddedMpvSession { + return { + id: 'mpv-1', + title: 'Example Movie', + streamUrl: 'https://example.com/movie.mp4', + status: 'playing', + positionSeconds: 0, + durationSeconds: null, + volume: 0.5, + audioTracks: [], + selectedAudioTrackId: null, + subtitleTracks: [], + selectedSubtitleTrackId: null, + playbackSpeed: 1, + aspectOverride: 'no', + recording: { active: false }, + startedAt: '2026-07-19T00:00:00.000Z', + updatedAt: '2026-07-19T00:00:01.000Z', + }; +} + +async function waitFor( + condition: () => boolean, + description: string +): Promise { + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + if (condition()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + throw new Error(`Timed out waiting for ${description}`); +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts index d2a60a690..b70512184 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts @@ -141,8 +141,10 @@ describe('EmbeddedMpvSessionController', () => { ); expect(electron.prepareEmbeddedMpv).toHaveBeenCalled(); + // Fractional CSS edges stay unrounded: the main process rounds once, + // after converting them to native units. expect(electron.createEmbeddedMpvSession).toHaveBeenCalledWith( - { x: 11, y: 21, width: 640, height: 360 }, + { x: 10.6, y: 20.5, width: 640, height: 360 }, 'Example Movie', 0.7 ); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts index 36e6e11cb..f760bec97 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts @@ -151,6 +151,31 @@ export class EmbeddedMpvSessionController { window.addEventListener('resize', scheduleBoundsSync); window.addEventListener('scroll', scheduleBoundsSync, true); + // Page zoom and monitor DPI rescale the CSS→native-pixel mapping the + // backend applies to these bounds. Moving the window to a display + // with a different scale can keep the CSS layout identical (no + // resize, no ResizeObserver), so watch devicePixelRatio through a + // re-armed matchMedia query and re-sync when it changes. + let detachDprWatch: (() => void) | null = null; + const watchDevicePixelRatio = () => { + detachDprWatch?.(); + detachDprWatch = null; + const query = window.matchMedia?.( + `(resolution: ${window.devicePixelRatio}dppx)` + ); + if (!query) { + return; + } + const onChange = () => { + watchDevicePixelRatio(); + scheduleBoundsSync(); + }; + query.addEventListener('change', onChange); + detachDprWatch = () => + query.removeEventListener('change', onChange); + }; + watchDevicePixelRatio(); + const create = async () => { this.session.set(createLoadingSession(playback, initialVolume)); await waitForStartupPaint(); @@ -237,6 +262,8 @@ export class EmbeddedMpvSessionController { resizeObserver.disconnect(); window.removeEventListener('resize', scheduleBoundsSync); window.removeEventListener('scroll', scheduleBoundsSync, true); + detachDprWatch?.(); + detachDprWatch = null; if (this.activeBoundsSync === scheduleBoundsSync) { this.activeBoundsSync = null; From 29e624b0dd68660051d4859d2c733e7f7bfa5e83 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:59:53 +0200 Subject: [PATCH 05/26] ci(release): make test draft releases traceable and self-cleaning (#1202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(release): make test draft releases traceable and self-cleaning Every PR and master build created a draft named "Release v" with tag test-, so 70+ identical drafts piled up and PR drafts were untraceable (for pull_request events github.sha is the ephemeral merge-commit SHA that resolves to nothing in the repo). - Title test drafts as "v — PR # @ [test]" / "v — master @ [test]"; tag releases keep "Release v" - Prepend a context header (PR, head commit, workflow run links) to the auto-generated release notes - Use the PR head SHA and pass target_commitish so generated notes actually cover the PR commits - Use stable tags (test-pr-, test-master) so action-gh-release updates one rolling draft in place instead of creating a new one per push - Mark all non-tag drafts as prerelease - Cancel superseded in-progress PR builds via a concurrency group - Delete a PR's rolling draft when the PR closes (new workflow) Co-Authored-By: Claude Fable 5 * ci(release): close review-bot race windows in draft release flow - Move concurrency from workflow level to job level: cancelling a whole run could interrupt action-gh-release mid-asset-replacement and leave the rolling draft incomplete. Build slots still cancel superseded PR work (matrix-aware groups); the release job gets its own serializing, never-cancelling group. - Re-check the live PR state in the release job right before touching the draft, so a build that outlives its PR cannot recreate the draft after cleanup deleted it. - In the cleanup workflow, cancel still-running builds of the closed PR (dead work anyway) and wait for them to settle before deleting. - Emit an explicit empty `body=` output for tag builds instead of a blank-line heredoc. Addresses Codex and Greptile review feedback on #1202. Co-Authored-By: Claude Fable 5 * ci(release): grant actions:write so PR-close cleanup can cancel builds gh run cancel needs the actions scope; with only contents: write the cancellation 403s silently and the settle-poll burns its full window. Also skip the cleanup job for fork PRs entirely: they never get a draft and their token is read-only regardless of the permissions block. Addresses Greptile P1 / Codex P2 follow-up on #1202. Co-Authored-By: Claude Fable 5 * ci(release): re-assert rolling draft title after asset upload action-gh-release@v2 updates name/body/target_commitish on the normal draft-reuse path, but in a rare race (release listing transiently missing the draft) it uploads assets to the canonical oldest draft without refreshing its metadata. PATCH the title and commitish on the release id the action actually used, so the draft title always names the current head SHA; the body is left alone to preserve generated notes. Addresses Codex round-2 feedback on #1202. Co-Authored-By: Claude Fable 5 * ci(release): prune stale assets before updating a rolling draft The release action only replaces same-name assets, so a PR that bumps the app version would leave old-version installers beside the new set in its rolling draft. Delete all existing assets of the matched draft before the upload; the action re-uploads the full current set right after. Published releases are never touched. Addresses Codex round-3 feedback on #1202. Co-Authored-By: Claude Fable 5 * ci(release): rebuild full draft metadata after asset upload Extend the post-upload metadata step to also rebuild the body (context header + notes from the same generate-notes API the action uses), not just title/commitish. The rolling draft now ends up with correct metadata regardless of which internal action-gh-release path ran, including the rare canonicalize-duplicate fallback. If notes generation fails, the body is left as the action set it. Addresses Codex round-4 feedback on #1202. Co-Authored-By: Claude Fable 5 * ci(release): only cancel pull_request runs when cleaning up a closed PR A manually dispatched build on the same head branch is not the PR's work; filter the cancellation list by event so PR-close cleanup cannot abort it. Addresses Codex round-5 feedback on #1202. Co-Authored-By: Claude Fable 5 * ci(release): guard PR-close cleanup against close-reopen races Re-check the live PR state at the start of the cleanup job and again right before deleting the draft, so a PR that is reopened while the cleanup is queued or waiting keeps its rolling draft and its fresh reopened-run builds are not cancelled. Addresses Codex round-6 feedback on #1202. Co-Authored-By: Claude Fable 5 * ci(release): keep tag_name when patching rolling draft metadata PATCHing a draft release without tag_name makes GitHub drop the pending tag (the draft turns into untagged-), so the next run cannot find the rolling draft by tag and creates a duplicate — observed live on this PR's own drafts. Include tag_name in both PATCH payloads of the metadata step. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/build-and-make.yaml | 181 ++++++++++++++++++++++++- .github/workflows/cleanup-pr-draft.yml | 92 +++++++++++++ 2 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/cleanup-pr-draft.yml diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 8df8b6e81..14fd1cc94 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -16,6 +16,13 @@ jobs: name: Build pinned Linux Embedded MPV runtime runs-on: ubuntu-22.04 timeout-minutes: 120 + # Concurrency lives on the build jobs, not the workflow: cancelling a + # whole run could interrupt action-gh-release mid-update and leave the + # rolling draft with missing assets. Build slots cancel superseded PR + # work; the release job only serializes and is never cancelled. + concurrency: + group: ${{ github.workflow }}-build-linux-runtime-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} steps: - name: Checkout code @@ -304,6 +311,9 @@ jobs: name: Build on ${{ matrix.os }} ${{ matrix.arch }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 + concurrency: + group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.arch }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -1200,6 +1210,9 @@ jobs: needs: linux-embedded-mpv-runtime runs-on: ${{ matrix.runner }} timeout-minutes: 120 + concurrency: + group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.linux_profile }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -1235,6 +1248,9 @@ jobs: - build-linux if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-release-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false permissions: contents: write @@ -1367,13 +1383,117 @@ jobs: id: package-version run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + # Test drafts (PR/master) get a self-describing title plus a context + # header linking the PR, real head commit, and workflow run. A stable + # tag per PR (test-pr-) / branch (test-master) makes the action + # update one rolling draft in place instead of piling up a new draft + # for every push. PR builds must not use github.sha here: that is the + # ephemeral merge-commit SHA, which resolves to nothing in the repo. + - name: Compose release metadata + id: release-meta + shell: bash + env: + VERSION: ${{ steps.package-version.outputs.version }} + EVENT_NAME: ${{ github.event_name }} + IS_TAG_BUILD: ${{ startsWith(github.ref, 'refs/tags/') }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + + SHORT_SHA="${HEAD_SHA:0:7}" + SAFE_BRANCH="${SOURCE_BRANCH//\//-}" + + if [ "${IS_TAG_BUILD}" = "true" ]; then + NAME="Release v${VERSION}" + TAG="${GITHUB_REF_NAME}" + BODY="" + elif [ "${EVENT_NAME}" = "pull_request" ]; then + NAME="v${VERSION} — PR #${PR_NUMBER} @ ${SHORT_SHA} [test]" + TAG="test-pr-${PR_NUMBER}" + BODY="$(printf '🧪 Test build for PR [#%s](%s) — %s\n\nCommit [`%s`](%s/commit/%s) · branch `%s` · [workflow run](%s)' \ + "${PR_NUMBER}" "${PR_URL}" "${PR_TITLE}" \ + "${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${SOURCE_BRANCH}" "${RUN_URL}")" + else + NAME="v${VERSION} — ${SAFE_BRANCH} @ ${SHORT_SHA} [test]" + TAG="test-${SAFE_BRANCH}" + BODY="$(printf '🧪 Test build from `%s` — commit [`%s`](%s/commit/%s) · [workflow run](%s)' \ + "${SOURCE_BRANCH}" "${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${RUN_URL}")" + fi + + { + echo "name=${NAME}" + echo "tag=${TAG}" + echo "commitish=${HEAD_SHA}" + } >> "${GITHUB_OUTPUT}" + + if [ -n "${BODY}" ]; then + { + echo "body<> "${GITHUB_OUTPUT}" + else + echo "body=" >> "${GITHUB_OUTPUT}" + fi + + # A PR can be closed while this workflow is still running; the + # cleanup workflow deletes the PR draft on close. Re-check the live + # PR state right before touching the draft so a late-finishing run + # cannot recreate a draft for a closed PR. + - name: Check PR is still open + if: github.event_name == 'pull_request' + id: pr-state + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + echo "state=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}" --jq '.state')" >> "${GITHUB_OUTPUT}" + + # The rolling draft keeps assets across runs and the release action + # only replaces same-name files. If the app version changes between + # pushes, old-version installers would linger beside the new set, + # so drop every existing asset first — the action re-uploads the + # full current set right after. Only drafts are pruned; published + # releases are never touched. + - name: Prune stale draft assets + if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.release-meta.outputs.tag }} + run: | + set -euo pipefail + + release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate | + jq -s --arg tag "${RELEASE_TAG}" \ + 'add | [.[] | select(.draft and .tag_name == $tag)][0].id // empty')" + + if [ -z "${release_id}" ]; then + echo "No existing draft for ${RELEASE_TAG}; nothing to prune." + exit 0 + fi + + gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" --paginate --jq '.[].id' | + xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/{}" + + echo "Pruned assets from draft ${release_id} (${RELEASE_TAG})." + - 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 with: draft: true - prerelease: ${{ github.event_name == 'pull_request' }} - name: Release v${{ steps.package-version.outputs.version }} - tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('test-{0}', github.sha) }} + prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }} + name: ${{ steps.release-meta.outputs.name }} + tag_name: ${{ steps.release-meta.outputs.tag }} + target_commitish: ${{ steps.release-meta.outputs.commitish }} + body: ${{ steps.release-meta.outputs.body }} generate_release_notes: true files: | artifacts/macos-x64-artifacts/*-x64.dmg @@ -1400,3 +1520,58 @@ jobs: artifacts/windows-artifacts/*.blockmap env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Rare action-gh-release path: when the release listing transiently + # misses the rolling draft, the action creates a duplicate, deletes + # it in favor of the canonical (oldest) draft, and uploads assets + # there WITHOUT refreshing that draft's metadata. Rebuild the full + # metadata (title, commitish, and body = context header + notes + # from the same generate-notes API the action uses) on the release + # id the action actually used, so the draft ends up correct no + # matter which internal path ran. If notes generation fails, the + # body is left as the action set it and only title/commitish are + # re-asserted. + - name: Ensure draft metadata is current + if: steps.draft-release.outputs.id != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_ID: ${{ steps.draft-release.outputs.id }} + RELEASE_TAG: ${{ steps.release-meta.outputs.tag }} + RELEASE_NAME: ${{ steps.release-meta.outputs.name }} + RELEASE_COMMITISH: ${{ steps.release-meta.outputs.commitish }} + RELEASE_BODY: ${{ steps.release-meta.outputs.body }} + run: | + set -euo pipefail + + GENERATED_NOTES="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + -f tag_name="${RELEASE_TAG}" \ + -f target_commitish="${RELEASE_COMMITISH}" \ + --jq '.body' || true)" + + # tag_name MUST be included in every PATCH: updating a draft + # without it makes GitHub drop the pending tag (the draft + # becomes "untagged-"), which breaks the rolling-draft + # lookup on the next run. + if [ -z "${GENERATED_NOTES}" ]; then + jq -n \ + --arg tag "${RELEASE_TAG}" \ + --arg name "${RELEASE_NAME}" \ + --arg commitish "${RELEASE_COMMITISH}" \ + '{tag_name: $tag, name: $name, target_commitish: $commitish}' | + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null + exit 0 + fi + + if [ -n "${RELEASE_BODY}" ]; then + FULL_BODY="$(printf '%s\n\n%s' "${RELEASE_BODY}" "${GENERATED_NOTES}")" + else + FULL_BODY="${GENERATED_NOTES}" + fi + + jq -n \ + --arg tag "${RELEASE_TAG}" \ + --arg name "${RELEASE_NAME}" \ + --arg commitish "${RELEASE_COMMITISH}" \ + --arg body "${FULL_BODY}" \ + '{tag_name: $tag, name: $name, target_commitish: $commitish, body: ($body | .[0:120000])}' | + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null diff --git a/.github/workflows/cleanup-pr-draft.yml b/.github/workflows/cleanup-pr-draft.yml new file mode 100644 index 000000000..695685255 --- /dev/null +++ b/.github/workflows/cleanup-pr-draft.yml @@ -0,0 +1,92 @@ +name: Cleanup PR Draft Release + +on: + pull_request: + types: [closed] + +# contents: write — delete the draft release; actions: write — cancel the +# closed PR's still-running build workflow before deleting. +permissions: + actions: write + contents: write + +jobs: + delete-draft: + name: Delete PR draft release + # Fork PRs never get a draft (the release job skips them) and their + # GITHUB_TOKEN is read-only regardless of the permissions block, so + # there is nothing to cancel or delete. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + # A closed PR can be reopened while this job is still queued or + # waiting; a reopened PR's fresh build must not be cancelled and + # its draft must not be deleted. Check the live state up front + # (and again right before deleting below). + - name: Check PR is still closed + id: pr-state + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + echo "state=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.state')" >> "${GITHUB_OUTPUT}" + + # A build for this PR may still be running and would recreate the + # rolling draft after we delete it. Cancel those runs (dead work + # for a closed PR anyway) and wait for them to wind down. The + # release job additionally re-checks the live PR state, so this + # wait is defense in depth, not the only guard. + - name: Cancel in-progress builds for the closed PR + if: steps.pr-state.outputs.state == 'closed' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + + # --event pull_request: a manually dispatched build on the + # same branch is not this PR's work and must not be cancelled. + list_active_runs() { + gh run list --repo "${GITHUB_REPOSITORY}" \ + --workflow 'Build and Make Electron App' \ + --branch "${HEAD_BRANCH}" \ + --event pull_request \ + --json databaseId,status \ + --jq '.[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "requested" or .status == "pending") | .databaseId' + } + + for run_id in $(list_active_runs); do + echo "Cancelling run ${run_id}" + gh run cancel "${run_id}" --repo "${GITHUB_REPOSITORY}" || true + done + + # Cancellation is asynchronous; poll until the runs settle. + for _ in $(seq 1 18); do + if [ -z "$(list_active_runs)" ]; then + break + fi + sleep 10 + done + + # Draft releases have no real git tag, so a lookup via + # releases/tags/ returns 404. List releases and match the + # draft by its stored tag_name (test-pr-) instead. The PR state + # is re-checked one last time right before deleting, in case the + # PR was reopened during the cancellation wait above. + - name: Delete draft release for closed PR + if: steps.pr-state.outputs.state == 'closed' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + if [ "$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.state')" != "closed" ]; then + echo "PR #${PR_NUMBER} was reopened; keeping its draft." + exit 0 + fi + + gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \ + --jq ".[] | select(.draft and .tag_name == \"test-pr-${PR_NUMBER}\") | .id" | + xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/{}" From 5cae310430966b065a67f20d722b767dea4af25d Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:30:21 +0200 Subject: [PATCH 06/26] fix(logging): redact sensitive portal and Electron diagnostics (#1182) * fix: redact sensitive log data * fix(ci): keep logging preload self-contained * fix(logging): preserve shared diagnostics * fix(logging): close trace redaction gaps * fix(logging): redact Xtream path credentials * fix(logging): close credential redaction gaps * fix(logging): suppress external player arguments * fix(logging): harden URL and date redaction * fix(logging): redact map keys and URL fragments * fix(logging): redact sensitive map values * fix(logging): redact credentials in diagnostic text * fix(logging): close remaining credential leaks --- AGENTS.md | 4 + CLAUDE.md | 5 + .../app/events/portal-debug.events.spec.ts | 33 ++ .../src/app/events/portal-debug.events.ts | 114 +---- .../src/app/events/settings.events.spec.ts | 70 ++++ .../src/app/events/settings.events.ts | 6 +- .../src/app/events/stalker.events.ts | 6 +- .../src/app/events/xtream.events.ts | 11 +- .../src/app/services/debug-trace.spec.ts | 47 +++ .../src/app/services/debug-trace.ts | 8 +- .../src/app/services/electron.service.spec.ts | 20 + apps/web/src/app/services/electron.service.ts | 26 +- apps/web/src/app/services/pwa.service.ts | 13 +- docs/architecture/electron-security.md | 20 + .../portal/shared/util/src/lib/logger.spec.ts | 47 ++- libs/portal/shared/util/src/lib/logger.ts | 23 +- .../src/lib/stalker-session.service.spec.ts | 35 +- .../src/lib/stalker-session.service.ts | 27 +- .../src/lib/services/xtream-api.service.ts | 4 +- libs/shared/logging/jest.config.ts | 13 + libs/shared/logging/project.json | 20 + libs/shared/logging/src/index.ts | 5 + .../src/lib/redact-sensitive-data.spec.ts | 357 ++++++++++++++++ .../logging/src/lib/redact-sensitive-data.ts | 395 ++++++++++++++++++ libs/shared/logging/tsconfig.json | 19 + libs/shared/logging/tsconfig.lib.json | 10 + libs/shared/logging/tsconfig.spec.json | 15 + tools/coverage/coverage-policy.json | 7 + tsconfig.base.json | 1 + 29 files changed, 1203 insertions(+), 158 deletions(-) create mode 100644 apps/electron-backend/src/app/events/settings.events.spec.ts create mode 100644 apps/electron-backend/src/app/services/debug-trace.spec.ts create mode 100644 libs/shared/logging/jest.config.ts create mode 100644 libs/shared/logging/project.json create mode 100644 libs/shared/logging/src/index.ts create mode 100644 libs/shared/logging/src/lib/redact-sensitive-data.spec.ts create mode 100644 libs/shared/logging/src/lib/redact-sensitive-data.ts create mode 100644 libs/shared/logging/tsconfig.json create mode 100644 libs/shared/logging/tsconfig.lib.json create mode 100644 libs/shared/logging/tsconfig.spec.json diff --git a/AGENTS.md b/AGENTS.md index 7371074f5..fb252fee7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,10 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal +- Settings, portal request/response, and trace payloads must use + `@iptvnator/shared/logging` or the redacting portal logger before reaching + `console.*`; never log raw credentials while debugging. + - GPU/compositor debugging: ```bash diff --git a/CLAUDE.md b/CLAUDE.md index 16fc9a0e2..d3d18d399 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,10 @@ Useful narrower flags: - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal +Settings, portal request/response, and trace payloads must use +`@iptvnator/shared/logging` or the redacting portal logger before reaching +`console.*`; never log raw credentials while debugging. + For GPU/compositor debugging: ```bash @@ -238,6 +242,7 @@ This is an Nx monorepo with the following structure: - **portal/shared/{data-access,ui,util}** - Cross-portal shared code - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`) - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`) + - **shared/logging** - Dependency-free structured redaction for diagnostic logs - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend) - **shared/m3u-utils** - M3U playlist utilities - **shared/testing** - Shared test helpers diff --git a/apps/electron-backend/src/app/events/portal-debug.events.spec.ts b/apps/electron-backend/src/app/events/portal-debug.events.spec.ts index 9912c78f3..b64c4ead5 100644 --- a/apps/electron-backend/src/app/events/portal-debug.events.spec.ts +++ b/apps/electron-backend/src/app/events/portal-debug.events.spec.ts @@ -87,4 +87,37 @@ describe('sanitizePortalDebugEvent', () => { }, }); }); + + it('redacts credentials in request, response, errors, and URL query params', () => { + const secrets = { + username: 'main-user-secret', + password: 'main-password-secret', + token: 'main-token-secret', + authorization: 'main-authorization-secret', + mac: 'main-mac-secret', + }; + const event = sanitizePortalDebugEvent({ + requestId: 'req-redaction', + provider: 'stalker', + operation: 'get_profile', + transport: 'electron-main', + startedAt: new Date().toISOString(), + durationMs: 5, + status: 'error', + request: { + params: secrets, + url: `https://example.com/portal?token=${secrets.token}&action=get_profile`, + }, + error: new Error( + `Request failed: https://example.com/portal?authorization=${secrets.authorization}&action=get_profile` + ), + }); + + const output = JSON.stringify(event); + for (const secret of Object.values(secrets)) { + expect(output).not.toContain(secret); + } + expect(output).toContain('get_profile'); + expect(output).toContain('req-redaction'); + }); }); diff --git a/apps/electron-backend/src/app/events/portal-debug.events.ts b/apps/electron-backend/src/app/events/portal-debug.events.ts index 153efee15..c9fb68142 100644 --- a/apps/electron-backend/src/app/events/portal-debug.events.ts +++ b/apps/electron-backend/src/app/events/portal-debug.events.ts @@ -1,117 +1,19 @@ import { BrowserWindow } from 'electron'; -import { PORTAL_DEBUG_EVENT, PortalDebugEvent } from '@iptvnator/shared/interfaces'; +import { + PORTAL_DEBUG_EVENT, + PortalDebugEvent, +} from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; import { environment } from '../../environments/environment'; -const MAX_DEBUG_DEPTH = 6; - -function sanitizePortalDebugValue( - value: unknown, - seen = new WeakSet(), - depth = 0 -): unknown { - if ( - value == null || - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - return value; - } - - if (typeof value === 'bigint') { - return value.toString(); - } - - if ( - typeof value === 'function' || - typeof value === 'symbol' - ) { - return undefined; - } - - if (depth >= MAX_DEBUG_DEPTH) { - return '[MaxDepth]'; - } - - if (value instanceof Error) { - const baseError = { - name: value.name, - message: value.message, - stack: value.stack, - } as Record; - - for (const [key, entry] of Object.entries( - value as unknown as Record - )) { - baseError[key] = sanitizePortalDebugValue( - entry, - seen, - depth + 1 - ); - } - - return baseError; - } - - if (value instanceof Date) { - return value.toISOString(); - } - - if (value instanceof URL) { - return value.toString(); - } - - if (Array.isArray(value)) { - return value.map((entry) => - sanitizePortalDebugValue(entry, seen, depth + 1) - ); - } - - if (value instanceof Map) { - return Object.fromEntries( - [...value.entries()].map(([key, entry]) => [ - String(key), - sanitizePortalDebugValue(entry, seen, depth + 1), - ]) - ); - } - - if (value instanceof Set) { - return [...value].map((entry) => - sanitizePortalDebugValue(entry, seen, depth + 1) - ); - } - - if (typeof value === 'object') { - if (seen.has(value)) { - return '[Circular]'; - } - - seen.add(value); - - const entries = Object.entries(value as Record).map( - ([key, entry]) => [ - key, - sanitizePortalDebugValue(entry, seen, depth + 1), - ] - ); - - seen.delete(value); - - return Object.fromEntries(entries); - } - - return String(value); -} - export function sanitizePortalDebugEvent( event: PortalDebugEvent ): PortalDebugEvent { return { ...event, - request: sanitizePortalDebugValue(event.request), - response: sanitizePortalDebugValue(event.response), - error: sanitizePortalDebugValue(event.error), + request: redactSensitiveData(event.request), + response: redactSensitiveData(event.response), + error: redactSensitiveData(event.error), }; } diff --git a/apps/electron-backend/src/app/events/settings.events.spec.ts b/apps/electron-backend/src/app/events/settings.events.spec.ts new file mode 100644 index 000000000..fa0825ea3 --- /dev/null +++ b/apps/electron-backend/src/app/events/settings.events.spec.ts @@ -0,0 +1,70 @@ +const handlers = new Map unknown>(); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn( + (channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler); + } + ), + }, +})); + +jest.mock('../services/store.service', () => ({ + MPV_PLAYER_ARGUMENTS: 'mpvPlayerArguments', + MPV_REUSE_INSTANCE: 'mpvReuseInstance', + VLC_PLAYER_ARGUMENTS: 'vlcPlayerArguments', + VLC_REUSE_INSTANCE: 'vlcReuseInstance', + store: { get: jest.fn(), set: jest.fn() }, +})); + +jest.mock('../server/http-server', () => ({ + httpServer: { updateSettings: jest.fn() }, +})); + +describe('SETTINGS_UPDATE logging', () => { + beforeEach(async () => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + await import('./settings.events'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not print a TMDB apiKey while retaining useful fields', () => { + const apiKey = 'tmdb-settings-api-key-secret'; + const handler = handlers.get('SETTINGS_UPDATE'); + + expect(handler).toBeDefined(); + handler?.({}, { language: 'de', tmdb: { apiKey, enabled: true } }); + + const output = JSON.stringify((console.log as jest.Mock).mock.calls); + expect(output).not.toContain(apiKey); + expect(output).toContain('language'); + expect(output).toContain('de'); + expect(output).toContain('enabled'); + }); + + it('does not print credentials embedded in external player arguments', () => { + const authorizationSecret = 'player-authorization-secret'; + const cookieSecret = 'player-cookie-secret'; + const handler = handlers.get('SETTINGS_UPDATE'); + + expect(handler).toBeDefined(); + handler?.( + {}, + { + language: 'de', + mpvPlayerArguments: `--http-header-fields=Authorization: Bearer ${authorizationSecret}`, + vlcPlayerArguments: `--http-referrer=https://example.com --http-cookie=Cookie: ${cookieSecret}`, + } + ); + + const output = JSON.stringify((console.log as jest.Mock).mock.calls); + expect(output).not.toContain(authorizationSecret); + expect(output).not.toContain(cookieSecret); + expect(output).toContain('language'); + expect(output).toContain('de'); + }); +}); diff --git a/apps/electron-backend/src/app/events/settings.events.ts b/apps/electron-backend/src/app/events/settings.events.ts index 5eb290f2c..d05ccdcf0 100644 --- a/apps/electron-backend/src/app/events/settings.events.ts +++ b/apps/electron-backend/src/app/events/settings.events.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron'; import { normalizeExternalPlayerArguments } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; import { EMBEDDED_MPV_FRAME_COPY, MPV_PLAYER_ARGUMENTS, @@ -17,7 +18,10 @@ export default class SettingsEvents { } ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { - console.log('Received SETTINGS_UPDATE with data:', arg); + console.log( + 'Received SETTINGS_UPDATE with data:', + redactSensitiveData(arg) + ); if (arg.mpvPlayerArguments !== undefined) { store.set( diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index d16fd8cba..a46a039f5 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -9,6 +9,7 @@ import { PortalDebugEvent, STALKER_REQUEST, } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service'; import { emitPortalDebugEvent } from './portal-debug.events'; import { buildStalkerIdentityRequestContext } from './stalker-identity'; @@ -186,7 +187,10 @@ ipcMain.handle( emitPortalDebugEvent(debugEvent); } - console.error('[StalkerEvents] Request error:', error); + console.error( + '[StalkerEvents] Request error:', + redactSensitiveData(error) + ); // Format error response if (axios.isAxiosError(error)) { diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index da96caa5e..0d63e0523 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -10,6 +10,7 @@ import { XTREAM_CANCEL_SESSION, normalizeXtreamServerUrl, } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; import { emitPortalDebugEvent } from './portal-debug.events'; import { UnsafeUrlError } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; @@ -212,10 +213,12 @@ ipcMain.handle( if (!payload.suppressErrorLog) { console.error( '[XTREAM_REQUEST] Failed', - formatXtreamError( - error, - requestUrlForLog, - payload.params?.action + redactSensitiveData( + formatXtreamError( + error, + requestUrlForLog, + payload.params?.action + ) ) ); } diff --git a/apps/electron-backend/src/app/services/debug-trace.spec.ts b/apps/electron-backend/src/app/services/debug-trace.spec.ts new file mode 100644 index 000000000..91da7daed --- /dev/null +++ b/apps/electron-backend/src/app/services/debug-trace.spec.ts @@ -0,0 +1,47 @@ +import { trace } from './debug-trace'; + +describe('debug trace redaction', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not serialize credentials from nested payloads or URLs', () => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + const secrets = { + password: 'trace-password-secret', + token: 'trace-token-secret', + authorization: 'trace-authorization-secret', + mac: 'trace-mac-secret', + }; + + trace('portal', 'request', { + params: secrets, + url: `https://example.com/portal?token=${secrets.token}&action=get_profile`, + requestId: 'diagnostic-request-id', + }); + + const output = JSON.stringify((console.log as jest.Mock).mock.calls); + for (const secret of Object.values(secrets)) { + expect(output).not.toContain(secret); + } + expect(output).toContain('diagnostic-request-id'); + expect(output).toContain('get_profile'); + }); + + it('redacts serialized credentials before truncating trace strings', () => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + const secret = 'long-trace-json-password-secret'; + const diagnostic = JSON.stringify({ + password: secret, + operation: 'get_profile', + padding: 'x'.repeat(300), + }); + + trace('portal', 'request', { diagnostic }); + + const output = JSON.stringify((console.log as jest.Mock).mock.calls); + expect(output).not.toContain(secret); + expect(output).toContain('[Redacted]'); + expect(output).toContain('get_profile'); + }); +}); diff --git a/apps/electron-backend/src/app/services/debug-trace.ts b/apps/electron-backend/src/app/services/debug-trace.ts index 19a727ea3..f134dc7f3 100644 --- a/apps/electron-backend/src/app/services/debug-trace.ts +++ b/apps/electron-backend/src/app/services/debug-trace.ts @@ -1,3 +1,5 @@ +import { redactSensitiveData } from '@iptvnator/shared/logging'; + const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); const TRACE_PREFIX = '[IPTVnator Trace]'; const MAX_TRACE_ARRAY_ITEMS = 5; @@ -146,10 +148,10 @@ export function summarizeForTrace(value: unknown, depth = 0): unknown { export function safeStringifyForTrace(payload: unknown): string { try { - return JSON.stringify(payload); + return JSON.stringify(redactSensitiveData(payload)); } catch (error) { return JSON.stringify({ - fallback: summarizeForTrace(payload), + fallback: summarizeForTrace(redactSensitiveData(payload)), stringifyError: error instanceof Error ? truncateString(error.message) @@ -166,7 +168,7 @@ export function trace(scope: string, message: string, payload?: unknown): void { console.log( `${TRACE_PREFIX}[${scope}] ${message} ${safeStringifyForTrace( - summarizeForTrace(payload) + summarizeForTrace(redactSensitiveData(payload)) )}` ); } diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index ad0c5015b..e7000a6e4 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -16,6 +16,7 @@ describe('ElectronService', () => { const session = { id: 'session-1' }; let electronBridge: { fetchPlaylistByUrl: jest.Mock; + onPlayerError: jest.Mock; openInMpv: jest.Mock; openInVlc: jest.Mock; }; @@ -24,9 +25,11 @@ describe('ElectronService', () => { beforeEach(() => { jest.spyOn(console, 'log').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); electronBridge = { fetchPlaylistByUrl: jest.fn(), + onPlayerError: jest.fn(), openInMpv: jest.fn().mockResolvedValue(session), openInVlc: jest.fn().mockResolvedValue(session), }; @@ -99,6 +102,23 @@ describe('ElectronService', () => { expect(electronBridge.fetchPlaylistByUrl).not.toHaveBeenCalled(); }); + it('redacts credentials from backend player errors before logging', () => { + const secret = 'player-error-token-secret'; + const listener = electronBridge.onPlayerError.mock.calls[0][0]; + + listener({ + player: 'MPV', + error: 'Playback failed', + originalError: `Request failed: token=${secret}&channel=news`, + }); + + const output = JSON.stringify( + (console.error as jest.Mock).mock.calls + ); + expect(output).not.toContain(secret); + expect(output).toContain('channel=news'); + }); + it('shows the trust-host action for Electron-wrapped security errors', async () => { const securityPayload = { code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 00f3d96af..7ffdaf6f5 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -7,7 +7,6 @@ import { DialogService } from '@iptvnator/ui/components'; import { DataService, SettingsStore } from '@iptvnator/services'; import { AUTO_UPDATE_PLAYLISTS, - createDevLogger, ELECTRON_BRIDGE_SECURITY_ERROR_CODES, ERROR, normalizeHost, @@ -22,6 +21,7 @@ import { } from '@iptvnator/shared/interfaces'; import { AppConfig } from '../../environments/environment'; import { + createLogger, createPortalDebugRequestContext, logPortalDebugEvent, } from '@iptvnator/portal/shared/util'; @@ -54,7 +54,7 @@ export class ElectronService extends DataService { private readonly store = inject(Store); private readonly settingsStore = inject(SettingsStore); private readonly translateService = inject(TranslateService); - private readonly debugLog = createDevLogger('ElectronService'); + private readonly logger = createLogger('ElectronService'); private readonly silentXtreamActions = new Set([ XtreamCodeActions.GetAccountInfo, XtreamCodeActions.GetLiveCategories, @@ -67,7 +67,6 @@ export class ElectronService extends DataService { constructor() { super(); - this.debugLog('Electron service initialized...'); this.setupPlayerErrorListener(); this.setupPortalDebugListener(); } @@ -81,7 +80,10 @@ export class ElectronService extends DataService { error: string; originalError: string; }) => { - console.error(`${data.player} Error:`, data.originalError); + this.logger.error( + `${data.player} Error:`, + data.originalError + ); this.snackBar.open( `${data.player} Error: ${data.error}`, 'Close', @@ -182,7 +184,7 @@ export class ElectronService extends DataService { duration: 5000, } ); - console.error('MPV launch error:', error); + this.logger.error('MPV launch error:', error); throw error; } } @@ -211,7 +213,7 @@ export class ElectronService extends DataService { duration: 5000, } ); - console.error('VLC launch error:', error); + this.logger.error('VLC launch error:', error); throw error; } } @@ -237,7 +239,7 @@ export class ElectronService extends DataService { return playlists as T; } - this.debugLog('Unknown IPC event type:', type); + this.logger.debug('Unknown IPC event type:', type); return undefined as T; } @@ -265,7 +267,7 @@ export class ElectronService extends DataService { return response; } catch (err: unknown) { const errorInfo = this.getErrorDetails(err); - console.error('Stalker request error:', err); + this.logger.error('Stalker request error:', err); this.snackBar.open( `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, 'Close', @@ -362,7 +364,7 @@ export class ElectronService extends DataService { data.title ); } else { - console.error( + this.logger.error( 'Either url or filePath must be provided, but not both.' ); return; @@ -387,7 +389,7 @@ export class ElectronService extends DataService { { duration: 2000 } ); } catch (error: unknown) { - console.error('Playlist refresh error:', error); + this.logger.error('Playlist refresh error:', error); if ( data.url && this.handlePlaylistSecurityError(error, () => { @@ -590,12 +592,12 @@ export class ElectronService extends DataService { // Log error to console if (isSilentAction) { - this.debugLog( + this.logger.debug( `Background Xtream action failed (${action ?? 'unknown'}):`, normalizedMessage ); } else { - console.error('Xtream request error:', normalizedMessage); + this.logger.error('Xtream request error:', normalizedMessage); } // Only show snackbar for user-triggered Xtream requests diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index e4f05f161..3204b3262 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -15,7 +15,6 @@ import { } from 'rxjs'; import { DataService } from '@iptvnator/services'; import { - createDevLogger, ERROR, Playlist, PLAYLIST_PARSE_BY_URL, @@ -32,6 +31,7 @@ import { createPortalDebugSuccessEvent, logPortalDebugEvent, logPortalDebugRequest, + createLogger, } from '@iptvnator/portal/shared/util'; import { getRuntimeBackendUrl } from './runtime-config'; @@ -71,7 +71,7 @@ export class PwaService extends DataService { private readonly store = inject(Store); private readonly swUpdate = inject(SwUpdate); private readonly translateService = inject(TranslateService); - private readonly debugLog = createDevLogger('PwaService'); + private readonly logger = createLogger('PwaService'); private readonly providerTargetIds = new Map>(); private readonly silentXtreamActions = new Set([ XtreamCodeActions.GetAccountInfo, @@ -88,7 +88,6 @@ export class PwaService extends DataService { constructor() { super(); - this.debugLog('PWA service initialized...'); } /** Uses service worker mechanism to check for available application updates */ @@ -358,7 +357,7 @@ export class PwaService extends DataService { ); if (isSilentAction) { - this.debugLog( + this.logger.debug( `Background Xtream action failed (${action ?? 'unknown'}):`, normalizedMessage ); @@ -398,7 +397,7 @@ export class PwaService extends DataService { // Log error to console if (isSilentAction) { - this.debugLog( + this.logger.debug( `Background Xtream action failed (${action ?? 'unknown'}):`, normalizedMessage ); @@ -409,7 +408,7 @@ export class PwaService extends DataService { }; } - console.error('Xtream request error:', normalizedMessage); + this.logger.error('Xtream request error:', normalizedMessage); this.snackBar.open( `Xtream request failed: ${normalizedMessage}`, 'Close', @@ -533,7 +532,7 @@ export class PwaService extends DataService { } catch (err: unknown) { const errorInfo = this.getErrorDetails(err); logPortalDebugEvent(createPortalDebugErrorEvent(context, err)); - console.error('Stalker request error:', err); + this.logger.error('Stalker request error:', err); this.snackBar.open( `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 47332c781..0f33d310f 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -188,6 +188,26 @@ playlist or EPG source host. The `IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch is only for explicitly trusted providers with invalid or self-signed certificates when the host-scoped UI path is not available. +## Sensitive Diagnostic Logging + +Settings, portal requests/responses, IPC trace payloads, and remote-request +errors can contain provider credentials. Code at those boundaries must pass +structured values through `redactSensitiveData` from +`@iptvnator/shared/logging`, or through the portal `createLogger`/portal-debug +helpers that apply it. Do not send a raw settings object, request params, +response, or `Error` directly to `console.*`. + +The redactor preserves non-sensitive diagnostic fields while replacing +credential fields case-insensitively, including usernames, passwords, tokens, +API keys, authorization/cookie headers, and MAC addresses. It also sanitizes +URL query parameters, serialized JSON, nested query values, errors, arrays, +and cyclic objects without mutating the original value. Depth, collection, +object-key, and string limits keep opt-in debug traces bounded. + +When adding a new logging boundary, extend the closest regression test with a +synthetic secret and assert that the exact value is absent from captured log +output. Never use a real provider credential to validate logging. + ## Filesystem Capabilities Renderer IPC payloads are not filesystem authorization. diff --git a/libs/portal/shared/util/src/lib/logger.spec.ts b/libs/portal/shared/util/src/lib/logger.spec.ts index 1691cae71..393f5f27e 100644 --- a/libs/portal/shared/util/src/lib/logger.spec.ts +++ b/libs/portal/shared/util/src/lib/logger.spec.ts @@ -14,7 +14,9 @@ describe('portal debug logger', () => { beforeEach(() => { globalWithNgDevMode.ngDevMode = true; - jest.spyOn(console, 'groupCollapsed').mockImplementation(() => undefined); + jest.spyOn(console, 'groupCollapsed').mockImplementation( + () => undefined + ); jest.spyOn(console, 'groupEnd').mockImplementation(() => undefined); jest.spyOn(console, 'log').mockImplementation(() => undefined); jest.spyOn(console, 'error').mockImplementation(() => undefined); @@ -36,7 +38,9 @@ describe('portal debug logger', () => { }); logPortalDebugRequest(context); - logPortalDebugEvent(createPortalDebugSuccessEvent(context, { ok: true })); + logPortalDebugEvent( + createPortalDebugSuccessEvent(context, { ok: true }) + ); expect(console.groupCollapsed).not.toHaveBeenCalled(); expect(console.log).not.toHaveBeenCalled(); @@ -85,4 +89,43 @@ describe('portal debug logger', () => { nowSpy.mockRestore(); }); + + it('redacts portal request and response credentials before logging', () => { + const secrets = { + username: 'portal-user-secret', + password: 'portal-password-secret', + token: 'portal-token-secret', + authorization: 'portal-authorization-secret', + mac: 'portal-mac-secret', + }; + const context = createPortalDebugRequestContext({ + provider: 'stalker', + operation: 'get_profile', + transport: 'pwa-http', + request: { + params: secrets, + url: `https://example.com/portal?token=${secrets.token}&action=get_profile`, + diagnosticId: 'request-42', + }, + }); + + logPortalDebugRequest(context); + logPortalDebugEvent( + createPortalDebugSuccessEvent(context, { + user_info: secrets, + status: 'ok', + }) + ); + + const output = JSON.stringify([ + ...(console.log as jest.Mock).mock.calls, + ...(console.error as jest.Mock).mock.calls, + ]); + for (const secret of Object.values(secrets)) { + expect(output).not.toContain(secret); + } + expect(output).toContain('request-42'); + expect(output).toContain('get_profile'); + expect(output).toContain('status'); + }); }); diff --git a/libs/portal/shared/util/src/lib/logger.ts b/libs/portal/shared/util/src/lib/logger.ts index 439b449e3..16578937a 100644 --- a/libs/portal/shared/util/src/lib/logger.ts +++ b/libs/portal/shared/util/src/lib/logger.ts @@ -3,6 +3,7 @@ import { PortalDebugProvider, PortalDebugTransport, } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; export interface Logger { debug: (...args: unknown[]) => void; @@ -26,19 +27,31 @@ export function createLogger(scope: string): Logger { return { debug: (...args: unknown[]) => { if (debugEnabled) { - console.debug(prefix, ...args); + console.debug( + prefix, + ...args.map((arg) => redactSensitiveData(arg)) + ); } }, info: (...args: unknown[]) => { if (debugEnabled) { - console.info(prefix, ...args); + console.info( + prefix, + ...args.map((arg) => redactSensitiveData(arg)) + ); } }, warn: (...args: unknown[]) => { - console.warn(prefix, ...args); + console.warn( + prefix, + ...args.map((arg) => redactSensitiveData(arg)) + ); }, error: (...args: unknown[]) => { - console.error(prefix, ...args); + console.error( + prefix, + ...args.map((arg) => redactSensitiveData(arg)) + ); }, }; } @@ -78,7 +91,7 @@ function logPortalDebugSection( method: 'log' | 'error' = 'log' ): void { if (typeof console[method] === 'function') { - console[method](label, value); + console[method](label, redactSensitiveData(value)); } } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index 51810adb6..8ed39a0de 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -24,7 +24,8 @@ type GetProfileWithIdentity = ( ) => Promise; describe('StalkerSessionService identity payloads', () => { - const portalUrl = 'https://portal.example.com/stalker_portal/server/load.php'; + const portalUrl = + 'https://portal.example.com/stalker_portal/server/load.php'; const macAddress = '00:1A:79:AA:BB:CC'; let service: StalkerSessionService; @@ -56,6 +57,10 @@ describe('StalkerSessionService identity payloads', () => { service = TestBed.inject(StalkerSessionService); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('omits SN, device IDs, and signatures from get_profile when identity is blank', async () => { const getProfile = service.getProfile as unknown as GetProfileWithIdentity; @@ -180,6 +185,34 @@ describe('StalkerSessionService identity payloads', () => { expect(handshakePayload.serialNumber).toBe('CUSTOMSN123'); }); + it('does not log credentials from portal request errors', async () => { + const token = 'stalker-error-token-secret'; + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + dataService.sendIpcEvent.mockRejectedValue( + new Error( + `Request failed: https://portal.example/api?token=${token}&action=get_profile` + ) + ); + + await expect( + service.getProfile(portalUrl, macAddress, token, {}, 'random-1') + ).rejects.toThrow('Request failed'); + + const output = consoleError.mock.calls + .flatMap((call) => + call.map((value) => + value instanceof Error + ? `${value.message}\n${value.stack ?? ''}` + : JSON.stringify(value) + ) + ) + .join('\n'); + expect(output).not.toContain(token); + expect(output).toContain('get_profile'); + }); + function lastStalkerPayload(): { params: Record; serialNumber?: string; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index e32498aca..91f1ea741 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -1,10 +1,7 @@ import { Injectable, inject } from '@angular/core'; -import { - createDevLogger, - Playlist, - STALKER_REQUEST, -} from '@iptvnator/shared/interfaces'; +import { Playlist, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; import { DataService } from '@iptvnator/services'; +import { createLogger } from '@iptvnator/portal/shared/util'; import { getStalkerPortalIdentityFromPlaylist, LEGACY_DEFAULT_STALKER_SERIAL, @@ -91,7 +88,7 @@ interface StalkerAuthConfirmationResponse { }) export class StalkerSessionService { private dataService = inject(DataService); - private readonly debugLog = createDevLogger('StalkerSession'); + private readonly logger = createLogger('StalkerSession'); // In-memory token cache for current session (keyed by playlist ID) private tokenCache = new Map(); @@ -234,7 +231,7 @@ export class StalkerSessionService { ); } catch (error) { // Keep failures non-fatal; next interval can recover after token refresh. - console.warn('[StalkerSession] Watchdog ping failed:', error); + this.logger.warn('Watchdog ping failed:', error); } finally { this.watchdogInFlight.delete(playlistId); } @@ -281,10 +278,10 @@ export class StalkerSessionService { }; } - console.error('[StalkerSession] No token in response'); + this.logger.error('No token in response'); throw new Error('Handshake failed: No token received'); } catch (error) { - console.error('[StalkerSession] Handshake error:', error); + this.logger.error('Handshake error:', error); throw error; } } @@ -364,7 +361,7 @@ export class StalkerSessionService { return response; } catch (error) { - console.error('[StalkerSession] Get profile error:', error); + this.logger.error('Get profile error:', error); throw error; } } @@ -404,7 +401,7 @@ export class StalkerSessionService { return false; } catch (error) { - console.error('[StalkerSession] do_auth error:', error); + this.logger.error('do_auth error:', error); throw error; } } @@ -447,7 +444,7 @@ export class StalkerSessionService { profileResponse.js.msg || profileResponse.js.block_msg || 'Unknown profile error'; - console.error('[StalkerSession] Profile error:', errorMsg); + this.logger.error('Profile error:', errorMsg); throw new Error(`Profile error: ${errorMsg}`); } @@ -457,7 +454,7 @@ export class StalkerSessionService { }; } catch (error) { // Profile fetch failed - this is a real error, propagate it - console.error('[StalkerSession] Profile fetch failed:', error); + this.logger.error('Profile fetch failed:', error); throw error; } } @@ -487,14 +484,14 @@ export class StalkerSessionService { // This prevents race conditions when multiple resources request a token simultaneously const pendingPromise = this.pendingAuth.get(playlist._id); if (pendingPromise) { - this.debugLog('Waiting for pending authentication...'); + this.logger.debug('Waiting for pending authentication...'); return pendingPromise; } // No cached token - need to do full authentication (handshake + get_profile) // Don't trust stored tokens as they may be from a different session if (!playlist.portalUrl || !playlist.macAddress) { - console.error('[StalkerSession] Missing portal URL or MAC address'); + this.logger.error('Missing portal URL or MAC address'); throw new Error('Portal URL and MAC address are required'); } const portalUrl = playlist.portalUrl; diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts index ec5a7db01..d85aa843b 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts @@ -1,5 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { DataService } from '@iptvnator/services'; +import { createLogger } from '@iptvnator/portal/shared/util'; import { EpgItem, XtreamCategory, @@ -78,6 +79,7 @@ interface EpgResponse { @Injectable({ providedIn: 'root' }) export class XtreamApiService { private readonly dataService = inject(DataService); + private readonly logger = createLogger('XtreamApiService'); async cancelSession(sessionId: string): Promise { if ( @@ -91,7 +93,7 @@ export class XtreamApiService { const result = await window.electron.xtreamCancelSession(sessionId); return result.success; } catch (error) { - console.error('Failed to cancel Xtream session:', error); + this.logger.error('Failed to cancel Xtream session:', error); return false; } } diff --git a/libs/shared/logging/jest.config.ts b/libs/shared/logging/jest.config.ts new file mode 100644 index 000000000..c2ec796ae --- /dev/null +++ b/libs/shared/logging/jest.config.ts @@ -0,0 +1,13 @@ +export default { + displayName: 'shared-logging', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js'], + coverageDirectory: '../../../coverage/libs/shared/logging', +}; diff --git a/libs/shared/logging/project.json b/libs/shared/logging/project.json new file mode 100644 index 000000000..031ec83b4 --- /dev/null +++ b/libs/shared/logging/project.json @@ -0,0 +1,20 @@ +{ + "name": "shared-logging", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/shared/logging/src", + "projectType": "library", + "tags": ["scope:shared", "domain:shared-runtime", "type:util"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/shared/logging/jest.config.ts", + "tsConfig": "libs/shared/logging/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/shared/logging/src/index.ts b/libs/shared/logging/src/index.ts new file mode 100644 index 000000000..1a61e0b26 --- /dev/null +++ b/libs/shared/logging/src/index.ts @@ -0,0 +1,5 @@ +export { + REDACTED_VALUE, + redactSensitiveData, +} from './lib/redact-sensitive-data'; +export type { RedactionOptions } from './lib/redact-sensitive-data'; diff --git a/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts b/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts new file mode 100644 index 000000000..75d6efcb6 --- /dev/null +++ b/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts @@ -0,0 +1,357 @@ +import { REDACTED_VALUE, redactSensitiveData } from './redact-sensitive-data'; + +const TEST_SECRETS = [ + 'settings-api-key-secret', + 'nested-user-secret', + 'nested-password-secret', + 'nested-token-secret', + 'nested-auth-secret', + 'nested-mac-secret', + 'query-password-secret', + 'query-token-secret', +]; + +function serialized(value: unknown): string { + return JSON.stringify(value); +} + +describe('redactSensitiveData', () => { + it('recursively redacts credentials while retaining diagnostic fields', () => { + const input = { + operation: 'get_profile', + settings: { tmdb: { apiKey: TEST_SECRETS[0] } }, + params: { + username: TEST_SECRETS[1], + PASSWORD: TEST_SECRETS[2], + access_token: TEST_SECRETS[3], + headers: { Authorization: `Bearer ${TEST_SECRETS[4]}` }, + macAddress: TEST_SECRETS[5], + }, + }; + + const result = redactSensitiveData(input); + const output = serialized(result); + + for (const secret of TEST_SECRETS) { + expect(output).not.toContain(secret); + } + expect(result).toEqual({ + operation: 'get_profile', + settings: { tmdb: { apiKey: REDACTED_VALUE } }, + params: { + username: REDACTED_VALUE, + PASSWORD: REDACTED_VALUE, + access_token: REDACTED_VALUE, + headers: { Authorization: REDACTED_VALUE }, + macAddress: REDACTED_VALUE, + }, + }); + }); + + it('redacts Stalker identity credentials while retaining request diagnostics', () => { + const identitySecrets = { + sn: 'stalker-sn-secret', + serialNumber: 'stalker-serial-number-secret', + device_id: 'stalker-device-id-secret', + deviceId1: 'stalker-device-id1-secret', + device_id2: 'stalker-device-id2-secret', + signature: 'stalker-signature-secret', + signature1: 'stalker-signature1-secret', + signature2: 'stalker-signature2-secret', + stalkerSerialNumber: 'playlist-stalker-serial-number-secret', + stalkerDeviceId1: 'playlist-stalker-device-id1-secret', + stalkerDeviceId2: 'playlist-stalker-device-id2-secret', + stalkerSignature1: 'playlist-stalker-signature1-secret', + stalkerSignature2: 'playlist-stalker-signature2-secret', + prehash: 'stalker-prehash-secret', + }; + + const result = redactSensitiveData({ + action: 'get_profile', + requestId: 'stalker-request-id', + params: identitySecrets, + }); + const output = serialized(result); + + for (const secret of Object.values(identitySecrets)) { + expect(output).not.toContain(secret); + } + expect(output).toContain('get_profile'); + expect(output).toContain('stalker-request-id'); + }); + + it('redacts credentials embedded in URL, URLSearchParams, errors, and serialized strings', () => { + const url = new URL( + `https://user:pass@example.com/live?password=${TEST_SECRETS[6]}&token=${TEST_SECRETS[7]}&action=get_live_streams` + ); + const params = new URLSearchParams({ + authorization: TEST_SECRETS[4], + category: 'news', + }); + const error = new Error( + `Request failed: https://example.com/api?token=${TEST_SECRETS[3]}&action=profile` + ); + const json = JSON.stringify({ + refreshToken: TEST_SECRETS[3], + status: 401, + }); + + const output = serialized( + redactSensitiveData({ url, params, error, json }) + ); + + for (const secret of TEST_SECRETS) { + expect(output).not.toContain(secret); + } + expect(output).toContain('action=get_live_streams'); + expect(output).toContain('category=news'); + expect(output).toContain('status'); + expect(output).toContain('401'); + }); + + it('redacts credentials in non-HTTP stream URL strings', () => { + const username = 'rtsp-user-secret'; + const password = 'rtsp-password-secret'; + const token = 'rtmp-token-secret'; + + const output = serialized( + redactSensitiveData([ + `rtsp://${username}:${password}@stream.example/live?token=${token}&channel=news`, + `Playback failed: rtmp://stream.example/live?password=${password}&channel=sports`, + ]) + ); + + expect(output).not.toContain(username); + expect(output).not.toContain(password); + expect(output).not.toContain(token); + expect(output).toContain('channel=news'); + expect(output).toContain('channel=sports'); + }); + + it('redacts a credential from a single-parameter string', () => { + const password = 'single-param-password-secret'; + const token = 'single-param-token-secret'; + + const output = serialized( + redactSensitiveData([ + `password=${password}`, + `token=${token}`, + 'operation=get_profile', + ]) + ); + + expect(output).not.toContain(password); + expect(output).not.toContain(token); + expect(output).toContain('get_profile'); + }); + + it('redacts credentials embedded in diagnostic text', () => { + const token = 'diagnostic-token-secret'; + const authorization = 'diagnostic-authorization-secret'; + const authorizationAssignment = + 'diagnostic-authorization-assignment-secret'; + const stalkerDeviceId = 'diagnostic-stalker-device-id-secret'; + const diagnostic = [ + `Request failed: token=${token}&action=get_profile`, + `Upstream response Authorization: Bearer ${authorization}; status=401`, + `Retrying with authorization=Bearer ${authorizationAssignment}, attempt=2`, + `Identity rejected: stalkerDeviceId1: ${stalkerDeviceId}; action=handshake`, + ]; + + const output = serialized(redactSensitiveData(diagnostic)); + + expect(output).not.toContain(token); + expect(output).not.toContain(authorization); + expect(output).not.toContain(authorizationAssignment); + expect(output).not.toContain(stalkerDeviceId); + expect(output).toContain('get_profile'); + expect(output).toContain('status=401'); + expect(output).toContain('attempt=2'); + expect(output).toContain('action=handshake'); + }); + + it('does not repeat a redacted Error message secret in its stack', () => { + const secret = 'error-stack-password-secret'; + const error = new Error(`password=${secret}&operation=get_profile`); + + const output = serialized(redactSensitiveData(error)); + + expect(output).not.toContain(secret); + expect(output).toContain(encodeURIComponent(REDACTED_VALUE)); + expect(output).toContain('get_profile'); + }); + + it('redacts credentials nested inside non-sensitive query values', () => { + const nestedUrl = `https://identity.example/callback?token=${TEST_SECRETS[3]}&step=authorize`; + const url = new URL('https://example.com/portal'); + url.searchParams.set('redirect', nestedUrl); + url.searchParams.set( + 'payload', + JSON.stringify({ password: TEST_SECRETS[2], action: 'profile' }) + ); + + const output = serialized(redactSensitiveData(url)); + + expect(output).not.toContain(TEST_SECRETS[2]); + expect(output).not.toContain(TEST_SECRETS[3]); + expect(output).toContain('authorize'); + expect(output).toContain('profile'); + }); + + it('redacts credentials from URL fragments while retaining diagnostics', () => { + const token = 'fragment-token-secret'; + const url = new URL( + `https://example.com/callback#access_token=${token}&state=diagnostic` + ); + + const output = serialized(redactSensitiveData(url)); + + expect(output).not.toContain(token); + expect(output).toContain('state=diagnostic'); + }); + + it('redacts credentials from Map keys while retaining values', () => { + const username = 'map-key-user-secret'; + const password = 'map-key-password-secret'; + const token = 'map-key-token-secret'; + const requests = new Map([ + [ + `https://example.com/live/${username}/${password}/101.ts?token=${token}`, + { status: 200 }, + ], + ]); + + const output = serialized(redactSensitiveData(requests)); + + expect(output).not.toContain(username); + expect(output).not.toContain(password); + expect(output).not.toContain(token); + expect(output).toContain('101.ts'); + expect(output).toContain('"status":200'); + }); + + it('redacts Map values selected by sensitive keys', () => { + const authorization = 'map-authorization-secret'; + const password = 'map-password-secret'; + const headers = new Map([ + ['Authorization', `Bearer ${authorization}`], + ['password', password], + ['requestId', 'diagnostic-request-id'], + ]); + + const result = redactSensitiveData(headers); + const output = serialized(result); + + expect(output).not.toContain(authorization); + expect(output).not.toContain(password); + expect(output).toContain('diagnostic-request-id'); + expect(result).toEqual({ + Authorization: REDACTED_VALUE, + password: REDACTED_VALUE, + requestId: 'diagnostic-request-id', + }); + }); + + it('redacts Xtream credentials in playback URL paths', () => { + const username = 'xtream-path-user-secret'; + const password = 'xtream-path-password-secret'; + const urls = [ + new URL( + `https://example.com/base/live/${username}/${password}/101.ts` + ), + new URL( + `https://example.com/movie/${username}/${password}/202.mkv` + ), + new URL( + `https://example.com/series/${username}/${password}/303.mp4` + ), + new URL( + `https://example.com/timeshift/${username}/${password}/60/2026-07-18:12-00/404.ts` + ), + ]; + const embedded = `Playback failed for https://example.com/live/${username}/${password}/505.m3u8`; + const ordinaryLiveUrl = 'https://example.com/live/channel.m3u8'; + + const output = serialized( + redactSensitiveData({ urls, embedded, ordinaryLiveUrl }) + ); + + expect(output).not.toContain(username); + expect(output).not.toContain(password); + expect(output).toContain('101.ts'); + expect(output).toContain('202.mkv'); + expect(output).toContain('303.mp4'); + expect(output).toContain('404.ts'); + expect(output).toContain('505.m3u8'); + expect(output).toContain(ordinaryLiveUrl); + }); + + it('redacts Xtream path credentials when no resource segment follows', () => { + const username = 'terminal-xtream-user-secret'; + const password = 'terminal-xtream-password-secret'; + + const output = serialized( + redactSensitiveData( + new URL(`https://example.com/live/${username}/${password}`) + ) + ); + + expect(output).not.toContain(username); + expect(output).not.toContain(password); + }); + + it('does not mutate input and safely bounds cycles, depth, arrays, objects, and strings', () => { + const input: Record = { + status: 'ok', + password: TEST_SECRETS[2], + items: [1, 2, 3, 4], + long: 'abcdefghij', + nested: { level: { value: 'too deep' } }, + extraA: 'a', + extraB: 'b', + }; + input['self'] = input; + const originalItems = input['items']; + + const result = redactSensitiveData(input, { + maxArrayItems: 2, + maxDepth: 2, + maxObjectKeys: 6, + maxStringLength: 8, + }); + + expect(input['password']).toBe(TEST_SECRETS[2]); + expect(input['items']).toBe(originalItems); + expect(result).not.toBe(input); + expect(() => serialized(result)).not.toThrow(); + expect(serialized(result)).not.toContain(TEST_SECRETS[2]); + expect(serialized(result)).toContain('[Truncated'); + }); + + it('serializes invalid dates without throwing', () => { + const invalidDate = new Date(Number.NaN); + + expect(() => redactSensitiveData(invalidDate)).not.toThrow(); + expect(redactSensitiveData([invalidDate])).toEqual(['[Invalid Date]']); + }); + + it('preserves repeated non-circular references while still redacting them', () => { + const shared = { + operation: 'get_profile', + password: TEST_SECRETS[2], + }; + + const result = redactSensitiveData({ first: shared, second: shared }); + + expect(result).toEqual({ + first: { + operation: 'get_profile', + password: REDACTED_VALUE, + }, + second: { + operation: 'get_profile', + password: REDACTED_VALUE, + }, + }); + }); +}); diff --git a/libs/shared/logging/src/lib/redact-sensitive-data.ts b/libs/shared/logging/src/lib/redact-sensitive-data.ts new file mode 100644 index 000000000..cc42a4db2 --- /dev/null +++ b/libs/shared/logging/src/lib/redact-sensitive-data.ts @@ -0,0 +1,395 @@ +export const REDACTED_VALUE = '[Redacted]'; + +const CIRCULAR_VALUE = '[Circular]'; +const MAX_DEPTH_VALUE = '[MaxDepth]'; +const DEFAULT_MAX_DEPTH = 6; +const DEFAULT_MAX_ARRAY_ITEMS = 50; +const DEFAULT_MAX_OBJECT_KEYS = 50; +const DEFAULT_MAX_STRING_LENGTH = 2_000; + +const SENSITIVE_KEY_NAMES = new Set([ + 'apikey', + 'auth', + 'authorization', + 'cookie', + 'credentials', + 'deviceid', + 'deviceid2', + 'login', + 'mac', + 'macaddress', + 'mpvplayerarguments', + 'passwd', + 'password', + 'pwd', + 'secret', + 'setcookie', + 'signature', + 'signature2', + 'sn', + 'token', + 'username', + 'vlcplayerarguments', +]); + +const SENSITIVE_KEY_SUFFIXES = [ + 'apikey', 'authorization', 'cookie', + 'deviceid', 'deviceid1', 'deviceid2', + 'macaddress', 'passwd', 'password', 'prehash', + 'serialnumber', 'signature', 'signature1', 'signature2', + 'secret', 'token', 'username', +]; + +const XTREAM_CREDENTIAL_PATH_SEGMENTS = new Set([ + 'live', + 'movie', + 'series', + 'timeshift', +]); + +export interface RedactionOptions { + maxDepth?: number; + maxArrayItems?: number; + maxObjectKeys?: number; + maxStringLength?: number; +} + +interface ResolvedRedactionOptions { + maxDepth: number; + maxArrayItems: number; + maxObjectKeys: number; + maxStringLength: number; +} + +function normalizeKey(key: string): string { + return key.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function isSensitiveKey(key: string): boolean { + const normalized = normalizeKey(key); + return ( + SENSITIVE_KEY_NAMES.has(normalized) || + SENSITIVE_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix)) + ); +} + +function resolveOptions(options: RedactionOptions): ResolvedRedactionOptions { + return { + maxDepth: Math.max(0, options.maxDepth ?? DEFAULT_MAX_DEPTH), + maxArrayItems: Math.max( + 0, + options.maxArrayItems ?? DEFAULT_MAX_ARRAY_ITEMS + ), + maxObjectKeys: Math.max( + 0, + options.maxObjectKeys ?? DEFAULT_MAX_OBJECT_KEYS + ), + maxStringLength: Math.max( + 0, + options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH + ), + }; +} + +function truncateString(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + const omitted = value.length - maxLength; + return `${value.slice(0, maxLength)}[Truncated ${omitted} chars]`; +} + +function redactSearchParams( + params: URLSearchParams, + sanitizeValue: (value: string) => string +): URLSearchParams { + const redacted = new URLSearchParams(); + + params.forEach((value, key) => { + redacted.append( + key, + isSensitiveKey(key) ? REDACTED_VALUE : sanitizeValue(value) + ); + }); + + return redacted; +} + +function redactUrl( + value: URL, + sanitizeValue: (value: string) => string +): string { + const redacted = new URL(value.toString()); + + if (redacted.username) { + redacted.username = REDACTED_VALUE; + } + if (redacted.password) { + redacted.password = REDACTED_VALUE; + } + + const pathSegments = redacted.pathname.split('/'); + for (let index = 0; index < pathSegments.length - 2; index += 1) { + if (XTREAM_CREDENTIAL_PATH_SEGMENTS.has(pathSegments[index])) { + pathSegments[index + 1] = REDACTED_VALUE; + pathSegments[index + 2] = REDACTED_VALUE; + } + } + redacted.pathname = pathSegments.join('/'); + + const search = redactSearchParams( + redacted.searchParams, + sanitizeValue + ).toString(); + redacted.search = search ? `?${search}` : ''; + + const fragment = redacted.hash.slice(1); + if (looksLikeSearchParams(fragment)) { + const hash = redactSearchParams( + new URLSearchParams(fragment), + sanitizeValue + ).toString(); + redacted.hash = hash ? `#${hash}` : ''; + } + + return redacted.toString(); +} + +function redactUrlStrings( + value: string, + sanitizeValue: (value: string) => string +): string { + return value.replace( + /[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu, + (candidate) => { + try { + return redactUrl(new URL(candidate), sanitizeValue); + } catch { + return candidate; + } + } + ); +} + +function redactEmbeddedSensitivePairs(value: string): string { + const redactedAssignments = value.replace( + /\b([a-z][a-z0-9_.-]*)(\s*=\s*)((?:Bearer\s+)?[^&\s,;]+)/giu, + (match, key: string, separator: string) => + isSensitiveKey(key) + ? `${key}${separator}${REDACTED_VALUE}` + : match + ); + return redactedAssignments.replace( + /\b([a-z0-9_.-]*(?:api[-_.]?key|auth(?:orization)?|cookie|credentials|device[-_.]?id[12]?|login|mac(?:[-_.]?address)?|passwd|password|prehash|pwd|secret|serial[-_.]?number|set[-_.]?cookie|signature[12]?|sn|token|username))(\s*:\s*)(?:Bearer\s+)?[^;,\r\n]+/giu, + (_match, key: string, separator: string) => + `${key}${separator}${REDACTED_VALUE}` + ); +} + +function looksLikeSearchParams(value: string): boolean { + return /^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/u.test(value); +} + +export function redactSensitiveData( + value: unknown, + options: RedactionOptions = {} +): unknown { + const resolved = resolveOptions(options); + const seen = new WeakSet(); + const visitString = (input: string, depth: number): string => { + const trimmed = input.trim(); + if (depth >= resolved.maxDepth) { + return MAX_DEPTH_VALUE; + } + if ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ) { + try { + return truncateString( + JSON.stringify(visit(JSON.parse(trimmed), depth + 1)), + resolved.maxStringLength + ); + } catch { + // Keep processing malformed or non-JSON diagnostic strings. + } + } + if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed)) { + try { + return truncateString( + redactUrl(new URL(trimmed), (entry) => + visitString(entry, depth + 1) + ), + resolved.maxStringLength + ); + } catch { + // Continue with embedded URL handling for malformed URLs. + } + } + + if (looksLikeSearchParams(trimmed)) { + return truncateString( + redactSearchParams(new URLSearchParams(trimmed), (entry) => + visitString(entry, depth + 1) + ).toString(), + resolved.maxStringLength + ); + } + + const redactedText = redactEmbeddedSensitivePairs(input); + return truncateString( + redactUrlStrings(redactedText, (entry) => + visitString(entry, depth + 1) + ), + resolved.maxStringLength + ); + }; + + const visitObject = ( + input: Record, + depth: number + ): Record => { + const output: Record = {}; + const keys = Object.keys(input); + + for (const key of keys.slice(0, resolved.maxObjectKeys)) { + if (isSensitiveKey(key)) { + output[key] = REDACTED_VALUE; + continue; + } + + try { + output[key] = visit(input[key], depth + 1); + } catch { + output[key] = '[Unserializable]'; + } + } + + if (keys.length > resolved.maxObjectKeys) { + output['__truncatedKeys'] = keys.length - resolved.maxObjectKeys; + } + + return output; + }; + + const visitError = ( + error: Error, + depth: number + ): Record => { + const output = visitObject( + error as Error & Record, + depth + ); + output['name'] = visitString(error.name, depth + 1); + output['message'] = visitString(error.message, depth + 1); + if (error.stack) { + const [, ...stackFrames] = error.stack.split('\n'); + output['stack'] = visitString( + [`${output['name']}: ${output['message']}`, ...stackFrames].join( + '\n' + ), + depth + 1 + ); + } + if ('cause' in error) { + output['cause'] = visit(error.cause, depth + 1); + } + return output; + }; + + const visit = (input: unknown, depth: number): unknown => { + if ( + input == null || + typeof input === 'boolean' || + typeof input === 'number' + ) { + return input; + } + if (typeof input === 'string') { + return visitString(input, depth); + } + if (typeof input === 'bigint') { + return input.toString(); + } + if (typeof input === 'symbol') { + return input.toString(); + } + if (typeof input === 'function') { + return undefined; + } + if (depth >= resolved.maxDepth) { + return MAX_DEPTH_VALUE; + } + + const object = input as object; + if (seen.has(object)) { + return CIRCULAR_VALUE; + } + seen.add(object); + + try { + if (input instanceof URL) { + return redactUrl(input, (entry) => + visitString(entry, depth + 1) + ); + } + if (input instanceof URLSearchParams) { + return redactSearchParams(input, (entry) => + visitString(entry, depth + 1) + ).toString(); + } + if (input instanceof Error) { + return visitError(input, depth); + } + if (input instanceof Date) { + return Number.isNaN(input.getTime()) + ? '[Invalid Date]' + : input.toISOString(); + } + if (Array.isArray(input)) { + const output = input + .slice(0, resolved.maxArrayItems) + .map((entry) => visit(entry, depth + 1)); + if (input.length > resolved.maxArrayItems) { + output.push( + `[Truncated ${ + input.length - resolved.maxArrayItems + } items]` + ); + } + return output; + } + if (input instanceof Map) { + const entries: Record = {}; + const mapEntries = Array.from(input).slice( + 0, + resolved.maxObjectKeys + ); + for (const [key, entry] of mapEntries) { + const stringKey = String(key); + const redactedKey = visitString(stringKey, depth + 1); + entries[redactedKey] = + isSensitiveKey(stringKey) && + !/[/:?=&]/u.test(stringKey) + ? REDACTED_VALUE + : visit(entry, depth + 1); + } + if (input.size > resolved.maxObjectKeys) { + entries['__truncatedKeys'] = + input.size - resolved.maxObjectKeys; + } + return entries; + } + if (input instanceof Set) { + return visit(Array.from(input), depth); + } + + return visitObject(input as Record, depth); + } finally { + seen.delete(object); + } + }; + + return visit(value, 0); +} diff --git a/libs/shared/logging/tsconfig.json b/libs/shared/logging/tsconfig.json new file mode 100644 index 000000000..ab8e5af25 --- /dev/null +++ b/libs/shared/logging/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ] +} diff --git a/libs/shared/logging/tsconfig.lib.json b/libs/shared/logging/tsconfig.lib.json new file mode 100644 index 000000000..163d90724 --- /dev/null +++ b/libs/shared/logging/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/shared/logging/tsconfig.spec.json b/libs/shared/logging/tsconfig.spec.json new file mode 100644 index 000000000..4b0383fc4 --- /dev/null +++ b/libs/shared/logging/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index d29c4c0a7..baadc91f0 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -149,6 +149,13 @@ "validationCommand": "pnpm nx test shared-interfaces", "e2eTags": ["@m3u", "@xtream", "@stalker"] }, + { + "name": "shared-logging", + "root": "libs/shared/logging", + "sourceRoot": "libs/shared/logging/src", + "validationCommand": "pnpm nx test shared-logging", + "e2eTags": ["@electron", "@xtream", "@stalker", "@settings"] + }, { "name": "m3u-utils", "root": "libs/shared/m3u-utils", diff --git a/tsconfig.base.json b/tsconfig.base.json index 2e8cc3c8c..05edde3ed 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -80,6 +80,7 @@ "libs/shared/m3u-utils/src/index.ts" ], "@iptvnator/shared/testing": ["libs/shared/testing/src/index.ts"], + "@iptvnator/shared/logging": ["libs/shared/logging/src/index.ts"], "@iptvnator/services": ["libs/services/src/index.ts"], "@iptvnator/shared/interfaces": [ "libs/shared/interfaces/src/index.ts" From 45b6d8a0414d4c26a8a5624175b306f304cd4c3c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:56:26 +0200 Subject: [PATCH 07/26] fix(m3u): parse playlists with URLs longer than 2084 characters (#1204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(m3u): parse playlists with URLs longer than 2084 characters Pluto TV style playlists (issue #1189) embed a session JWT in every stream URL (~2200 chars). validator.isURL inside iptv-playlist-parser rejected anything over its IE-era 2084-char default, and the parser's stalled item index then collapsed the whole playlist into a single channel. Sync the 4gray/iptv-playlist-parser fork with upstream v0.15.2, which removes URL validation entirely and adds an explicit branch so '#' comments and unknown directives are never treated as URLs. Two fork deltas are preserved on top: the radio attribute (radio player detection) and pipe stripping (item.url is cut at the first '|' while |User-Agent=/|Referer= params still land in item.http). The now-dead validator/is-valid-path dependencies are dropped from the fork. - pin iptv-playlist-parser to the fork commit SHA - add a parser contract spec guarding long URLs, comment handling, radio, pipe stripping, and header EPG attrs - document the parser fork contract in the M3U architecture doc Fixes #1189 Co-Authored-By: Claude Fable 5 * chore(m3u): bump parser to optimized fork build Pulls the fork's optimized parse() rewrite (2.5-3x faster: 100k channels ~780ms -> ~285ms, 10k ~79ms -> ~25ms) and the README documenting fork deltas. Output is differential-verified byte-identical to the previous build; all parser-contract, unit, and import E2E suites rerun green against the new pin. Co-Authored-By: Claude Fable 5 * chore(m3u): bump parser for input robustness and library hygiene Pulls the fork's real-world input tolerance: UTF-8 BOM, blank lines and whitespace before the header, and case-insensitive #EXTM3U no longer reject the playlist (all VLC-accepted forms); Node Buffers are decoded as UTF-8 and other non-string input throws a clear TypeError. Also brings truthful types (url?: string), fork metadata, an enforced 100% coverage gate, and the fork CHANGELOG. Extends the contract spec with a BOM regression test. Co-Authored-By: Claude Fable 5 * chore(m3u): pin parser to the tagged fork release v0.15.2-iptvnator.1 Same commit as before (33f5e9c) — the readable tag replaces the raw SHA in package.json while pnpm-lock still records the immutable codeload tarball by commit. Fork release: https://github.com/4gray/iptv-playlist-parser/releases/tag/v0.15.2-iptvnator.1 Co-Authored-By: Claude Fable 5 * fix(types): make ParsedPlaylistItem.url optional to match runtime The parser fork's d.ts now truthfully declares url?: string (a trailing #EXTINF without a stream URL yields url === undefined at runtime, and always has). The local ParsedPlaylistItem mirrored the old type lie and made the production typecheck reject the parser's Playlist type. createChannel and createPlaylistObject already tolerate the absent url. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../app/iptv-playlist-parser.contract.spec.ts | 109 ++++++++++++++++++ docs/architecture/m3u-playlist-module.md | 13 +++ libs/services/src/lib/playlists.service.ts | 5 +- .../src/lib/parsed-playlist.interface.ts | 3 +- package.json | 2 +- pnpm-lock.yaml | 52 ++------- 6 files changed, 134 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/app/iptv-playlist-parser.contract.spec.ts diff --git a/apps/web/src/app/iptv-playlist-parser.contract.spec.ts b/apps/web/src/app/iptv-playlist-parser.contract.spec.ts new file mode 100644 index 000000000..85f2338cc --- /dev/null +++ b/apps/web/src/app/iptv-playlist-parser.contract.spec.ts @@ -0,0 +1,109 @@ +import { parse } from 'iptv-playlist-parser'; + +/** + * Contract tests for the 4gray/iptv-playlist-parser fork (jest maps the + * module to the real parser source via test-stubs/iptv-playlist-parser.mjs). + * Guards the fork-specific behaviors iptvnator depends on: + * - no URL length/format validation (issue #1189: Pluto JWT URLs > 2084 chars) + * - '#' comments never become URLs and never shift the item index + * - the fork-only `radio` attribute survives upstream syncs + * - `url` is stripped at the first '|' while pipe params land in `http.*` + */ +describe('iptv-playlist-parser contract (4gray fork)', () => { + const plutoUrl = (id: string) => + `https://cfd-v4-service-channel-stitcher-use1-1.prd.pluto.tv/v2/stitch/hls/channel/${id}/master.m3u8?jwt=${'e'.repeat(2100)}&masterJWTPassthrough=true`; + + it('parses playlists whose URLs are longer than 2084 characters (#1189)', () => { + const playlist = [ + '#EXTM3U', + '#EXTINF:-1 group-title="Anime & Geek" tvg-id="one" tvg-name="Beyblade",Beyblade', + plutoUrl('one'), + '#EXTINF:-1 group-title="TV Brasileira" tvg-id="two" tvg-name="TV Cultura",TV Cultura', + plutoUrl('two'), + '', + ].join('\n'); + + const result = parse(playlist); + + expect(result.items.length).toBe(2); + expect(result.items[0].name).toBe('Beyblade'); + expect(result.items[0].url).toBe(plutoUrl('one')); + expect(result.items[1].name).toBe('TV Cultura'); + expect(result.items[1].url).toBe(plutoUrl('two')); + }); + + it('ignores comments and unknown directives between #EXTINF and the URL', () => { + const playlist = [ + '#EXTM3U', + '#EXTINF:-1 tvg-id="one",Channel One', + '# stray comment', + '#EXT-X-SESSION-DATA:DATA-ID="com.example",VALUE="x"', + 'http://example.com/one.m3u8', + '#EXTINF:-1 tvg-id="two",Channel Two', + 'http://example.com/two.m3u8', + '', + ].join('\n'); + + const result = parse(playlist); + + expect(result.items.length).toBe(2); + expect(result.items[0].url).toBe('http://example.com/one.m3u8'); + expect(result.items[0].raw).toContain('# stray comment'); + expect(result.items[1].url).toBe('http://example.com/two.m3u8'); + }); + + it('parses the radio attribute used by the radio player', () => { + const playlist = [ + '#EXTM3U', + '#EXTINF:-1 radio="true" tvg-id="r1",Radio One', + 'http://example.com/radio1', + '#EXTINF:-1 tvg-id="tv1",TV One', + 'http://example.com/tv1', + '', + ].join('\n'); + + const result = parse(playlist); + + expect(result.items[0].radio).toBe('true'); + expect(result.items[1].radio).toBe(''); + }); + + it('strips pipe options from url while keeping them in http.*', () => { + const playlist = [ + '#EXTM3U', + '#EXTINF:-1,Piped', + 'http://example.com/stream.ts|User-Agent=CustomUA&Referer=http://ref.example', + '', + ].join('\n'); + + const result = parse(playlist); + + expect(result.items[0].url).toBe('http://example.com/stream.ts'); + expect(result.items[0].http['user-agent']).toBe('CustomUA'); + expect(result.items[0].http.referrer).toBe('http://ref.example'); + }); + + it('accepts playlists with a UTF-8 BOM before the header', () => { + const result = parse( + '#EXTM3U\n#EXTINF:-1,One\nhttp://example.com/one.m3u8\n' + ); + + expect(result.items.length).toBe(1); + expect(result.items[0].url).toBe('http://example.com/one.m3u8'); + }); + + it('exposes EPG urls from the playlist header', () => { + const result = parse( + [ + '#EXTM3U x-tvg-url="https://epg.example/guide.xml"', + '#EXTINF:-1,One', + 'http://example.com/one.m3u8', + '', + ].join('\n') + ); + + expect(result.header.attrs['x-tvg-url']).toBe( + 'https://epg.example/guide.xml' + ); + }); +}); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 9ba3d33e6..85847412d 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -42,6 +42,19 @@ The M3U playlist module provides: └─────────────────────────────────────────────────────────────────────┘ ``` +## M3U Parsing (`iptv-playlist-parser` fork) + +All four parse call sites (Electron `playlist-source.ts` import, `playlist-refresh.worker.ts`, `web-backend` `/parse`, PWA `playlists.service.ts`) use the +[4gray/iptv-playlist-parser](https://github.com/4gray/iptv-playlist-parser) fork, pinned by commit SHA in `package.json`. The fork tracks upstream +`freearhey/iptv-playlist-parser` (currently synced to v0.15.2) plus two deliberate deltas iptvnator depends on: + +- **`radio` attribute** — `item.radio` (string, `'true'` triggers the radio player, EPG suppression, and external-player gating app-wide). Upstream does not have this field; it must survive every upstream sync. +- **Pipe stripping** — `item.url` is cut at the first `|`; `|User-Agent=` / `|Referer=` params still land in `item.http`. Upstream 0.15.0 stopped stripping, but iptvnator consumes `item.url` verbatim in hls.js/mpv/vlc, catch-up URL building, and url-keyed favorites. + +There is intentionally **no URL validation** (upstream removed it in 0.15.0): any non-empty non-`#` line after `#EXTINF` becomes the item URL. This is what fixes issue #1189 (Pluto TV JWT URLs longer than validator's 2084-char IE-era limit used to be rejected, and the stalled item index collapsed the whole playlist into one channel). `#` comment lines and unknown directives are appended to `item.raw` and never treated as URLs. + +The behavioral contract is guarded by `apps/web/src/app/iptv-playlist-parser.contract.spec.ts` (jest maps the module to the real parser source) and by the fork's own test suite. + ## State Management (libs/m3u-state/) ### State Structure diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index f9fefc171..cc6938fc7 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -898,9 +898,8 @@ export class PlaylistsService { path?: string ) { try { - // Dynamic import keeps the ~130KB validator dep (transitively pulled - // by iptv-playlist-parser) out of the eager bundle. parse() only runs - // on user-triggered imports. + // Dynamic import keeps the parser out of the eager bundle; + // parse() only runs on user-triggered imports. const parserModule = await import('iptv-playlist-parser'); const parse = resolvePlaylistParser(parserModule); const parsedPlaylist = parse(playlist); diff --git a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts index 32f7a3473..350be337f 100644 --- a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts @@ -22,7 +22,8 @@ export interface ParsedPlaylistItem { referrer: string; 'user-agent': string; }; - url: string; + /** absent when an #EXTINF entry has no stream URL (e.g. truncated file) */ + url?: string; raw: string; catchup?: { type?: string; diff --git a/package.json b/package.json index 76f64db8a..3b726e78b 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "epg-parser": "^0.1.6", "fix-path": "5.0.0", "hls.js": "1.6.13", - "iptv-playlist-parser": "github:4gray/iptv-playlist-parser", + "iptv-playlist-parser": "github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.1", "marked": "18.0.5", "mpegts.js": "1.8.0", "ms": "2.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b151b699a..f01426f3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,8 +126,8 @@ importers: specifier: 1.6.13 version: 1.6.13 iptv-playlist-parser: - specifier: github:4gray/iptv-playlist-parser - version: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/38a62d0b7c98f442bfa102ee8e1fe14169ae9386 + specifier: github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.1 + version: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/33f5e9c09539524d758901c02a6f29302833c0a4 marked: specifier: 18.0.5 version: 18.0.5 @@ -7594,9 +7594,10 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} - iptv-playlist-parser@https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/38a62d0b7c98f442bfa102ee8e1fe14169ae9386: - resolution: {tarball: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/38a62d0b7c98f442bfa102ee8e1fe14169ae9386} - version: 0.12.2 + iptv-playlist-parser@https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/33f5e9c09539524d758901c02a6f29302833c0a4: + resolution: {tarball: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/33f5e9c09539524d758901c02a6f29302833c0a4} + version: 0.15.2-iptvnator.1 + engines: {node: '>=18'} iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -7663,10 +7664,6 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true - is-extglob@1.0.0: - resolution: {integrity: sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==} - engines: {node: '>=0.10.0'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -7694,10 +7691,6 @@ packages: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} - is-glob@2.0.1: - resolution: {integrity: sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==} - engines: {node: '>=0.10.0'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -7718,10 +7711,6 @@ packages: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} - is-invalid-path@0.1.0: - resolution: {integrity: sha512-aZMG0T3F34mTg4eTdszcGXx54oiZ4NtHSft3hWNJMGJXUUqdIj3cOZuHcU0nCWWcY3jd7yRe/3AEm3vSNTpBGQ==} - engines: {node: '>=0.10.0'} - is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} @@ -7819,10 +7808,6 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} - is-valid-path@0.1.1: - resolution: {integrity: sha512-+kwPrVDu9Ms03L90Qaml+79+6DZHqHyRoANI6IsZJ/g8frhnfchDOBCa0RbQ6/kdHt5CS5OeIEyrYznNuVN+8A==} - engines: {node: '>=0.10.0'} - is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -11544,10 +11529,6 @@ packages: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} engines: {node: ^20.17.0 || >=22.9.0} - validator@13.15.26: - resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} - engines: {node: '>= 0.10'} - varint@6.0.0: resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} @@ -20610,10 +20591,7 @@ snapshots: ipaddr.js@2.3.0: {} - iptv-playlist-parser@https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/38a62d0b7c98f442bfa102ee8e1fe14169ae9386: - dependencies: - is-valid-path: 0.1.1 - validator: 13.15.26 + iptv-playlist-parser@https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/33f5e9c09539524d758901c02a6f29302833c0a4: {} iron-webcrypto@1.2.1: {} @@ -20680,8 +20658,6 @@ snapshots: is-docker@3.0.0: {} - is-extglob@1.0.0: {} - is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -20706,10 +20682,6 @@ snapshots: has-tostringtag: 1.0.2 safe-regex-test: 1.1.0 - is-glob@2.0.1: - dependencies: - is-extglob: 1.0.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -20724,10 +20696,6 @@ snapshots: is-interactive@2.0.0: {} - is-invalid-path@0.1.0: - dependencies: - is-glob: 2.0.1 - is-lambda@1.0.1: {} is-map@2.0.3: {} @@ -20799,10 +20767,6 @@ snapshots: is-unicode-supported@2.1.0: {} - is-valid-path@0.1.1: - dependencies: - is-invalid-path: 0.1.0 - is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -25378,8 +25342,6 @@ snapshots: validate-npm-package-name@7.0.2: {} - validator@13.15.26: {} - varint@6.0.0: {} vary@1.1.2: {} From ec6fc403a3cdbd601d46b1ac0b4c20c06c058708 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 19 Jul 2026 09:00:14 +0200 Subject: [PATCH 08/26] feat: manual EPG-to-channel mapping with mapping fallback (#1165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(epg): manual EPG-to-channel mapping with mapping fallback in all EPG paths * fix: epg mapping in live tv list * fix(epg): harden manual EPG mapping — upgrade safety, perf, playlist-scoped keys Follow-up fixes on top of the manual EPG-to-channel mapping feature: - epg-database: dedupe existing epg_programs rows before creating the unique (channel_id, start, title) index — a plain CREATE UNIQUE INDEX crashed the EPG worker on upgrade when historical duplicates exist; replace INSERT OR REPLACE with ON CONFLICT DO UPDATE so the epg_programs_fts delete trigger is not bypassed (REPLACE skips delete triggers unless recursive_triggers is on), with a plain-INSERT fallback when the index cannot be created - db: add idx_content_epg_channel — the mapping fallback scanned the whole content table on every single-channel EPG lookup - keys: scope Xtream mapping keys per playlist via shared buildXtreamEpgMappingKey (xtream:{playlistId}:{id}) — bare stream ids collide across portals; the backend fallback now joins categories to resolve the playlist id - pwa: hide "Map EPG channel" entries behind the supportsEpgMapping capability — the menu item was a dead end in the PWA - parser: parse the XMLTV offset sign from the string — Math.sign(0) dropped the minutes of ±00:xx offsets - cleanup: typed window.electron access instead of ad-hoc casts, drop unused resolveChannelId and dialog data field, shared EpgMappingDialogComponent.open() for all seven call sites - dialog UX: minimum-characters search hint, save/remove snackbars, current mapping shows the EPG channel display name, takeUntilDestroyed on the search stream - i18n: fill the new keys in all 17 locales - tests: cover mapping CRUD/search escaping, the dedup-index guard, offset parsing and playlist-scoped keys; update stale stream-resolver specs for the new 50-item limit and 10s timeout Co-Authored-By: Claude Fable 5 * fix(epg): escape backslashes in EPG channel search LIKE pattern CodeQL js/incomplete-sanitization: a lone trailing backslash in the search term paired with the closing wildcard under the ESCAPE clause and corrupted the pattern. Co-Authored-By: Claude Fable 5 * perf(epg): batch mapping lookups in the viewport preview queue Expose the existing getEpgMappingsBatch operation over a new EPG_MAPPING_GET_BATCH IPC channel and use it in resolveManualMappings — the per-entry lookup issued one IPC round-trip per visible channel on every scroll event. Co-Authored-By: Claude Fable 5 * perf(epg): batch mapping prefetch in the collection preview loader Resolve all candidate mapping keys for an Xtream preview batch with a single getEpgMappingsBatch IPC call instead of per-channel lookups. Also fix a worker early-exit: a channel without tvgId/name returned out of the shared-iterator loop and silently killed one of the three concurrent preview workers. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: 4gray Co-authored-by: Claude Fable 5 --- .../src/app/api/main.preload.ts | 10 + .../operations/epg-mapping.operations.spec.ts | 223 ++++++++++++++++++ .../operations/epg-mapping.operations.ts | 125 ++++++++++ .../src/app/database/schema.ts | 1 + .../src/app/events/epg-query.service.ts | 79 ++++++- .../src/app/events/epg.events.spec.ts | 74 +++--- .../src/app/events/epg.events.ts | 183 +++++++++++++- .../src/app/workers/epg-database.spec.ts | 98 +++++++- .../src/app/workers/epg-database.ts | 68 +++++- .../app/workers/epg-streaming-parser.spec.ts | 33 ++- .../src/app/workers/epg-streaming-parser.ts | 28 ++- apps/web/src/assets/i18n/ar.json | 16 ++ apps/web/src/assets/i18n/ary.json | 16 ++ apps/web/src/assets/i18n/by.json | 16 ++ apps/web/src/assets/i18n/de.json | 16 ++ apps/web/src/assets/i18n/el.json | 16 ++ apps/web/src/assets/i18n/en.json | 16 ++ apps/web/src/assets/i18n/es.json | 16 ++ apps/web/src/assets/i18n/fr.json | 16 ++ apps/web/src/assets/i18n/it.json | 16 ++ apps/web/src/assets/i18n/ja.json | 16 ++ apps/web/src/assets/i18n/ko.json | 16 ++ apps/web/src/assets/i18n/nl.json | 16 ++ apps/web/src/assets/i18n/pl.json | 16 ++ apps/web/src/assets/i18n/pt.json | 16 ++ apps/web/src/assets/i18n/ru.json | 16 ++ apps/web/src/assets/i18n/tr.json | 16 ++ apps/web/src/assets/i18n/zh.json | 16 ++ apps/web/src/assets/i18n/zhtw.json | 16 ++ .../src/lib/epg-runtime-bridge.service.ts | 73 +++++- .../stream-resolver.service.spec.ts | 57 ++++- .../lib/collection/stream-resolver.service.ts | 223 ++++++++++++++++-- .../global-favorites-list.component.html | 6 + .../global-favorites-list.component.ts | 39 ++- .../src/lib/services/epg-queue.service.ts | 74 +++++- .../lib/stores/features/with-epg.feature.ts | 29 ++- .../portal-channels-list.component.html | 18 ++ .../portal-channels-list.component.scss | 8 + .../portal-channels-list.component.ts | 56 +++++ .../src/lib/runtime-capabilities.service.ts | 9 + libs/shared/database/src/lib/connection.ts | 10 + libs/shared/database/src/lib/schema.ts | 23 ++ libs/shared/interfaces/src/index.ts | 1 + .../src/lib/electron-api.interface.ts | 32 +++ .../src/lib/epg-mapping-key.util.spec.ts | 21 ++ .../src/lib/epg-mapping-key.util.ts | 21 ++ libs/ui/components/src/index.ts | 1 + .../all-channels-view.component.html | 6 + .../all-channels-view.component.ts | 22 ++ .../channel-details-dialog.component.html | 6 + .../channel-details-dialog.component.ts | 24 +- .../epg-mapping-dialog.component.html | 77 ++++++ .../epg-mapping-dialog.component.scss | 95 ++++++++ .../epg-mapping-dialog.component.ts | 205 ++++++++++++++++ .../favorites-view.component.html | 6 + .../favorites-view.component.ts | 23 ++ .../groups-view/groups-view.component.html | 6 + .../groups-view/groups-view.component.ts | 23 ++ .../recent-view/recent-view.component.html | 6 + .../recent-view/recent-view.component.ts | 24 ++ 60 files changed, 2343 insertions(+), 91 deletions(-) create mode 100644 apps/electron-backend/src/app/database/operations/epg-mapping.operations.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/epg-mapping.operations.ts create mode 100644 libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/epg-mapping-key.util.ts create mode 100644 libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html create mode 100644 libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.scss create mode 100644 libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 43c7a2de7..f232d88d4 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -526,6 +526,16 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }), searchEpgPrograms: (searchTerm: string, limit?: number) => ipcRenderer.invoke('EPG_DB_SEARCH_PROGRAMS', searchTerm, limit), + getEpgMapping: (channelKey: string) => + ipcRenderer.invoke('EPG_MAPPING_GET', { channelKey }), + getEpgMappingsBatch: (channelKeys: string[]) => + ipcRenderer.invoke('EPG_MAPPING_GET_BATCH', { channelKeys }), + setEpgMapping: (channelKey: string, epgChannelId: string, playlistId?: string) => + ipcRenderer.invoke('EPG_MAPPING_SET', { channelKey, epgChannelId, playlistId }), + deleteEpgMapping: (channelKey: string) => + ipcRenderer.invoke('EPG_MAPPING_DELETE', { channelKey }), + searchEpgChannels: (searchTerm: string, limit?: number) => + ipcRenderer.invoke('EPG_CHANNEL_SEARCH', { searchTerm, limit }), setMpvPlayerPath: (mpvPlayerPath: string) => ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath), setVlcPlayerPath: (vlcPlayerPath: string) => diff --git a/apps/electron-backend/src/app/database/operations/epg-mapping.operations.spec.ts b/apps/electron-backend/src/app/database/operations/epg-mapping.operations.spec.ts new file mode 100644 index 000000000..337aa8b85 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/epg-mapping.operations.spec.ts @@ -0,0 +1,223 @@ +const eqMock = jest.fn((left: unknown, right: unknown) => ({ + kind: 'eq', + left, + right, +})); +const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({ + kind: 'inArray', + left, + values, +})); +const orMock = jest.fn((...conditions: unknown[]) => ({ + kind: 'or', + conditions, +})); +const sqlMock = jest.fn( + (strings: TemplateStringsArray, ...values: unknown[]) => ({ + kind: 'sql', + strings: Array.from(strings), + values, + }) +); + +jest.mock('drizzle-orm', () => ({ + and: jest.fn(), + eq: (left: unknown, right: unknown) => eqMock(left, right), + inArray: (left: unknown, values: unknown[]) => inArrayMock(left, values), + or: (...conditions: unknown[]) => orMock(...conditions), + sql: (strings: TemplateStringsArray, ...values: unknown[]) => + sqlMock(strings, ...values), +})); + +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; +import { + deleteEpgMapping, + getEpgMapping, + getEpgMappingsBatch, + searchEpgChannels, + setEpgMapping, +} from './epg-mapping.operations'; + +function createSelectChain(result: unknown[]) { + const chain: Record = {}; + chain['from'] = jest.fn().mockReturnValue(chain); + chain['where'] = jest.fn().mockReturnValue(chain); + chain['orderBy'] = jest.fn().mockReturnValue(chain); + chain['limit'] = jest.fn().mockResolvedValue(result); + chain['then'] = ( + resolve: (value: unknown) => unknown, + reject: (reason: unknown) => unknown + ) => Promise.resolve(result).then(resolve, reject); + return chain; +} + +function createDbMock(selectResult: unknown[] = []) { + const chain = createSelectChain(selectResult); + const onConflictDoUpdate = jest.fn().mockResolvedValue(undefined); + const values = jest.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = jest.fn().mockReturnValue({ values }); + const deleteWhere = jest.fn().mockResolvedValue(undefined); + const deleteFn = jest.fn().mockReturnValue({ where: deleteWhere }); + const select = jest.fn().mockReturnValue(chain); + + return { + db: { + select, + insert, + delete: deleteFn, + } as unknown as AppDatabase, + chain, + insert, + values, + onConflictDoUpdate, + deleteFn, + deleteWhere, + select, + }; +} + +describe('epg-mapping.operations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getEpgMapping', () => { + it('returns the mapping row when present', async () => { + const row = { + id: 1, + channelKey: 'xtream:pl-1:42', + epgChannelId: 'bbc.one.uk', + playlistId: 'pl-1', + }; + const { db } = createDbMock([row]); + + await expect(getEpgMapping(db, 'xtream:pl-1:42')).resolves.toEqual( + row + ); + expect(eqMock).toHaveBeenCalledWith( + schema.epgChannelMappings.channelKey, + 'xtream:pl-1:42' + ); + }); + + it('returns null when no mapping exists', async () => { + const { db } = createDbMock([]); + + await expect(getEpgMapping(db, 'missing')).resolves.toBeNull(); + }); + }); + + describe('getEpgMappingsBatch', () => { + it('returns an empty map without querying for an empty input', async () => { + const { db, select } = createDbMock(); + + const result = await getEpgMappingsBatch(db, []); + + expect(result.size).toBe(0); + expect(select).not.toHaveBeenCalled(); + }); + + it('maps channel keys to their EPG channel ids', async () => { + const { db } = createDbMock([ + { channelKey: 'a', epgChannelId: 'epg-a' }, + { channelKey: 'b', epgChannelId: 'epg-b' }, + ]); + + const result = await getEpgMappingsBatch(db, ['a', 'b', 'c']); + + expect(result.get('a')).toBe('epg-a'); + expect(result.get('b')).toBe('epg-b'); + expect(result.has('c')).toBe(false); + expect(inArrayMock).toHaveBeenCalledWith( + schema.epgChannelMappings.channelKey, + ['a', 'b', 'c'] + ); + }); + }); + + describe('setEpgMapping', () => { + it('upserts on the channel key', async () => { + const { db, insert, values, onConflictDoUpdate } = createDbMock(); + + await expect( + setEpgMapping(db, 'xtream:pl-1:42', 'bbc.one.uk', 'pl-1') + ).resolves.toEqual({ success: true }); + + expect(insert).toHaveBeenCalledWith(schema.epgChannelMappings); + expect(values).toHaveBeenCalledWith({ + channelKey: 'xtream:pl-1:42', + epgChannelId: 'bbc.one.uk', + playlistId: 'pl-1', + }); + expect(onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + target: schema.epgChannelMappings.channelKey, + }) + ); + }); + + it('stores null when no playlist id is provided', async () => { + const { db, values } = createDbMock(); + + await setEpgMapping(db, 'bbc.one', 'bbc.one.uk'); + + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ playlistId: null }) + ); + }); + }); + + describe('deleteEpgMapping', () => { + it('deletes by channel key', async () => { + const { db, deleteFn, deleteWhere } = createDbMock(); + + await expect(deleteEpgMapping(db, 'bbc.one')).resolves.toEqual({ + success: true, + }); + + expect(deleteFn).toHaveBeenCalledWith(schema.epgChannelMappings); + expect(deleteWhere).toHaveBeenCalled(); + expect(eqMock).toHaveBeenCalledWith( + schema.epgChannelMappings.channelKey, + 'bbc.one' + ); + }); + }); + + describe('searchEpgChannels', () => { + it('escapes LIKE wildcards and searches name and id with an ESCAPE clause', async () => { + const { db } = createDbMock([]); + + await searchEpgChannels(db, 'HBO%_'); + + // sql`${column} LIKE ${pattern} ESCAPE '\\'` — the pattern is the + // second interpolated value. + const patterns = sqlMock.mock.calls.map((call) => call[2]); + expect(patterns).toContain('%HBO\\%\\_%'); + const templates = sqlMock.mock.calls.map((call) => + Array.from(call[0] as TemplateStringsArray).join('?') + ); + expect( + templates.every((template) => template.includes("ESCAPE '\\'")) + ).toBe(true); + }); + + it('escapes backslashes so a trailing "\\" cannot corrupt the pattern', async () => { + const { db } = createDbMock([]); + + await searchEpgChannels(db, 'C\\'); + + const patterns = sqlMock.mock.calls.map((call) => call[2]); + expect(patterns).toContain('%C\\\\%'); + }); + + it('applies the result limit', async () => { + const { db, chain } = createDbMock([]); + + await searchEpgChannels(db, 'news', 5); + + expect(chain['limit']).toHaveBeenCalledWith(5); + }); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/epg-mapping.operations.ts b/apps/electron-backend/src/app/database/operations/epg-mapping.operations.ts new file mode 100644 index 000000000..1f19e7934 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/epg-mapping.operations.ts @@ -0,0 +1,125 @@ +import { and, eq, inArray, or, sql } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; + +/** + * Get a single EPG channel mapping by lookup key. + */ +export async function getEpgMapping( + db: AppDatabase, + channelKey: string +): Promise<{ id: number; channelKey: string; epgChannelId: string; playlistId: string | null } | null> { + const rows = await db + .select({ + id: schema.epgChannelMappings.id, + channelKey: schema.epgChannelMappings.channelKey, + epgChannelId: schema.epgChannelMappings.epgChannelId, + playlistId: schema.epgChannelMappings.playlistId, + }) + .from(schema.epgChannelMappings) + .where(eq(schema.epgChannelMappings.channelKey, channelKey)) + .limit(1); + + return rows[0] ?? null; +} + +/** + * Batch lookup of EPG channel mappings for multiple keys. + * Returns a Map of channelKey → epgChannelId (only for keys that have mappings). + */ +export async function getEpgMappingsBatch( + db: AppDatabase, + channelKeys: string[] +): Promise> { + if (channelKeys.length === 0) { + return new Map(); + } + + const rows = await db + .select({ + channelKey: schema.epgChannelMappings.channelKey, + epgChannelId: schema.epgChannelMappings.epgChannelId, + }) + .from(schema.epgChannelMappings) + .where(inArray(schema.epgChannelMappings.channelKey, channelKeys)); + + const mapping = new Map(); + for (const row of rows) { + mapping.set(row.channelKey, row.epgChannelId); + } + return mapping; +} + +/** + * Create or update an EPG channel mapping (upsert). + */ +export async function setEpgMapping( + db: AppDatabase, + channelKey: string, + epgChannelId: string, + playlistId?: string +): Promise<{ success: boolean }> { + await db + .insert(schema.epgChannelMappings) + .values({ + channelKey, + epgChannelId, + playlistId: playlistId ?? null, + }) + .onConflictDoUpdate({ + target: schema.epgChannelMappings.channelKey, + set: { + epgChannelId, + playlistId: playlistId ?? null, + updatedAt: sql`(datetime('now'))`, + }, + }); + + return { success: true }; +} + +/** + * Remove an EPG channel mapping. + */ +export async function deleteEpgMapping( + db: AppDatabase, + channelKey: string +): Promise<{ success: boolean }> { + await db + .delete(schema.epgChannelMappings) + .where(eq(schema.epgChannelMappings.channelKey, channelKey)); + + return { success: true }; +} + +/** + * Search EPG channels by display name for the mapping dialog. + */ +export async function searchEpgChannels( + db: AppDatabase, + searchTerm: string, + limit = 50 +): Promise> { + // Escape the escape character itself and the LIKE wildcards so user + // input like "HBO%", "_BC" or a trailing "\" is matched literally — + // an unescaped backslash would pair with the following character (or + // the closing "%") under the ESCAPE clause and corrupt the pattern. + const escaped = searchTerm.trim().replace(/[\\%_]/g, '\\$&'); + const pattern = `%${escaped}%`; + + return db + .select({ + id: schema.epgChannels.id, + displayName: schema.epgChannels.displayName, + iconUrl: schema.epgChannels.iconUrl, + }) + .from(schema.epgChannels) + .where( + or( + sql`${schema.epgChannels.displayName} LIKE ${pattern} ESCAPE '\\'`, + sql`${schema.epgChannels.id} LIKE ${pattern} ESCAPE '\\'` + ) + ) + .orderBy(schema.epgChannels.displayName) + .limit(limit); +} diff --git a/apps/electron-backend/src/app/database/schema.ts b/apps/electron-backend/src/app/database/schema.ts index 84dfb64ae..5cbc118ec 100644 --- a/apps/electron-backend/src/app/database/schema.ts +++ b/apps/electron-backend/src/app/database/schema.ts @@ -11,6 +11,7 @@ export { recentlyViewed, favorites, epgChannels, + epgChannelMappings, epgPrograms, playbackPositions, downloads, diff --git a/apps/electron-backend/src/app/events/epg-query.service.ts b/apps/electron-backend/src/app/events/epg-query.service.ts index 63db5b36e..75c8a23c4 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -1,5 +1,9 @@ import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm'; -import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + buildXtreamEpgMappingKey, + EpgChannelMetadata, + EpgProgram, +} from '@iptvnator/shared/interfaces'; import { getDatabase } from '../database/connection'; import * as schema from '../database/schema'; @@ -36,13 +40,21 @@ export class EpgQueryService { ): Promise { try { const db = await getDatabase(); - const trimmedChannelId = channelId.trim(); + let trimmedChannelId = channelId.trim(); const sourceUrls = this.normalizeSourceUrls(options.sourceUrls); if (!trimmedChannelId) { return []; } + // Check for manual EPG mapping — if a user has mapped this + // channel key to a different EPG channel ID, use the mapped + // ID for all subsequent lookups. + const mapped = await this.getMapping(db, trimmedChannelId); + if (mapped) { + trimmedChannelId = mapped; + } + let results = await this.selectChannelPrograms( db, trimmedChannelId, @@ -905,6 +917,69 @@ export class EpgQueryService { !Number.isNaN(new Date(program.stop).getTime()) ); } + + private async getMapping( + db: EpgDatabase, + channelKey: string + ): Promise { + try { + // Direct lookup by channel key. + const rows = await db + .select({ + epgChannelId: schema.epgChannelMappings.epgChannelId, + }) + .from(schema.epgChannelMappings) + .where( + eq(schema.epgChannelMappings.channelKey, channelKey) + ) + .limit(1); + if (rows.length > 0) { + return rows[0].epgChannelId; + } + + // Fallback: the key may be an Xtream provider epg_channel_id + // while the mapping was saved under the playlist-scoped Xtream + // key. Resolve the owning streams (joined through categories for + // the playlist ID) and check their mapping keys. The same + // epg_channel_id can appear in several playlists, so check a few. + const contentRows = await db + .select({ + xtreamId: schema.content.xtreamId, + playlistId: schema.categories.playlistId, + }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where(eq(schema.content.epgChannelId, channelKey)) + .limit(5); + if (contentRows.length > 0) { + const candidateKeys = contentRows.map((row) => + buildXtreamEpgMappingKey(row.playlistId, row.xtreamId) + ); + const mapped = await db + .select({ + epgChannelId: schema.epgChannelMappings.epgChannelId, + }) + .from(schema.epgChannelMappings) + .where( + inArray( + schema.epgChannelMappings.channelKey, + candidateKeys + ) + ) + .limit(1); + if (mapped.length > 0) { + return mapped[0].epgChannelId; + } + } + + return null; + } catch { + return null; + } + } } export const epgQueryService = new EpgQueryService(); diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index efbc14696..6f6799a9a 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -371,46 +371,46 @@ describe('EpgEvents', () => { it('falls back to case-insensitive channel id lookup for EPG programs', async () => { const select = jest.fn(); - const programLimitExact = jest.fn().mockResolvedValue([]); - const channelLimit = jest - .fn() - .mockResolvedValue([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }]); - const programLimitResolved = jest.fn().mockResolvedValue([ - { - id: 1, - channelId: 'BBC.ONE.UK', - start: '2026-04-14T10:00:00Z', - stop: '2026-04-14T11:00:00Z', - title: 'News', - description: null, - category: null, - iconUrl: null, - rating: null, - episodeNum: null, - }, - ]); + /** + * Build a flexible query-chain mock that every db.select().from().() call + * resolves to the same chain object. Each method (where, orderBy, limit) returns + * the chain itself, and limit() additionally resolves to the given data. This + * handles any query shape our service uses (with or without orderBy) without + * requiring exact call-count ordering — important because our mapping feature + * inserts extra queries that the original test didn't anticipate. + */ + function queryChain(data: T) { + const chain: Record = {} as Record; + chain.where = jest.fn().mockReturnValue(chain); + chain.innerJoin = jest.fn().mockReturnValue(chain); + chain.orderBy = jest.fn().mockReturnValue(chain); + chain.limit = jest.fn().mockResolvedValue(data); + return chain; + } + + // getMapping queries (must come first — they return empty) const from = jest .fn() - .mockReturnValueOnce({ - where: jest.fn().mockReturnValue({ - orderBy: jest.fn().mockReturnValue({ - limit: programLimitExact, - }), - }), - }) - .mockReturnValueOnce({ - where: jest.fn().mockReturnValue({ - limit: channelLimit, - }), - }) - .mockReturnValueOnce({ - where: jest.fn().mockReturnValue({ - orderBy: jest.fn().mockReturnValue({ - limit: programLimitResolved, - }), - }), - }); + .mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings + .mockReturnValueOnce(queryChain([])) // 2. getMapping: content table + // Test expectations below + .mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty) + .mockReturnValueOnce(queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }])) // 4. selectChannelById → channel found + .mockReturnValueOnce(queryChain([ // 5. selectChannelPrograms (BBC.ONE.UK → programs) + { + id: 1, + channelId: 'BBC.ONE.UK', + start: '2026-04-14T10:00:00Z', + stop: '2026-04-14T11:00:00Z', + title: 'News', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + }, + ])); select.mockImplementation(() => ({ from })); diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 7a371ca72..795f93a75 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -9,6 +9,13 @@ import { getDatabase } from '../database/connection'; import * as schema from '../database/schema'; import { epgQueryService } from './epg-query.service'; import { epgWorkerService } from './epg-worker.service'; +import { + getEpgMapping, + getEpgMappingsBatch, + setEpgMapping, + deleteEpgMapping, + searchEpgChannels, +} from '../database/operations/epg-mapping.operations'; /** * EPG Events Handler @@ -129,6 +136,59 @@ export default class EpgEvents { } ); + // EPG channel mapping CRUD + ipcMain.handle( + 'EPG_MAPPING_GET', + async (_event, args: { channelKey: string }) => { + return this.handleGetEpgMapping(args.channelKey); + } + ); + + ipcMain.handle( + 'EPG_MAPPING_SET', + async ( + _event, + args: { + channelKey: string; + epgChannelId: string; + playlistId?: string; + } + ) => { + return this.handleSetEpgMapping( + args.channelKey, + args.epgChannelId, + args.playlistId + ); + } + ); + + ipcMain.handle( + 'EPG_MAPPING_GET_BATCH', + async (_event, args: { channelKeys: string[] }) => { + return this.handleGetEpgMappingsBatch(args.channelKeys); + } + ); + + ipcMain.handle( + 'EPG_MAPPING_DELETE', + async (_event, args: { channelKey: string }) => { + return this.handleDeleteEpgMapping(args.channelKey); + } + ); + + ipcMain.handle( + 'EPG_CHANNEL_SEARCH', + async ( + _event, + args: { searchTerm: string; limit?: number } + ) => { + return this.handleSearchEpgChannels( + args.searchTerm, + args.limit + ); + } + ); + return ipcMain; } @@ -294,7 +354,21 @@ export default class EpgEvents { channelIds: string[], options?: { sourceUrls?: string[] } ): Promise> { - return epgQueryService.getCurrentProgramsBatch(channelIds, options); + const resolvedMap = await this.resolveChannelIds(channelIds); + const resolvedIds = channelIds.map( + (id) => resolvedMap.get(id) ?? id + ); + const results = await epgQueryService.getCurrentProgramsBatch( + resolvedIds, + options + ); + // Remap results back to the original request keys. + const remapped: Record = {}; + for (const originalId of channelIds) { + const resolvedId = resolvedMap.get(originalId) ?? originalId; + remapped[originalId] = results[resolvedId] ?? null; + } + return remapped; } private static async handleGetAllChannels(): Promise<{ @@ -308,7 +382,21 @@ export default class EpgEvents { channelIds: string[], options?: { sourceUrls?: string[] } ): Promise> { - return epgQueryService.getChannelMetadata(channelIds, options); + const resolvedMap = await this.resolveChannelIds(channelIds); + const resolvedIds = channelIds.map( + (id) => resolvedMap.get(id) ?? id + ); + const results = await epgQueryService.getChannelMetadata( + resolvedIds, + options + ); + // Remap results back to the original request keys. + const remapped: Record = {}; + for (const originalId of channelIds) { + const resolvedId = resolvedMap.get(originalId) ?? originalId; + remapped[originalId] = results[resolvedId] ?? null; + } + return remapped; } private static async handleGetChannelsByRange( @@ -325,6 +413,97 @@ export default class EpgEvents { return epgQueryService.getChannelsByRange(skip, limit); } + // --------------------------------------------------------------------------- + // EPG mapping resolution — called at the IPC boundary before queries. + // --------------------------------------------------------------------------- + + /** + * Batch-resolve multiple channel IDs through manual mappings. + * Returns a Map of original ID → mapped ID (identity when no mapping). + */ + private static async resolveChannelIds( + channelIds: string[] + ): Promise> { + try { + const db = await getDatabase(); + const mappings = await getEpgMappingsBatch(db, channelIds); + return mappings; + } catch { + return new Map(); + } + } + + // --------------------------------------------------------------------------- + // EPG mapping CRUD handlers + // --------------------------------------------------------------------------- + + private static async handleGetEpgMapping( + channelKey: string + ): Promise<{ id: number; channelKey: string; epgChannelId: string; playlistId: string | null } | null> { + try { + const db = await getDatabase(); + return getEpgMapping(db, channelKey); + } catch { + return null; + } + } + + private static async handleGetEpgMappingsBatch( + channelKeys: string[] + ): Promise> { + if (!Array.isArray(channelKeys) || channelKeys.length === 0) { + return {}; + } + + try { + const db = await getDatabase(); + const mappings = await getEpgMappingsBatch(db, channelKeys); + return Object.fromEntries(mappings); + } catch { + return {}; + } + } + + private static async handleSetEpgMapping( + channelKey: string, + epgChannelId: string, + playlistId?: string + ): Promise<{ success: boolean }> { + try { + const db = await getDatabase(); + return setEpgMapping(db, channelKey, epgChannelId, playlistId); + } catch { + return { success: false }; + } + } + + private static async handleDeleteEpgMapping( + channelKey: string + ): Promise<{ success: boolean }> { + try { + const db = await getDatabase(); + return deleteEpgMapping(db, channelKey); + } catch { + return { success: false }; + } + } + + private static async handleSearchEpgChannels( + searchTerm: string, + limit?: number + ): Promise> { + if (!searchTerm?.trim()) { + return []; + } + + try { + const db = await getDatabase(); + return searchEpgChannels(db, searchTerm, limit); + } catch { + return []; + } + } + static async clearEpgData(): Promise { return epgWorkerService.clearEpgData(); } diff --git a/apps/electron-backend/src/app/workers/epg-database.spec.ts b/apps/electron-backend/src/app/workers/epg-database.spec.ts index 2ec863a8b..ed7c5e1a2 100644 --- a/apps/electron-backend/src/app/workers/epg-database.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-database.spec.ts @@ -21,12 +21,30 @@ function normalizeSql(sql: unknown): string { return String(sql).replace(/\s+/g, ' ').trim(); } -function createEpgDatabaseMock() { +function createEpgDatabaseMock( + options: { + /** Result of the sqlite_master index-existence probe. */ + dedupIndexExists?: boolean; + /** Make the CREATE UNIQUE INDEX statement throw on run(). */ + failIndexCreation?: boolean; + } = {} +) { const statements = new Map(); const prepare = jest.fn((statement: string) => { - const run = jest.fn(); - statements.set(normalizeSql(statement), run); - return { run }; + const normalized = normalizeSql(statement); + const run = jest.fn(() => { + if ( + options.failIndexCreation && + normalized.startsWith('CREATE UNIQUE INDEX') + ) { + throw new Error('UNIQUE constraint failed'); + } + }); + const get = jest.fn(() => + options.dedupIndexExists ? { 1: 1 } : undefined + ); + statements.set(normalized, run); + return { run, get }; }); const transaction = jest.fn((callback: (rows: unknown[]) => void) => { return (rows: unknown[]) => callback(rows); @@ -110,6 +128,78 @@ describe('EpgDatabase', () => { 'source_url = excluded.source_url' ); }); + + it('removes pre-existing duplicate programs before creating the dedup index', () => { + const { Database, database, statements } = createEpgDatabaseMock(); + + new EpgDatabase(Database); + + const preparedSql = database.prepare.mock.calls.map(([sql]) => + normalizeSql(sql) + ); + const dedupDeleteSql = preparedSql.find((sql) => + sql.startsWith('DELETE FROM epg_programs WHERE id NOT IN') + ); + const createIndexSql = preparedSql.find((sql) => + sql.startsWith('CREATE UNIQUE INDEX idx_epg_programs_dedup') + ); + + expect(dedupDeleteSql).toBeDefined(); + expect(createIndexSql).toBeDefined(); + expect(statements.get(dedupDeleteSql!)).toHaveBeenCalled(); + expect(statements.get(createIndexSql!)).toHaveBeenCalled(); + + const insertProgramSql = preparedSql.find((sql) => + sql.startsWith('INSERT INTO epg_programs') + ); + expect(insertProgramSql).toContain( + 'ON CONFLICT(channel_id, start, title) DO UPDATE SET' + ); + }); + + it('skips the dedup pass when the index already exists', () => { + const { Database, database } = createEpgDatabaseMock({ + dedupIndexExists: true, + }); + + new EpgDatabase(Database); + + const preparedSql = database.prepare.mock.calls.map(([sql]) => + normalizeSql(sql) + ); + expect( + preparedSql.some((sql) => + sql.startsWith('DELETE FROM epg_programs WHERE id NOT IN') + ) + ).toBe(false); + expect( + preparedSql.some((sql) => sql.startsWith('CREATE UNIQUE INDEX')) + ).toBe(false); + + const insertProgramSql = preparedSql.find((sql) => + sql.startsWith('INSERT INTO epg_programs') + ); + expect(insertProgramSql).toContain( + 'ON CONFLICT(channel_id, start, title) DO UPDATE SET' + ); + }); + + it('falls back to plain inserts when index creation fails', () => { + const { Database, database } = createEpgDatabaseMock({ + failIndexCreation: true, + }); + + expect(() => new EpgDatabase(Database)).not.toThrow(); + + const preparedSql = database.prepare.mock.calls.map(([sql]) => + normalizeSql(sql) + ); + const insertProgramSql = preparedSql.find((sql) => + sql.startsWith('INSERT INTO epg_programs') + ); + expect(insertProgramSql).toBeDefined(); + expect(insertProgramSql).not.toContain('ON CONFLICT'); + }); }); describe('EpgDatabaseClearOperation', () => { diff --git a/apps/electron-backend/src/app/workers/epg-database.ts b/apps/electron-backend/src/app/workers/epg-database.ts index e58d3db98..669faaddb 100644 --- a/apps/electron-backend/src/app/workers/epg-database.ts +++ b/apps/electron-backend/src/app/workers/epg-database.ts @@ -30,10 +30,30 @@ export class EpgDatabase { updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') `); - this.insertProgramStmt = this.db.prepare(` - INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); + // Guard against duplicate entries when the clearFirst logic misses old + // rows (e.g. because the source URL changed between imports). The same + // channel + start + title is treated as the same programme — a later + // import with a corrected stop time simply updates the earlier row. + // The upsert (instead of INSERT OR REPLACE) keeps the epg_programs_fts + // triggers consistent: REPLACE deletes rows without firing the delete + // trigger unless recursive_triggers is enabled, leaving ghost FTS rows. + const dedupIndexReady = this.ensureProgramDedupIndex(); + + this.insertProgramStmt = this.db.prepare( + dedupIndexReady + ? `INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(channel_id, start, title) DO UPDATE SET + stop = excluded.stop, + description = excluded.description, + category = excluded.category, + icon_url = excluded.icon_url, + rating = excluded.rating, + episode_num = excluded.episode_num, + source_url = excluded.source_url` + : `INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); this.deleteOrphanChannelsForSourceStmt = this.db.prepare(` DELETE FROM epg_channels @@ -134,6 +154,46 @@ export class EpgDatabase { close(): void { this.db.close(); } + + /** + * Create the unique (channel_id, start, title) dedup index. Databases + * that predate the index may already contain duplicate rows — exactly + * the situation the index is meant to prevent — so those are removed + * first; a plain `CREATE UNIQUE INDEX` would otherwise throw in this + * constructor and permanently break EPG imports for upgrading users. + * Returns false when the index could not be created, in which case the + * insert statement falls back to the previous plain-INSERT behaviour. + */ + private ensureProgramDedupIndex(): boolean { + try { + const exists = this.db + .prepare( + `SELECT 1 FROM sqlite_master + WHERE type = 'index' AND name = 'idx_epg_programs_dedup'` + ) + .get(); + if (!exists) { + this.db + .prepare( + `DELETE FROM epg_programs + WHERE id NOT IN ( + SELECT MIN(id) FROM epg_programs + GROUP BY channel_id, start, title + )` + ) + .run(); + this.db + .prepare( + `CREATE UNIQUE INDEX idx_epg_programs_dedup + ON epg_programs(channel_id, start, title)` + ) + .run(); + } + return true; + } catch { + return false; + } + } } export class EpgDatabaseClearOperation { diff --git a/apps/electron-backend/src/app/workers/epg-streaming-parser.spec.ts b/apps/electron-backend/src/app/workers/epg-streaming-parser.spec.ts index a36a3320f..bbd929208 100644 --- a/apps/electron-backend/src/app/workers/epg-streaming-parser.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-streaming-parser.spec.ts @@ -8,9 +8,40 @@ import { describe('parseXmltvDate', () => { it('normalizes XMLTV timestamps with timezone offsets', () => { expect(parseXmltvDate('20260415053700 +0000')).toBe( - '2026-04-15T05:37:00+00:00' + '2026-04-15T05:37:00.000Z' ); }); + + it('converts positive offsets to UTC', () => { + expect(parseXmltvDate('20260415053700 +0200')).toBe( + '2026-04-15T03:37:00.000Z' + ); + }); + + it('converts negative offsets to UTC', () => { + expect(parseXmltvDate('20260415053700 -0530')).toBe( + '2026-04-15T11:07:00.000Z' + ); + }); + + it('keeps the minutes component of zero-hour offsets', () => { + expect(parseXmltvDate('20260415053700 +0030')).toBe( + '2026-04-15T05:07:00.000Z' + ); + expect(parseXmltvDate('20260415053700 -0030')).toBe( + '2026-04-15T06:07:00.000Z' + ); + }); + + it('handles half-hour and 45-minute offsets', () => { + expect(parseXmltvDate('20260415053700 +0545')).toBe( + '2026-04-14T23:52:00.000Z' + ); + }); + + it('treats offset-less timestamps as UTC', () => { + expect(parseXmltvDate('20260415053700')).toBe('2026-04-15T05:37:00Z'); + }); }); describe('StreamingEpgParser', () => { diff --git a/apps/electron-backend/src/app/workers/epg-streaming-parser.ts b/apps/electron-backend/src/app/workers/epg-streaming-parser.ts index 7c82e7d00..c660870a0 100644 --- a/apps/electron-backend/src/app/workers/epg-streaming-parser.ts +++ b/apps/electron-backend/src/app/workers/epg-streaming-parser.ts @@ -56,15 +56,31 @@ export function parseXmltvDate(dateStr: string): string { const [, year, month, day, hour, minute, second, tz] = match; - let isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}`; - if (tz) { - isoString += `${tz.slice(0, 3)}:${tz.slice(3)}`; - } else { - isoString += 'Z'; + // Normalise to UTC so all stored timestamps have a consistent + // representation. Without this, lexicographic comparisons in the + // SQL queries (e.g. lte(start, now)) produce incorrect results + // when offset-containing strings like "+01:00" are compared against + // the always-UTC `now` string. + // The sign is parsed from the string rather than derived from the + // hours value: Math.sign(0) would silently drop the minutes of + // offsets like "+0030". + const offsetSign = tz.startsWith('-') ? -1 : 1; + const offsetTotalMinutes = + offsetSign * + (Number(tz.slice(1, 3)) * 60 + Number(tz.slice(3))); + const utcMs = Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute) - offsetTotalMinutes, + Number(second) + ); + return new Date(utcMs).toISOString(); } - return isoString; + return `${year}-${month}-${day}T${hour}:${minute}:${second}Z`; } /** diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 694708e82..fb6f1f064 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "إزالة من المفضلة", "ADD_FAVORITE": "إضافة إلى المفضلة", "SHOW_DETAILS": "عرض تفاصيل القناة", + "MAP_EPG": "ربط قناة دليل البرامج", "FAVORITES_UPDATED": "تم تحديث المفضلة!", "SELECT_CHANNEL_PLAYBACK": "يرجى اختيار قناة لبدء التشغيل", "SORT_BY": "ترتيب حسب", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "عرض ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "ربط قناة دليل البرامج (EPG)", + "SUBTITLE": "اختر يدويًا قناة دليل البرامج لـ \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "البحث في قنوات دليل البرامج...", + "CURRENT_MAPPING": "الربط الحالي", + "NO_MAPPING": "لم يتم تعيين ربط لدليل البرامج", + "SAVE": "حفظ الربط", + "REMOVE": "إزالة الربط", + "CLOSE": "إغلاق", + "NO_RESULTS": "لم يتم العثور على قنوات دليل البرامج", + "SEARCH_HINT": "اكتب {{min}} أحرف على الأقل للبحث", + "SAVED": "تم حفظ ربط دليل البرامج", + "REMOVED": "تمت إزالة ربط دليل البرامج", + "SAVE_FAILED": "تعذر تحديث ربط دليل البرامج" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index aa115e6ed..3384a01bb 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "حيد من المفضلة", "ADD_FAVORITE": "زيد للمفضلة", "SHOW_DETAILS": "وري تفاصيل القناة", + "MAP_EPG": "ربط قناة ديال EPG", "FAVORITES_UPDATED": "المفضلة تحدثات!", "SELECT_CHANNEL_PLAYBACK": "عفاك اختار قناة باش يبدا التشغيل", "SORT_BY": "رتب بـ", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "وري ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "ربط قناة ديال EPG", + "SUBTITLE": "اختار يدويا قناة EPG لـ \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "قلب على قنوات EPG...", + "CURRENT_MAPPING": "الربط الحالي", + "NO_MAPPING": "ما كاين حتى ربط ديال EPG", + "SAVE": "سجل الربط", + "REMOVE": "حيد الربط", + "CLOSE": "سد", + "NO_RESULTS": "ما لقينا حتى قناة EPG", + "SEARCH_HINT": "كتب على الأقل {{min}} حروف باش تقلب", + "SAVED": "تسجل الربط ديال EPG", + "REMOVED": "تحيد الربط ديال EPG", + "SAVE_FAILED": "ما قدرناش نبدلو الربط ديال EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 4c086d4f8..df3746201 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Выдаліць са спісу фаварытаў", "ADD_FAVORITE": "Дадаць у выбранае", "SHOW_DETAILS": "Паказаць інфармацыю аб канале", + "MAP_EPG": "Прывязаць канал EPG", "FAVORITES_UPDATED": "Спіс фаварытаў быў абноўлены!", "SELECT_CHANNEL_PLAYBACK": "Калі ласка, выберыце канал, каб пачаць прайграванне", "SORT_BY": "Сартаваць па", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "паказаць ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Прывязка канала EPG", + "SUBTITLE": "Выберыце канал EPG для \"{{channelName}}\" уручную", + "SEARCH_PLACEHOLDER": "Пошук каналаў EPG...", + "CURRENT_MAPPING": "Бягучая прывязка", + "NO_MAPPING": "Прывязка EPG не зададзена", + "SAVE": "Захаваць прывязку", + "REMOVE": "Выдаліць прывязку", + "CLOSE": "Закрыць", + "NO_RESULTS": "Каналы EPG не знойдзены", + "SEARCH_HINT": "Увядзіце не менш за {{min}} сімвалы для пошуку", + "SAVED": "Прывязка EPG захавана", + "REMOVED": "Прывязка EPG выдалена", + "SAVE_FAILED": "Не ўдалося абнавіць прывязку EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index cfc44846a..5d324b6ac 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Aus Favoritenliste entfernen", "ADD_FAVORITE": "Zu Favoriten hinzufügen", "SHOW_DETAILS": "Senderdetails anzeigen", + "MAP_EPG": "EPG-Kanal zuordnen", "FAVORITES_UPDATED": "Favoriten wurden aktualisiert!", "SELECT_CHANNEL_PLAYBACK": "Bitte wählen Sie einen Sender aus, um die Wiedergabe zu starten", "SORT_BY": "Sortieren nach", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "anzeigen ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "EPG-Kanal zuordnen", + "SUBTITLE": "EPG-Kanal für \"{{channelName}}\" manuell auswählen", + "SEARCH_PLACEHOLDER": "EPG-Kanäle durchsuchen...", + "CURRENT_MAPPING": "Aktuelle Zuordnung", + "NO_MAPPING": "Keine EPG-Zuordnung festgelegt", + "SAVE": "Zuordnung speichern", + "REMOVE": "Zuordnung entfernen", + "CLOSE": "Schließen", + "NO_RESULTS": "Keine EPG-Kanäle gefunden", + "SEARCH_HINT": "Mindestens {{min}} Zeichen zum Suchen eingeben", + "SAVED": "EPG-Zuordnung gespeichert", + "REMOVED": "EPG-Zuordnung entfernt", + "SAVE_FAILED": "EPG-Zuordnung konnte nicht aktualisiert werden" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 0f430fa8c..292507e06 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Αφαίρεση από τα αγαπημένα", "ADD_FAVORITE": "Προσθήκη στα αγαπημένα", "SHOW_DETAILS": "Εμφάνιση λεπτομερειών καναλιού", + "MAP_EPG": "Αντιστοίχιση καναλιού EPG", "FAVORITES_UPDATED": "Τα αγαπημένα ενημερώθηκαν!", "SELECT_CHANNEL_PLAYBACK": "Επιλέξτε ένα κανάλι για να ξεκινήσει η αναπαραγωγή", "SORT_BY": "Ταξινόμηση κατά", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "εμφάνιση ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Αντιστοίχιση καναλιού EPG", + "SUBTITLE": "Επιλέξτε χειροκίνητα κανάλι EPG για το \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Αναζήτηση καναλιών EPG...", + "CURRENT_MAPPING": "Τρέχουσα αντιστοίχιση", + "NO_MAPPING": "Δεν έχει οριστεί αντιστοίχιση EPG", + "SAVE": "Αποθήκευση αντιστοίχισης", + "REMOVE": "Κατάργηση αντιστοίχισης", + "CLOSE": "Κλείσιμο", + "NO_RESULTS": "Δεν βρέθηκαν κανάλια EPG", + "SEARCH_HINT": "Πληκτρολογήστε τουλάχιστον {{min}} χαρακτήρες για αναζήτηση", + "SAVED": "Η αντιστοίχιση EPG αποθηκεύτηκε", + "REMOVED": "Η αντιστοίχιση EPG καταργήθηκε", + "SAVE_FAILED": "Δεν ήταν δυνατή η ενημέρωση της αντιστοίχισης EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 38321a321..2ec687719 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Remove from favorites", "ADD_FAVORITE": "Add to favorites", "SHOW_DETAILS": "Show channel details", + "MAP_EPG": "Map EPG channel", "FAVORITES_UPDATED": "Favorites were updated!", "SELECT_CHANNEL_PLAYBACK": "Please select a channel to start playback", "SORT_BY": "Sort by", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "show ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Map EPG Channel", + "SUBTITLE": "Manually select an EPG channel for \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Search EPG channels...", + "CURRENT_MAPPING": "Current mapping", + "NO_MAPPING": "No EPG mapping set", + "SAVE": "Save mapping", + "REMOVE": "Remove mapping", + "CLOSE": "Close", + "NO_RESULTS": "No EPG channels found", + "SEARCH_HINT": "Type at least {{min}} characters to search", + "SAVED": "EPG mapping saved", + "REMOVED": "EPG mapping removed", + "SAVE_FAILED": "Could not update the EPG mapping" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index e4972fa64..bee82556b 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Quitar de favoritos", "ADD_FAVORITE": "Agregar a favoritos", "SHOW_DETAILS": "Mostrar detalles del canal", + "MAP_EPG": "Asignar canal EPG", "FAVORITES_UPDATED": "¡Se actualizaron los favoritos!", "SELECT_CHANNEL_PLAYBACK": "Selecciona un canal para iniciar la reproducción", "SORT_BY": "Ordenar por", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "ver ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Asignar canal EPG", + "SUBTITLE": "Selecciona manualmente un canal EPG para \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Buscar canales EPG...", + "CURRENT_MAPPING": "Asignación actual", + "NO_MAPPING": "No hay asignación EPG", + "SAVE": "Guardar asignación", + "REMOVE": "Eliminar asignación", + "CLOSE": "Cerrar", + "NO_RESULTS": "No se encontraron canales EPG", + "SEARCH_HINT": "Escribe al menos {{min}} caracteres para buscar", + "SAVED": "Asignación EPG guardada", + "REMOVED": "Asignación EPG eliminada", + "SAVE_FAILED": "No se pudo actualizar la asignación EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 8a06f85be..b020bc756 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Retirer des favoris", "ADD_FAVORITE": "Ajouter aux favoris", "SHOW_DETAILS": "Afficher les détails de la chaîne", + "MAP_EPG": "Associer une chaîne EPG", "FAVORITES_UPDATED": "Les favoris ont été mis à jour !", "SELECT_CHANNEL_PLAYBACK": "Veuillez sélectionner une chaîne pour démarrer la lecture", "SORT_BY": "Trier par", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "afficher ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Associer une chaîne EPG", + "SUBTITLE": "Sélectionner manuellement une chaîne EPG pour « {{channelName}} »", + "SEARCH_PLACEHOLDER": "Rechercher des chaînes EPG...", + "CURRENT_MAPPING": "Association actuelle", + "NO_MAPPING": "Aucune association EPG définie", + "SAVE": "Enregistrer l'association", + "REMOVE": "Supprimer l'association", + "CLOSE": "Fermer", + "NO_RESULTS": "Aucune chaîne EPG trouvée", + "SEARCH_HINT": "Saisissez au moins {{min}} caractères pour rechercher", + "SAVED": "Association EPG enregistrée", + "REMOVED": "Association EPG supprimée", + "SAVE_FAILED": "Impossible de mettre à jour l'association EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 51b91c1f5..2207419fc 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Rimuovi dai preferiti", "ADD_FAVORITE": "Aggiungi ai preferiti", "SHOW_DETAILS": "Mostra dettagli canale", + "MAP_EPG": "Associa canale EPG", "FAVORITES_UPDATED": "Aggiornamento preferiti completato!", "SELECT_CHANNEL_PLAYBACK": "Seleziona un canale per avviare la riproduzione", "SORT_BY": "Ordina per", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "mostra ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Associa canale EPG", + "SUBTITLE": "Seleziona manualmente un canale EPG per \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Cerca canali EPG...", + "CURRENT_MAPPING": "Associazione attuale", + "NO_MAPPING": "Nessuna associazione EPG impostata", + "SAVE": "Salva associazione", + "REMOVE": "Rimuovi associazione", + "CLOSE": "Chiudi", + "NO_RESULTS": "Nessun canale EPG trovato", + "SEARCH_HINT": "Digita almeno {{min}} caratteri per cercare", + "SAVED": "Associazione EPG salvata", + "REMOVED": "Associazione EPG rimossa", + "SAVE_FAILED": "Impossibile aggiornare l'associazione EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 3f5fe87f9..24bed12b9 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "お気に入りから削除", "ADD_FAVORITE": "お気に入りに追加", "SHOW_DETAILS": "チャンネルの詳細を表示", + "MAP_EPG": "EPGチャンネルを割り当て", "FAVORITES_UPDATED": "お気に入りが更新されました!", "SELECT_CHANNEL_PLAYBACK": "再生を開始するチャンネルを選択してください", "SORT_BY": "並び替え", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "表示 ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "EPGチャンネルの割り当て", + "SUBTITLE": "「{{channelName}}」のEPGチャンネルを手動で選択します", + "SEARCH_PLACEHOLDER": "EPGチャンネルを検索...", + "CURRENT_MAPPING": "現在の割り当て", + "NO_MAPPING": "EPGの割り当てはありません", + "SAVE": "割り当てを保存", + "REMOVE": "割り当てを削除", + "CLOSE": "閉じる", + "NO_RESULTS": "EPGチャンネルが見つかりません", + "SEARCH_HINT": "検索するには{{min}}文字以上入力してください", + "SAVED": "EPGの割り当てを保存しました", + "REMOVED": "EPGの割り当てを削除しました", + "SAVE_FAILED": "EPGの割り当てを更新できませんでした" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 04700d99d..563e17b7c 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "즐겨찾기에서 삭제하기", "ADD_FAVORITE": "즐겨찾기에 추가", "SHOW_DETAILS": "채널 세부정보 표시", + "MAP_EPG": "EPG 채널 매핑", "FAVORITES_UPDATED": "즐겨찾기가 업데이트되었습니다!", "SELECT_CHANNEL_PLAYBACK": "재생을 시작할 채널을 선택하세요", "SORT_BY": "정렬 기준", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "보기 ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "EPG 채널 매핑", + "SUBTITLE": "\"{{channelName}}\"의 EPG 채널을 직접 선택하세요", + "SEARCH_PLACEHOLDER": "EPG 채널 검색...", + "CURRENT_MAPPING": "현재 매핑", + "NO_MAPPING": "설정된 EPG 매핑 없음", + "SAVE": "매핑 저장", + "REMOVE": "매핑 제거", + "CLOSE": "닫기", + "NO_RESULTS": "EPG 채널을 찾을 수 없음", + "SEARCH_HINT": "검색하려면 {{min}}자 이상 입력하세요", + "SAVED": "EPG 매핑이 저장되었습니다", + "REMOVED": "EPG 매핑이 제거되었습니다", + "SAVE_FAILED": "EPG 매핑을 업데이트할 수 없습니다" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index eb2a99e17..4a7fa9131 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Verwijder uit favorieten", "ADD_FAVORITE": "Toevoegen aan favorieten", "SHOW_DETAILS": "Toon kanaaldetails", + "MAP_EPG": "EPG-kanaal koppelen", "FAVORITES_UPDATED": "Favorieten zijn bijgewerkt!", "SELECT_CHANNEL_PLAYBACK": "Selecteer een kanaal om af te spelen", "SORT_BY": "Sorteren op", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "toon ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "EPG-kanaal koppelen", + "SUBTITLE": "Selecteer handmatig een EPG-kanaal voor \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "EPG-kanalen zoeken...", + "CURRENT_MAPPING": "Huidige koppeling", + "NO_MAPPING": "Geen EPG-koppeling ingesteld", + "SAVE": "Koppeling opslaan", + "REMOVE": "Koppeling verwijderen", + "CLOSE": "Sluiten", + "NO_RESULTS": "Geen EPG-kanalen gevonden", + "SEARCH_HINT": "Typ minimaal {{min}} tekens om te zoeken", + "SAVED": "EPG-koppeling opgeslagen", + "REMOVED": "EPG-koppeling verwijderd", + "SAVE_FAILED": "Kan de EPG-koppeling niet bijwerken" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index ff88234c3..d35d051ca 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Usuń z ulubionych", "ADD_FAVORITE": "Dodaj do ulubionych", "SHOW_DETAILS": "Pokaż szczegóły kanału", + "MAP_EPG": "Przypisz kanał EPG", "FAVORITES_UPDATED": "Ulubione zostały zaktualizowane!", "SELECT_CHANNEL_PLAYBACK": "Wybierz kanał, aby rozpocząć odtwarzanie", "SORT_BY": "Sortuj według", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "pokaż ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Przypisz kanał EPG", + "SUBTITLE": "Ręcznie wybierz kanał EPG dla \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Szukaj kanałów EPG...", + "CURRENT_MAPPING": "Bieżące przypisanie", + "NO_MAPPING": "Brak przypisania EPG", + "SAVE": "Zapisz przypisanie", + "REMOVE": "Usuń przypisanie", + "CLOSE": "Zamknij", + "NO_RESULTS": "Nie znaleziono kanałów EPG", + "SEARCH_HINT": "Wpisz co najmniej {{min}} znaki, aby wyszukać", + "SAVED": "Przypisanie EPG zapisane", + "REMOVED": "Przypisanie EPG usunięte", + "SAVE_FAILED": "Nie udało się zaktualizować przypisania EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 585377af9..e1cfb1a61 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Remover dos favoritos", "ADD_FAVORITE": "Adicionar aos favoritos", "SHOW_DETAILS": "Mostrar detalhes do canal", + "MAP_EPG": "Associar canal EPG", "FAVORITES_UPDATED": "Favoritos foram atualizados!", "SELECT_CHANNEL_PLAYBACK": "Selecione um canal para iniciar a reprodução", "SORT_BY": "Ordenar por", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "mostrar ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Associar canal EPG", + "SUBTITLE": "Selecione manualmente um canal EPG para \"{{channelName}}\"", + "SEARCH_PLACEHOLDER": "Pesquisar canais EPG...", + "CURRENT_MAPPING": "Associação atual", + "NO_MAPPING": "Nenhuma associação EPG definida", + "SAVE": "Guardar associação", + "REMOVE": "Remover associação", + "CLOSE": "Fechar", + "NO_RESULTS": "Nenhum canal EPG encontrado", + "SEARCH_HINT": "Digite pelo menos {{min}} caracteres para pesquisar", + "SAVED": "Associação EPG guardada", + "REMOVED": "Associação EPG removida", + "SAVE_FAILED": "Não foi possível atualizar a associação EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 027fe915a..5ca85485c 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Удалить из списка фаворитов", "ADD_FAVORITE": "Добавить в избранное", "SHOW_DETAILS": "Показать сведения о канале", + "MAP_EPG": "Привязать канал EPG", "FAVORITES_UPDATED": "Список фаворитов был обновлен!", "SELECT_CHANNEL_PLAYBACK": "Пожалуйста, выберите канал для начала воспроизведения", "SORT_BY": "Сортировать по", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "показать ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "Привязка канала EPG", + "SUBTITLE": "Выберите канал EPG для \"{{channelName}}\" вручную", + "SEARCH_PLACEHOLDER": "Поиск каналов EPG...", + "CURRENT_MAPPING": "Текущая привязка", + "NO_MAPPING": "Привязка EPG не задана", + "SAVE": "Сохранить привязку", + "REMOVE": "Удалить привязку", + "CLOSE": "Закрыть", + "NO_RESULTS": "Каналы EPG не найдены", + "SEARCH_HINT": "Введите не менее {{min}} символов для поиска", + "SAVED": "Привязка EPG сохранена", + "REMOVED": "Привязка EPG удалена", + "SAVE_FAILED": "Не удалось обновить привязку EPG" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 2a3d4a970..b0c0fd1c7 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "Favorilerden kaldır", "ADD_FAVORITE": "Favorilere ekle", "SHOW_DETAILS": "Kanal ayrıntılarını göster", + "MAP_EPG": "EPG kanalı eşle", "FAVORITES_UPDATED": "Favoriler güncellendi!", "SELECT_CHANNEL_PLAYBACK": "Lütfen oynatmaya başlamak için bir kanal seçin", "SORT_BY": "Sırala", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "göster ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "EPG Kanalı Eşle", + "SUBTITLE": "\"{{channelName}}\" için EPG kanalını elle seçin", + "SEARCH_PLACEHOLDER": "EPG kanallarında ara...", + "CURRENT_MAPPING": "Geçerli eşleme", + "NO_MAPPING": "EPG eşlemesi ayarlanmadı", + "SAVE": "Eşlemeyi kaydet", + "REMOVE": "Eşlemeyi kaldır", + "CLOSE": "Kapat", + "NO_RESULTS": "EPG kanalı bulunamadı", + "SEARCH_HINT": "Aramak için en az {{min}} karakter yazın", + "SAVED": "EPG eşlemesi kaydedildi", + "REMOVED": "EPG eşlemesi kaldırıldı", + "SAVE_FAILED": "EPG eşlemesi güncellenemedi" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 2e831cabb..957581e4f 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "从收藏夹中删除", "ADD_FAVORITE": "添加到收藏", "SHOW_DETAILS": "显示频道详情", + "MAP_EPG": "映射 EPG 频道", "FAVORITES_UPDATED": "收藏夹已更新!", "SELECT_CHANNEL_PLAYBACK": "请选择一个频道开始播放", "SORT_BY": "排序方式", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "展开 ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "映射 EPG 频道", + "SUBTITLE": "为“{{channelName}}”手动选择 EPG 频道", + "SEARCH_PLACEHOLDER": "搜索 EPG 频道...", + "CURRENT_MAPPING": "当前映射", + "NO_MAPPING": "未设置 EPG 映射", + "SAVE": "保存映射", + "REMOVE": "移除映射", + "CLOSE": "关闭", + "NO_RESULTS": "未找到 EPG 频道", + "SEARCH_HINT": "输入至少 {{min}} 个字符进行搜索", + "SAVED": "EPG 映射已保存", + "REMOVED": "EPG 映射已移除", + "SAVE_FAILED": "无法更新 EPG 映射" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index f7589cdca..292ff3b8f 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -569,6 +569,7 @@ "REMOVE_FAVORITE": "從我的最愛中移除", "ADD_FAVORITE": "加入我的最愛", "SHOW_DETAILS": "顯示頻道詳細資訊", + "MAP_EPG": "對應 EPG 頻道", "FAVORITES_UPDATED": "我的最愛已更新!", "SELECT_CHANNEL_PLAYBACK": "請選擇一個頻道以開始播放", "SORT_BY": "排序依據", @@ -714,6 +715,21 @@ "GROUP_EXPAND": "顯示 ›" } }, + "EPG_MAPPING_DIALOG": { + "TITLE": "對應 EPG 頻道", + "SUBTITLE": "為「{{channelName}}」手動選擇 EPG 頻道", + "SEARCH_PLACEHOLDER": "搜尋 EPG 頻道...", + "CURRENT_MAPPING": "目前對應", + "NO_MAPPING": "尚未設定 EPG 對應", + "SAVE": "儲存對應", + "REMOVE": "移除對應", + "CLOSE": "關閉", + "NO_RESULTS": "找不到 EPG 頻道", + "SEARCH_HINT": "輸入至少 {{min}} 個字元進行搜尋", + "SAVED": "EPG 對應已儲存", + "REMOVED": "EPG 對應已移除", + "SAVE_FAILED": "無法更新 EPG 對應" + }, "LANGUAGES": { "ARABIC": "العربية", "MOROCCAN_ARABIC": "العربية (اللهجة المغربية)", diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts index 8e48fb452..b8c3d5815 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -1,14 +1,16 @@ import { inject, Injectable } from '@angular/core'; import { + ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES, ElectronBridgeApi, ElectronBridgeEpgFetchResult, + ElectronBridgeEpgMapping, ElectronBridgeEpgProgress, ElectronBridgeEpgProgressStats, ElectronBridgeEpgProgressStatus, + ElectronBridgeEpgSearchResult, ElectronBridgeEpgChannelWithPrograms, ElectronBridgeEpgFreshnessResult, ElectronBridgeEpgLookupOptions, - ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES, ElectronBridgeResult, ElectronBridgeTrustOptions, EpgChannelMetadata, @@ -38,6 +40,11 @@ type EpgElectronBridge = Pick< | 'getEpgChannelsByRange' | 'onEpgProgress' | 'searchEpgPrograms' + | 'getEpgMapping' + | 'getEpgMappingsBatch' + | 'setEpgMapping' + | 'deleteEpgMapping' + | 'searchEpgChannels' >; @Injectable({ providedIn: 'root' }) @@ -80,6 +87,10 @@ export class EpgRuntimeBridgeService { return this.runtime.supportsEpgProgramSearch; } + get supportsEpgMapping(): boolean { + return this.runtime.supportsEpgMapping; + } + fetchEpg( urls: string[], options?: ElectronBridgeTrustOptions @@ -221,6 +232,66 @@ export class EpgRuntimeBridgeService { ); } + getEpgMapping( + channelKey: string + ): Promise { + if (!this.supportsEpgMapping) { + return Promise.resolve(null); + } + + return this.bridge?.getEpgMapping?.(channelKey) ?? Promise.resolve(null); + } + + getEpgMappingsBatch( + channelKeys: string[] + ): Promise | null> { + if (!this.supportsEpgMapping || channelKeys.length === 0) { + return Promise.resolve(null); + } + + return ( + this.bridge?.getEpgMappingsBatch?.(channelKeys) ?? + Promise.resolve(null) + ); + } + + setEpgMapping( + channelKey: string, + epgChannelId: string, + playlistId?: string + ): Promise { + if (!this.supportsEpgMapping) { + return Promise.resolve(null); + } + + return ( + this.bridge?.setEpgMapping?.(channelKey, epgChannelId, playlistId) ?? + Promise.resolve(null) + ); + } + + deleteEpgMapping(channelKey: string): Promise { + if (!this.supportsEpgMapping) { + return Promise.resolve(null); + } + + return this.bridge?.deleteEpgMapping?.(channelKey) ?? Promise.resolve(null); + } + + searchEpgChannels( + searchTerm: string, + limit?: number + ): Promise { + if (!this.supportsEpgMapping) { + return Promise.resolve(null); + } + + return ( + this.bridge?.searchEpgChannels?.(searchTerm, limit) ?? + Promise.resolve(null) + ); + } + onProgress(callback: (data: EpgImportProgress) => void): void { if (!this.supportsProgress) { return; diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index 9578dc38b..b323ed2cc 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -44,6 +44,8 @@ describe('StreamResolverService', () => { }; epgBridge = { getChannelPrograms: jest.fn(), + getEpgMapping: jest.fn().mockResolvedValue(null), + getEpgMappingsBatch: jest.fn().mockResolvedValue(null), supportsProgramLookup: true, }; @@ -267,7 +269,7 @@ describe('StreamResolverService', () => { password: 'pass', }, 1, - 10, + 50, { suppressErrorLog: true, } @@ -342,7 +344,7 @@ describe('StreamResolverService', () => { password: 'pass', }, 1, - 2, + 5, { suppressErrorLog: true, } @@ -416,7 +418,7 @@ describe('StreamResolverService', () => { stop_timestamp: '1774526400', }, ]), - 10_000 + 30_000 ); }) ); @@ -431,7 +433,7 @@ describe('StreamResolverService', () => { xtreamId: 1, } satisfies UnifiedCollectionItem); - await jest.advanceTimersByTimeAsync(3000); + await jest.advanceTimersByTimeAsync(10_000); await expect(detailPromise).resolves.toMatchObject({ epgMode: 'portal', @@ -445,6 +447,53 @@ describe('StreamResolverService', () => { } }); + it('resolves manual EPG mappings for Xtream previews with one batched lookup', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'xtream-1', + serverUrl: 'https://xtream.example.com', + username: 'user', + password: 'pass', + } satisfies Partial) + ); + + const nowMs = Date.now(); + epgBridge.getEpgMappingsBatch = jest.fn().mockResolvedValue({ + 'xtream:xtream-1:7': 'mapped.channel.id', + }); + epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([ + { + channel: 'mapped.channel.id', + title: 'Mapped Program', + desc: 'From uploaded XMLTV', + start: new Date(nowMs - 60_000).toISOString(), + stop: new Date(nowMs + 60_000).toISOString(), + }, + ]); + + const epgMap = await service.loadEpgForItems([ + { + uid: 'xtream::xtream-1::7', + name: 'Mapped Channel', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream', + xtreamId: 7, + } satisfies UnifiedCollectionItem, + ]); + + expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledTimes(1); + expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledWith( + expect.arrayContaining(['xtream:xtream-1:7', 'Mapped Channel']) + ); + // The mapping resolved via XMLTV, so no provider EPG call is needed. + expect(xtreamApi.getShortEpg).not.toHaveBeenCalled(); + expect(epgMap.get('Mapped Channel')).toMatchObject({ + title: 'Mapped Program', + }); + }); + it('loads current Stalker EPG previews for live collection rows', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 6893ee6b6..0b487fafd 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -3,6 +3,7 @@ import { firstValueFrom } from 'rxjs'; import { DataService, PlaylistsService } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { + buildXtreamEpgMappingKey, Channel, EpgItem, EpgProgram, @@ -63,11 +64,11 @@ export class StreamResolverService { private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly stalkerSession = inject(StalkerSessionService); private readonly m3uEpgTimeoutMs = 3000; - private readonly portalEpgTimeoutMs = 3000; + private readonly portalEpgTimeoutMs = 10000; private readonly xtreamEpgCache = new Map(); private readonly xtreamEpgFailureTimestamps = new Map(); private readonly xtreamEpgCacheTtlMs = 60 * 1000; - private readonly xtreamEpgFailureCooldownMs = 60 * 1000; + private readonly xtreamEpgFailureCooldownMs = 30 * 1000; private get supportsProgramLookup(): boolean { return this.epgBridge.supportsProgramLookup; @@ -410,11 +411,59 @@ export class StreamResolverService { return []; } + // 1) Check uploaded XMLTV EPG via the provider's epg_channel_id. + // The field is populated at runtime from the content table's + // epg_channel_id column but is not declared on the TS interface. + const epgKey = (item as unknown as Record).epgChannelId?.trim(); + if (this.supportsProgramLookup && epgKey) { + const uploaded = await this.epgBridge + .getChannelPrograms(epgKey) + .catch(() => null); + if (uploaded && uploaded.length > 0) { + return this.mapProgramsToEpgItems(uploaded); + } + } + + // 2) Fall back to the manual mapping table (playlist-scoped + // Xtream key → epgChannelId). + if (this.supportsProgramLookup && item.xtreamId) { + const mapping = await this.epgBridge + .getEpgMapping( + buildXtreamEpgMappingKey(item.playlistId, item.xtreamId) + ) + .catch(() => null); + if (mapping?.epgChannelId) { + const mapped = await this.epgBridge + .getChannelPrograms(mapping.epgChannelId) + .catch(() => null); + if (mapped && mapped.length > 0) { + return this.mapProgramsToEpgItems(mapped); + } + } + } + + // 3) Try the full EPG endpoint (same as the main live-view + // loadEpg() in with-epg.feature.ts) — many providers only + // support get_simple_data_table, not get_short_epg. + try { + const fullEpg = await this.xtreamApi.getFullEpg( + creds, + item.xtreamId, + { suppressErrorLog: true } + ); + if (fullEpg.length > 0) { + return fullEpg; + } + } catch { + // getFullEpg failed — continue to short-EPG fallback below. + } + + // Fall back to the short-EPG endpoint with a generous limit. return await this.fetchXtreamEpgItems( item.playlistId, creds, item.xtreamId, - 10 + 50 ); } catch { return []; @@ -595,31 +644,78 @@ export class StreamResolverService { return; } - await Promise.all( - channels.map(async (channel) => { - if (!channel.xtreamId) { - return; - } + // Prefetch manual EPG mappings for the whole batch in one IPC + // round-trip — a per-channel lookup would issue O(N × candidates) + // IPC calls on every list load. + const mappingByKey = await this.prefetchEpgMappings( + playlistId, + channels + ); + + // Limit concurrency to avoid overwhelming the provider with + // simultaneous EPG requests when loading a large channel list. + const concurrency = 3; + const pending: Promise[] = []; + const iterator = channels.entries(); + + const enqueueNext = async (): Promise => { + for (;;) { + const entry = iterator.next(); + if (entry.done) return; + const [, channel] = entry.value; + if (!channel.xtreamId) continue; try { - const items = await this.fetchXtreamEpgItems( - playlistId, - creds, - channel.xtreamId, - 2 - ); const nowSeconds = Math.floor(now / 1000); - const currentItem = - items.find( - (item) => - Number(item.start_timestamp) <= nowSeconds && - nowSeconds < Number(item.stop_timestamp) - ) ?? null; + let currentItem: EpgItem | null = null; + + // 1) Try uploaded XMLTV EPG via the provider's epg_channel_id. + const epgChannelKey = (channel as unknown as Record).epgChannelId?.trim(); + if (this.supportsProgramLookup && epgChannelKey) { + currentItem = await this.findCurrentInXmltv( + epgChannelKey, nowSeconds + ); + } + + // 2) Fall back to manual mapping (epg_channel_mappings + // table), using the batch-prefetched map. Candidate key + // order matches how mappings can be saved: the + // playlist-scoped Xtream key (portal/favorites dialogs), + // then tvgId and name (M3U dialogs). + if (!currentItem && this.supportsProgramLookup) { + for (const key of this.mappingCandidateKeys( + playlistId, + channel + )) { + const mappedId = mappingByKey.get(key); + if (!mappedId) continue; + currentItem = await this.findCurrentInXmltv( + mappedId, + nowSeconds + ); + if (currentItem) break; + } + } + + if (!currentItem) { + const items = await this.fetchXtreamEpgItems( + playlistId, + creds, + channel.xtreamId, + 5 + ); + currentItem = + items.find( + (item) => + Number(item.start_timestamp) <= nowSeconds && + nowSeconds < Number(item.stop_timestamp) + ) ?? null; + } const epgKey = channel.tvgId?.trim() || channel.name?.trim(); if (!epgKey) { - return; + continue; } epgMap.set( @@ -646,8 +742,14 @@ export class StreamResolverService { epgMap.set(epgKey, null); } } - }) - ); + } + }; + + // Start limited concurrent workers. + for (let i = 0; i < concurrency; i++) { + pending.push(enqueueNext()); + } + await Promise.all(pending); } private getXtreamEpgCacheKey( @@ -958,4 +1060,79 @@ export class StreamResolverService { private isHttpUrl(value: string): boolean { return value.startsWith('http://') || value.startsWith('https://'); } + + /** + * All keys a manual mapping for this channel may have been saved under: + * the playlist-scoped Xtream key first, then the M3U lookup keys. + */ + private mappingCandidateKeys( + playlistId: string, + channel: UnifiedCollectionItem + ): string[] { + return [ + channel.xtreamId != null + ? buildXtreamEpgMappingKey(playlistId, channel.xtreamId) + : null, + channel.tvgId?.trim() || null, + channel.name?.trim() || null, + ].filter((key): key is string => Boolean(key)); + } + + /** + * Resolve the manual EPG mappings for every candidate key in the batch + * with a single IPC call. Returns an empty map when program lookup or + * the mapping bridge is unavailable (PWA). + */ + private async prefetchEpgMappings( + playlistId: string, + channels: UnifiedCollectionItem[] + ): Promise> { + const result = new Map(); + if (!this.supportsProgramLookup) { + return result; + } + + const keys = new Set(); + for (const channel of channels) { + if (!channel.xtreamId) continue; + for (const key of this.mappingCandidateKeys(playlistId, channel)) { + keys.add(key); + } + } + if (keys.size === 0) { + return result; + } + + const mappings = await this.epgBridge + .getEpgMappingsBatch([...keys]) + .catch(() => null); + if (!mappings) { + return result; + } + for (const [key, mappedId] of Object.entries(mappings)) { + const trimmed = mappedId?.trim(); + if (trimmed) { + result.set(key, trimmed); + } + } + return result; + } + + /** Look up the current program for an EPG channel ID from uploaded XMLTV. */ + private async findCurrentInXmltv( + epgChannelId: string, + nowSeconds: number + ): Promise { + if (!this.supportsProgramLookup || !epgChannelId) return null; + const programs = await this.epgBridge + .getChannelPrograms(epgChannelId) + .catch(() => null); + if (!programs || programs.length === 0) return null; + const items = this.mapProgramsToEpgItems(programs); + return items.find( + (item) => + Number(item.start_timestamp) <= nowSeconds && + nowSeconds < Number(item.stop_timestamp) + ) ?? null; + } } diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html index ce4bbe8bf..b686dc97d 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -61,6 +61,12 @@ > + @if (supportsEpgMapping) { + + } @if (contextMenuChannel()?.m3uChannel) { + } @else {
search_off diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss index 0df50214b..341cb6b6a 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss @@ -171,3 +171,11 @@ transform: translateY(0); } } + +.channel-context-menu-anchor { + position: fixed; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; +} diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 991a2100a..56c71104b 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -13,15 +13,20 @@ import { input, OnDestroy, output, + signal, untracked, viewChild, } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { ActivatedRoute } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { + buildXtreamEpgMappingKey, EpgItem, EpgProgram, XtreamCategory, @@ -30,6 +35,7 @@ import { import { ChannelListItemComponent, ChannelListSkeletonComponent, + EpgMappingDialogComponent, } from '@iptvnator/ui/components'; import { PortalChannelSortMode, @@ -66,7 +72,9 @@ interface XtreamCategoryLike { imports: [ ChannelListItemComponent, ChannelListSkeletonComponent, + MatButtonModule, MatIcon, + MatMenuModule, ScrollingModule, TranslatePipe, ], @@ -83,7 +91,14 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { private readonly epgQueueService = inject(EpgQueueService); private readonly route = inject(ActivatedRoute); private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly dialog = inject(MatDialog); + + readonly contextMenuTrigger = + viewChild.required('contextMenuTrigger'); + readonly contextMenuChannel = signal(null); + readonly contextMenuPosition = signal({ x: '0px', y: '0px' }); readonly supportsEpg = this.runtime.supportsEpg; + readonly supportsEpgMapping = this.runtime.supportsEpgMapping; readonly isSelectedTypeContentLoading = this.xtreamStore.selectedTypeContentLoading; readonly channels = computed(() => { @@ -246,6 +261,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { const uncachedEntries: { streamId: number; epgChannelId?: string | null; + playlistId?: string | null; }[] = []; // Apply cached results immediately @@ -266,6 +282,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { uncachedEntries.push({ streamId: channel.xtream_id, epgChannelId: channel.epg_channel_id ?? null, + playlistId: playlist.id ?? null, }); } } @@ -459,4 +476,43 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { ? Math.floor(parsedDate / 1000) : null; } + + // ── Context menu ──────────────────────────────────────────── + + onChannelContextMenu(channel: XtreamChannelListItem, event: MouseEvent): void { + this.contextMenuChannel.set(channel); + this.contextMenuPosition.set({ + x: `${event.clientX}px`, + y: `${event.clientY}px`, + }); + + const trigger = this.contextMenuTrigger(); + if (trigger.menuOpen) { + trigger.closeMenu(); + } + + queueMicrotask(() => { + this.contextMenuTrigger().openMenu(); + }); + } + + openEpgMapping(): void { + const channel = this.contextMenuChannel(); + if (!channel) { + return; + } + + this.contextMenuTrigger().closeMenu(); + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const xtreamId = channel.xtream_id ?? channel.id; + if (!playlistId || xtreamId == null) { + return; + } + + EpgMappingDialogComponent.open(this.dialog, { + channelKey: buildXtreamEpgMappingKey(playlistId, xtreamId), + channelName: channel.title ?? channel.name ?? String(xtreamId), + playlistId, + }); + } } diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index e2d3ed248..c3703b129 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -115,6 +115,15 @@ export class RuntimeCapabilitiesService { return this.hasElectronMethod('searchEpgPrograms'); } + get supportsEpgMapping(): boolean { + return ( + this.hasElectronMethod('getEpgMapping') && + this.hasElectronMethod('setEpgMapping') && + this.hasElectronMethod('deleteEpgMapping') && + this.hasElectronMethod('searchEpgChannels') + ); + } + get supportsSqlite(): boolean { return [ 'dbDeleteAllPlaylists', diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index aed2531c2..7bc8ca437 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -169,6 +169,7 @@ const CREATE_TABLE_STATEMENTS = [ `CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`, `CREATE INDEX IF NOT EXISTS idx_content_title ON content(title)`, `CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id)`, + `CREATE INDEX IF NOT EXISTS idx_content_epg_channel ON content(epg_channel_id)`, `CREATE INDEX IF NOT EXISTS idx_content_type_added ON content(type, added)`, `CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type)`, // Partial covering index for visible categories — supports the dashboard's @@ -261,6 +262,15 @@ const CREATE_TABLE_STATEMENTS = [ INSERT INTO epg_programs_fts(rowid, title, description, category) VALUES (new.id, new.title, new.description, new.category); END`, + // EPG channel mappings (manual user overrides) + `CREATE TABLE IF NOT EXISTS epg_channel_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_key TEXT NOT NULL UNIQUE, + epg_channel_id TEXT NOT NULL, + playlist_id TEXT, + updated_at TEXT DEFAULT (datetime('now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_epg_channel_mappings_playlist ON epg_channel_mappings(playlist_id)`, // Playback Positions table `CREATE TABLE IF NOT EXISTS playback_positions ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index 0093dab99..1c00709ba 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -110,6 +110,9 @@ export const content = sqliteTable( categoryIdx: index('idx_content_category').on(table.categoryId), titleIdx: index('idx_content_title').on(table.title), xtreamIdx: index('idx_content_xtream').on(table.xtreamId), + epgChannelIdx: index('idx_content_epg_channel').on( + table.epgChannelId + ), categoryTypeXtreamUnique: uniqueIndex( 'content_category_type_xtream_unique' ).on(table.categoryId, table.type, table.xtreamId), @@ -347,6 +350,26 @@ export const downloads = sqliteTable( }) ); +// EPG channel mappings (manual user overrides) +export const epgChannelMappings = sqliteTable( + 'epg_channel_mappings', + { + id: integer('id').primaryKey({ autoIncrement: true }), + channelKey: text('channel_key').notNull().unique(), + epgChannelId: text('epg_channel_id').notNull(), + playlistId: text('playlist_id'), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + playlistIdx: index('idx_epg_channel_mappings_playlist').on( + table.playlistId + ), + }) +); + +export type EpgChannelMapping = typeof epgChannelMappings.$inferSelect; +export type NewEpgChannelMapping = typeof epgChannelMappings.$inferInsert; + export type Download = typeof downloads.$inferSelect; export type NewDownload = typeof downloads.$inferInsert; diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index b65a7381a..2f25d78e0 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -7,6 +7,7 @@ export * from './lib/epg-channel-metadata.model'; export * from './lib/epg-channel-with-programs.interface'; export * from './lib/epg-channel.model'; export * from './lib/epg-item.interface'; +export * from './lib/epg-mapping-key.util'; export * from './lib/epg-program.model'; export * from './lib/external-player-arguments.utils'; export * from './lib/external-player-session.interface'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 489ed1b0e..6c92e676a 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -266,6 +266,19 @@ export interface ElectronBridgeEpgFreshnessResult { freshUrls: string[]; } +export interface ElectronBridgeEpgMapping { + id: number; + channelKey: string; + epgChannelId: string; + playlistId: string | null; +} + +export interface ElectronBridgeEpgSearchResult { + id: string; + displayName: string; + iconUrl: string | null; +} + export interface ElectronBridgeEpgLookupOptions { sourceUrls?: string[]; } @@ -616,6 +629,25 @@ export interface ElectronBridgeApi { searchTerm: string, limit?: number ) => Promise; + + // EPG channel mapping (manual user overrides) + getEpgMapping: ( + channelKey: string + ) => Promise; + getEpgMappingsBatch: ( + channelKeys: string[] + ) => Promise>; + setEpgMapping: ( + channelKey: string, + epgChannelId: string, + playlistId?: string + ) => Promise; + deleteEpgMapping: (channelKey: string) => Promise; + searchEpgChannels: ( + searchTerm: string, + limit?: number + ) => Promise; + updateSettings: (settings: Partial) => Promise; getAiSettings: () => Promise; setMpvPlayerPath: (mpvPlayerPath: string) => Promise; diff --git a/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts b/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts new file mode 100644 index 000000000..fd7453c38 --- /dev/null +++ b/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts @@ -0,0 +1,21 @@ +import { buildXtreamEpgMappingKey } from './epg-mapping-key.util'; + +describe('buildXtreamEpgMappingKey', () => { + it('scopes the key to the playlist', () => { + expect(buildXtreamEpgMappingKey('playlist-a', 12345)).toBe( + 'xtream:playlist-a:12345' + ); + }); + + it('produces distinct keys for the same stream id in different playlists', () => { + expect(buildXtreamEpgMappingKey('playlist-a', 12345)).not.toBe( + buildXtreamEpgMappingKey('playlist-b', 12345) + ); + }); + + it('accepts string stream ids', () => { + expect(buildXtreamEpgMappingKey('playlist-a', '42')).toBe( + 'xtream:playlist-a:42' + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts b/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts new file mode 100644 index 000000000..5173ea8d3 --- /dev/null +++ b/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts @@ -0,0 +1,21 @@ +/** + * Key helpers for the manual EPG channel mapping table + * (`epg_channel_mappings`). + * + * M3U channels use their EPG lookup key (tvg-id, falling back to the + * channel name) directly — those identifiers are meaningful across + * playlists, so a mapping saved once applies everywhere. + * + * Xtream stream IDs, on the other hand, are provider-local integers: + * stream 12345 in portal A has nothing to do with stream 12345 in + * portal B. Mapping keys for Xtream channels therefore embed the + * playlist ID so mappings never leak across portals. Shared between the + * renderer and the Electron main process/worker, like + * `title-normalization.util.ts`. + */ +export function buildXtreamEpgMappingKey( + playlistId: string, + xtreamId: number | string +): string { + return `xtream:${playlistId}:${xtreamId}`; +} diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index a725b7ea6..09fca1a52 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/channel-list-container/channel-list-container.component'; export * from './lib/channel-list-container/channel-details-dialog/channel-details-dialog.component'; +export * from './lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component'; export * from './lib/channel-list-container/channel-list-item/channel-list-item.component'; export * from './lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component'; export * from './lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component'; diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html index 92006c4d0..accebb6d3 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html @@ -110,6 +110,12 @@ >
+ @if (supportsEpgMapping) { + + } + } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts index 73f85d009..d8aaca3ca 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts @@ -1,11 +1,18 @@ import { ClipboardModule } from '@angular/cdk/clipboard'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogModule, +} from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { getM3uArchiveDays, isM3uCatchupPlaybackSupported } from '@iptvnator/shared/m3u-utils'; import { Channel } from '@iptvnator/shared/interfaces'; +import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; +import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component'; interface ChannelDetailField { readonly empty?: boolean; @@ -35,6 +42,9 @@ interface HeroStat { }) export class ChannelDetailsDialogComponent { readonly channel = inject(MAT_DIALOG_DATA); + private readonly dialog = inject(MatDialog); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping; readonly archiveDays = getM3uArchiveDays(this.channel); readonly catchupAvailable = this.archiveDays > 0; @@ -182,4 +192,16 @@ export class ChannelDetailsDialogComponent { return { labelKey, monospace, value: normalized }; } + + openEpgMapping(): void { + const channelKey = resolveChannelEpgLookupKey(this.channel); + if (!channelKey) { + return; + } + + EpgMappingDialogComponent.open(this.dialog, { + channelKey, + channelName: this.channel.name ?? channelKey, + }); + } } diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html new file mode 100644 index 000000000..e4be4b844 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html @@ -0,0 +1,77 @@ +

{{ 'EPG_MAPPING_DIALOG.TITLE' | translate }}

+ + +

+ {{ 'EPG_MAPPING_DIALOG.SUBTITLE' | translate : { channelName: data.channelName } }} +

+ +
+ + {{ 'EPG_MAPPING_DIALOG.CURRENT_MAPPING' | translate }}: + + + {{ currentMapping() || ('EPG_MAPPING_DIALOG.NO_MAPPING' | translate) }} + +
+ + + {{ 'EPG_MAPPING_DIALOG.SEARCH_PLACEHOLDER' | translate }} + + search + @if (loading()) { + + } + @if (!hasSearchTerm()) { + + {{ + 'EPG_MAPPING_DIALOG.SEARCH_HINT' + | translate: { min: searchMinChars } + }} + + } + + + @if (hasSearchTerm()) { +
+ @for (channel of results(); track channel.id) { + + } @empty { +
+ search_off + {{ 'EPG_MAPPING_DIALOG.NO_RESULTS' | translate }} +
+ } +
+ } +
+ + + @if (currentMapping()) { + + } + + + diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.scss b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.scss new file mode 100644 index 000000000..dde913830 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.scss @@ -0,0 +1,95 @@ +.epg-mapping-subtitle { + margin: 0 0 16px; + color: var(--mat-sys-on-surface-variant); + font-size: 14px; +} + +.epg-mapping-current { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + padding: 8px 12px; + border-radius: 8px; + background: var(--mat-sys-surface-container-high); + + &__label { + font-size: 12px; + color: var(--mat-sys-on-surface-variant); + } + + &__value { + font-size: 14px; + font-weight: 500; + font-family: monospace; + } +} + +.epg-mapping-search { + width: 100%; + margin-bottom: 8px; +} + +.epg-mapping-results { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 300px; + overflow-y: auto; +} + +.epg-mapping-result { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 10px 12px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + cursor: pointer; + text-align: left; + width: 100%; + color: inherit; + transition: background 0.15s, border-color 0.15s; + + &:hover { + background: var(--mat-sys-surface-container-high); + } + + &--selected { + border-color: var(--mat-sys-primary); + background: color-mix( + in srgb, + var(--mat-sys-primary) 10%, + transparent + ); + } + + &__name { + font-size: 14px; + font-weight: 500; + color: var(--mat-sys-on-surface); + } + + &__id { + font-size: 11px; + color: var(--mat-sys-on-surface-variant); + font-family: monospace; + } +} + +.epg-mapping-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 32px; + color: var(--mat-sys-on-surface-variant); + + mat-icon { + font-size: 40px; + width: 40px; + height: 40px; + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts new file mode 100644 index 000000000..bc0e423c8 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts @@ -0,0 +1,205 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { debounceTime, from, Subject, switchMap } from 'rxjs'; + +export interface EpgMappingDialogData { + channelKey: string; + channelName: string; + /** Owning playlist for portal channels; omitted for M3U lookup keys. */ + playlistId?: string | null; +} + +export interface EpgMappingDialogResult { + channelKey: string; + epgChannelId: string; +} + +const SEARCH_MIN_CHARS = 2; + +@Component({ + selector: 'app-epg-mapping-dialog', + templateUrl: './epg-mapping-dialog.component.html', + styleUrl: './epg-mapping-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + TranslatePipe, + ], +}) +export class EpgMappingDialogComponent { + readonly data = inject(MAT_DIALOG_DATA); + private readonly dialogRef = inject( + MatDialogRef + ); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly epgService = inject(EpgService); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + + readonly searchMinChars = SEARCH_MIN_CHARS; + readonly searchTerm = signal(''); + readonly loading = signal(false); + readonly results = signal< + Array<{ id: string; displayName: string; iconUrl: string | null }> + >([]); + readonly selectedId = signal(null); + readonly currentMapping = signal(null); + + private readonly search$ = new Subject(); + + /** Open the dialog with the shared sizing used by every caller. */ + static open( + dialog: MatDialog, + data: EpgMappingDialogData + ): MatDialogRef { + return dialog.open(EpgMappingDialogComponent, { + data, + width: '500px', + maxHeight: '90vh', + }); + } + + constructor() { + // Fetch the current mapping (if any) on open — callers don't know it. + this.loadCurrentMapping(); + + this.search$ + .pipe( + debounceTime(300), + switchMap((term) => { + if (term.trim().length < SEARCH_MIN_CHARS) { + return from(Promise.resolve([])); + } + this.loading.set(true); + const promise = this.epgBridge.searchEpgChannels(term); + return from(promise ?? Promise.resolve([])); + }), + takeUntilDestroyed() + ) + .subscribe({ + next: (items) => { + this.results.set(items ?? []); + this.loading.set(false); + }, + error: () => { + this.results.set([]); + this.loading.set(false); + }, + }); + } + + readonly hasSearchTerm = computed( + () => this.searchTerm().trim().length >= SEARCH_MIN_CHARS + ); + + onSearchInput(value: string): void { + this.searchTerm.set(value); + this.search$.next(value); + } + + selectChannel(id: string): void { + this.selectedId.set(id); + } + + save(): void { + const epgChannelId = this.selectedId(); + if (!epgChannelId) return; + + this.epgBridge + .setEpgMapping( + this.data.channelKey, + epgChannelId, + this.data.playlistId ?? undefined + ) + .then((result) => { + if (!result?.success) { + this.notify('EPG_MAPPING_DIALOG.SAVE_FAILED'); + return; + } + this.epgService.clearCache(); + this.notify('EPG_MAPPING_DIALOG.SAVED'); + this.dialogRef.close({ + channelKey: this.data.channelKey, + epgChannelId, + }); + }); + } + + removeMapping(): void { + this.epgBridge.deleteEpgMapping(this.data.channelKey).then((result) => { + if (!result?.success) { + this.notify('EPG_MAPPING_DIALOG.SAVE_FAILED'); + return; + } + this.epgService.clearCache(); + this.notify('EPG_MAPPING_DIALOG.REMOVED'); + this.dialogRef.close(); + }); + } + + close(): void { + this.dialogRef.close(); + } + + /** + * Resolve the stored mapping and enrich it with the EPG channel's + * display name so the user sees "BBC One (bbc.one.uk)" instead of a + * bare ID. + */ + private loadCurrentMapping(): void { + this.epgBridge.getEpgMapping(this.data.channelKey).then(async (m) => { + const mappedId = m?.epgChannelId ?? null; + if (!mappedId) { + this.currentMapping.set(null); + return; + } + this.currentMapping.set(mappedId); + try { + const matches = + (await this.epgBridge.searchEpgChannels(mappedId, 10)) ?? + []; + const exact = matches.find((r) => r.id === mappedId); + if (exact?.displayName && exact.displayName !== mappedId) { + this.currentMapping.set( + `${exact.displayName} (${mappedId})` + ); + } + } catch { + // Display name enrichment is cosmetic — keep the bare ID. + } + }); + } + + private notify(key: string): void { + this.snackBar.open(this.translate.instant(key), undefined, { + duration: 2000, + }); + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html index 9960ee3db..769c96a45 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html @@ -69,6 +69,12 @@ > + @if (supportsEpgMapping) { + + } + } + } + diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss index 40c6c3558..57ccc3f1c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss @@ -63,3 +63,11 @@ padding-inline: 6px; } } + +.channel-context-menu-anchor { + position: fixed; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 49c1d6c9b..dac8cf39c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -25,6 +25,8 @@ import { SettingsStore, } from '@iptvnator/services'; import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { MatDialog } from '@angular/material/dialog'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { WebPlayerViewComponent } from '@iptvnator/ui/playback'; import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component'; @@ -41,10 +43,12 @@ class StubChannelListItemComponent { readonly progressPercentage = input(0); readonly showFavoriteButton = input(false); readonly showProgramInfoButton = input(false); + readonly showDetailsContextMenu = input(false); readonly isFavorite = input(false); readonly clicked = output(); readonly activated = output(); readonly favoriteToggled = output(); + readonly contextMenuRequested = output(); } @Component({ @@ -215,6 +219,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { resolveRadioPlayback: jest.fn(), fetchChannelEpg: jest.fn(), ensureBulkItvEpg: jest.fn(), + applyMappedItvEpg: jest.fn().mockResolvedValue(undefined), clearBulkItvEpgCache: jest.fn(() => { bulkItvEpgByChannel.set({}); bulkItvEpgLoaded.set(false); @@ -326,6 +331,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { get isElectron() { return Boolean(window.electron); }, + get supportsEpgMapping() { + return false; + }, }, }, { provide: PlaylistsService, useValue: playlistService }, @@ -343,6 +351,22 @@ describe('StalkerLiveStreamLayoutComponent', () => { open: jest.fn(), }, }, + { + provide: MatDialog, + useValue: { + open: jest.fn(), + }, + }, + { + provide: EpgRuntimeBridgeService, + useValue: { + supportsEpgMapping: false, + getEpgMapping: jest.fn().mockResolvedValue(null), + getEpgMappingsBatch: jest + .fn() + .mockResolvedValue(null), + }, + }, ], }) .overrideComponent(StalkerLiveStreamLayoutComponent, { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index e50766d38..02e33442d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -15,7 +15,9 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -23,6 +25,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { ChannelListItemComponent, ChannelListSkeletonComponent, + EpgMappingDialogComponent, ResizableDirective, } from '@iptvnator/ui/components'; import { @@ -31,6 +34,7 @@ import { SettingsStore, } from '@iptvnator/services'; import { + buildStalkerEpgMappingKey, Channel, EpgItem, EpgProgram, @@ -50,6 +54,7 @@ import { WebPlayerViewComponent, } from '@iptvnator/ui/playback'; import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { LiveLayoutSidebarStateService, PORTAL_PLAYER, @@ -86,6 +91,7 @@ type StalkerPlayableChannel = StalkerPortalItem & { EpgTimelineComponent, MatButtonModule, MatIconModule, + MatMenuModule, MatProgressSpinnerModule, MatTooltipModule, NgTemplateOutlet, @@ -99,6 +105,8 @@ type StalkerPlayableChannel = StalkerPortalItem & { export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly stalkerStore = inject(StalkerStore); private readonly playlistService = inject(PlaylistsService); + private readonly dialog = inject(MatDialog); + private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); private readonly portalPlayer = inject(PORTAL_PLAYER); @@ -147,8 +155,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly selectedChannelId = this.stalkerStore.selectedItvId; protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId; + + /** Context menu (Map EPG) */ + readonly contextMenuTrigger = + viewChild.required('contextMenuTrigger'); + readonly contextMenuChannel = signal(null); + readonly contextMenuPosition = signal({ x: '0px', y: '0px' }); readonly isElectron = this.runtime.isElectron; readonly supportsEpg = this.runtime.supportsEpg; + readonly supportsEpgMapping = this.runtime.supportsEpgMapping; readonly openStreamOnDoubleClick = computed(() => this.settingsStore.openStreamOnDoubleClick() ); @@ -311,6 +326,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } this.syncBulkEpgPreviews(channels); + + // Overlay manual EPG mappings for the rendered channels; the + // store dedupes per channel id, so this is cheap on rerenders. + if (this.supportsEpgMapping && channels.length > 0) { + const channelIds = channels.map((channel) => channel.id); + untracked(() => + void this.stalkerStore.applyMappedItvEpg(channelIds) + ); + } }); effect(() => { @@ -544,6 +568,94 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.selectedLiveEpgDate.set(selectedDate); } + // ── Context menu (Map EPG) ───────────────────────────────────── + + onChannelContextMenu(channel: StalkerItvChannel, event: MouseEvent): void { + this.contextMenuChannel.set(channel); + this.contextMenuPosition.set({ + x: `${event.clientX}px`, + y: `${event.clientY}px`, + }); + + const trigger = this.contextMenuTrigger(); + if (trigger.menuOpen) { + trigger.closeMenu(); + } + + queueMicrotask(() => { + this.contextMenuTrigger().openMenu(); + }); + } + + async openEpgMapping(): Promise { + const channel = this.contextMenuChannel(); + if (!channel) { + return; + } + + this.contextMenuTrigger().closeMenu(); + const playlistId = this.stalkerStore.currentPlaylist()?._id; + const channelId = normalizeStalkerEntityId(channel.id); + if (!playlistId || !channelId) { + return; + } + + const channelKey = buildStalkerEpgMappingKey( + String(playlistId), + channelId + ); + const mappingBefore = await this.epgBridge + .getEpgMapping(channelKey) + .catch(() => null); + + EpgMappingDialogComponent.open(this.dialog, { + channelKey, + channelName: channel.o_name || channel.name || channelId, + playlistId: String(playlistId), + }) + .afterClosed() + .subscribe(() => { + void this.refreshEpgAfterMappingChange( + channel, + channelKey, + mappingBefore?.epgChannelId ?? null + ); + }); + } + + /** + * Reload EPG state when the dialog actually changed the mapping — + * covers both save and removal; a plain cancel skips the reload. + */ + private async refreshEpgAfterMappingChange( + channel: StalkerItvChannel, + channelKey: string, + epgChannelIdBefore: string | null + ): Promise { + const mappingAfter = await this.epgBridge + .getEpgMapping(channelKey) + .catch(() => null); + if ((mappingAfter?.epgChannelId ?? null) === epgChannelIdBefore) { + return; + } + + this.stalkerStore.clearBulkItvEpgCache(); + const selectedId = this.selectedChannelId(); + const selected = selectedId + ? this.channels().find( + (item) => + normalizeStalkerEntityId(item.id) === + normalizeStalkerEntityId(selectedId) + ) + : null; + await this.loadEpgForChannel(selected ?? channel); + // Use the unfiltered list so an active search filter cannot drop + // the playing channel's mapping override. + await this.stalkerStore.applyMappedItvEpg( + this.channels().map((item) => item.id) + ); + } + private async loadEpgForChannel(item: StalkerItvChannel) { if (!this.supportsEpg) { this.fallbackEpgPrograms.set([]); diff --git a/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts b/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts index fd7453c38..62c0d54e6 100644 --- a/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts +++ b/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts @@ -1,4 +1,7 @@ -import { buildXtreamEpgMappingKey } from './epg-mapping-key.util'; +import { + buildStalkerEpgMappingKey, + buildXtreamEpgMappingKey, +} from './epg-mapping-key.util'; describe('buildXtreamEpgMappingKey', () => { it('scopes the key to the playlist', () => { @@ -19,3 +22,17 @@ describe('buildXtreamEpgMappingKey', () => { ); }); }); + +describe('buildStalkerEpgMappingKey', () => { + it('scopes the key to the playlist', () => { + expect(buildStalkerEpgMappingKey('portal-a', '205')).toBe( + 'stalker:portal-a:205' + ); + }); + + it('never collides with the Xtream key space', () => { + expect(buildStalkerEpgMappingKey('p', 1)).not.toBe( + buildXtreamEpgMappingKey('p', 1) + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts b/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts index 5173ea8d3..8f0b024e2 100644 --- a/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts +++ b/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts @@ -6,11 +6,11 @@ * channel name) directly — those identifiers are meaningful across * playlists, so a mapping saved once applies everywhere. * - * Xtream stream IDs, on the other hand, are provider-local integers: - * stream 12345 in portal A has nothing to do with stream 12345 in - * portal B. Mapping keys for Xtream channels therefore embed the - * playlist ID so mappings never leak across portals. Shared between the - * renderer and the Electron main process/worker, like + * Xtream stream IDs and Stalker channel IDs, on the other hand, are + * provider-local: stream 12345 in portal A has nothing to do with + * stream 12345 in portal B. Mapping keys for portal channels therefore + * embed the playlist ID so mappings never leak across portals. Shared + * between the renderer and the Electron main process/worker, like * `title-normalization.util.ts`. */ export function buildXtreamEpgMappingKey( @@ -19,3 +19,10 @@ export function buildXtreamEpgMappingKey( ): string { return `xtream:${playlistId}:${xtreamId}`; } + +export function buildStalkerEpgMappingKey( + playlistId: string, + stalkerId: number | string +): string { + return `stalker:${playlistId}:${stalkerId}`; +} From aa1941cf2ce0bf8cab0603016deb50396dfa23b5 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:39:59 +0200 Subject: [PATCH 13/26] fix(embedded-mpv): stop native-view video jump by moving control menus into the dock (#1207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(embedded-mpv): stop native-view video jump by moving menus into the dock Opening any control popover in the native-view embedded MPV dock used to shrink the MPV view by a 300 px bottom cutout so the popover DOM stayed clickable, which made mpv re-letterbox the video on every menu open/close. All five menus now render horizontally inside the fixed-height controls strip, so menu state never changes the native view bounds: - volume expands as an inline horizontal slider next to the mute button - audio/subtitle/speed/aspect morph the dock row into a back button, a panel title, and a scrollable chip ribbon (app-embedded-mpv-dock-panel) with wheel-to-horizontal-scroll mapping, edge fades, active-chip reveal/focus, roving arrow-key navigation, ellipsis + tooltips, and RTL-aware scrolling - boundsProvider loses the menus.anyOpen() cutout branch and the MENU_OPEN_BOTTOM_CUTOUT_PX constant is removed; HIDDEN_BOUNDS for modal overlays is unchanged - global arrow shortcuts (seek/volume) are suspended while a chip panel is open so arrows walk the chips; Esc, click-outside, and close-on-select semantics are preserved - new EMBEDDED_MPV.PLAYER.BACK i18n key in all 18 languages Regression coverage: the new dock-panels spec asserts the bounds provider returns full host bounds while every menu is open (fails against the old cutout behavior), plus panel morph/a11y/selection specs and a dedicated dock-panel component spec (keyboard, wheel, tabindex, emits). Frame-copy shared controls (app-player-controls) are untouched. Co-Authored-By: Claude Fable 5 * fix(embedded-mpv): address native-view dock review feedback Resolves the actionable P2 review comments on the dock rework: - Inline volume no longer clips the dock actions. At sidebar-constrained player widths (~480-660px, viewport wider than the 720px breakpoint) the new in-flow volume slider widened the non-shrinking actions column and, under overflow:hidden, clipped the fullscreen button. Add min-width:0 to .embedded-mpv-player__actions and __volume-group so the inline volume (a scroll container) compresses its own slider instead of pushing neighbors off-edge. Verified in Chromium: fullscreen stays visible down to 480px. - Space now selects a focused chip. onPanelKeydown stops Space/Enter from bubbling to the global shortcut handler (whose Space case preventDefault'd the button's native activation and toggled playback) without calling preventDefault itself, so the menuitemradio chip activates and emits chipSelected. Matches the WAI-ARIA menu activation-key expectation. - Simplify dock-panel opener tracking: always remember the toggled kind so focus restoration is correct if in-panel switching ever becomes reachable (currently unreachable — the toggle buttons are removed from the DOM while a panel is open); restoreOpenerFocus still no-ops unless focus fell to body. Not changed: the "closePanels no-ops when unavailable" comment — verified unreachable (chip selection closes via menus.close() directly, not through closePopovers; isAvailable() is engine-bound and the native dock only renders while it is true, with engine handoff calling menus.closeAll()). Regression test added for Space/Enter chip activation. The volume-overflow fix is CSS layout (no jsdom layout engine) and was validated in a real browser. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + docs/architecture/embedded-mpv-native.md | 50 +- docs/architecture/player-controls-contract.md | 5 +- .../embedded-mpv-dock-panel.component.html | 65 ++ .../embedded-mpv-dock-panel.component.scss | 166 ++++ .../embedded-mpv-dock-panel.component.spec.ts | 189 +++++ .../embedded-mpv-dock-panel.component.ts | 213 +++++ .../embedded-mpv-dock-panels.ts | 205 +++++ .../embedded-mpv-format.utils.ts | 10 - .../embedded-mpv-legacy-interactions.ts | 4 +- ...d-mpv-player.component.dock-panels.spec.ts | 324 ++++++++ .../embedded-mpv-player.component.html | 751 +++++++----------- .../embedded-mpv-player.component.scss | 139 +--- .../embedded-mpv-player.component.ts | 78 +- .../embedded-mpv-shortcuts.spec.ts | 17 + .../embedded-mpv-shortcuts.ts | 19 + .../embedded-mpv-ui-state.spec.ts | 16 + .../embedded-mpv-ui-state.ts | 19 +- 35 files changed, 1666 insertions(+), 622 deletions(-) create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.html create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.spec.ts create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.ts create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panels.ts create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index fb6f1f064..bf4ec546f 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "رجوع", "RETRY": "إعادة المحاولة", "STALLED": "يستغرق بدء هذا البث وقتًا أطول من المتوقع.", "PLAYBACK_FAILED": "فشل التشغيل عبر MPV المضمّن.", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3384a01bb..34d2bc61a 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "رجوع", "RETRY": "حاول مرة أخرى", "STALLED": "هاد البث كياخد وقت كثر من المتوقع باش يبدا.", "PLAYBACK_FAILED": "فشل التشغيل بـ MPV المدمج.", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index df3746201..307fe897c 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Назад", "RETRY": "Паўтарыць", "STALLED": "Гэты паток запускаецца даўжэй, чым чакалася.", "PLAYBACK_FAILED": "Не ўдалося прайграць праз убудаваны MPV.", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 5d324b6ac..e530ef81d 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Zurück", "RETRY": "Erneut versuchen", "STALLED": "Der Start dieses Streams dauert länger als erwartet.", "PLAYBACK_FAILED": "Die Wiedergabe mit eingebettetem MPV ist fehlgeschlagen.", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 292507e06..24ad44f67 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Πίσω", "RETRY": "Επανάληψη", "STALLED": "Η έναρξη αυτής της ροής καθυστερεί περισσότερο από το αναμενόμενο.", "PLAYBACK_FAILED": "Η αναπαραγωγή με το ενσωματωμένο MPV απέτυχε.", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 2ec687719..d0e7651a2 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Back", "RETRY": "Retry", "STALLED": "This stream is taking longer than expected to start.", "PLAYBACK_FAILED": "Embedded MPV playback failed.", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index bee82556b..752453717 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Atrás", "RETRY": "Reintentar", "STALLED": "Este stream está tardando más de lo esperado en iniciarse.", "PLAYBACK_FAILED": "Falló la reproducción de MPV integrado.", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index b020bc756..ae7d75e39 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Retour", "RETRY": "Réessayer", "STALLED": "Ce flux met plus de temps que prévu à démarrer.", "PLAYBACK_FAILED": "Échec de la lecture avec MPV intégré.", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 2207419fc..4b970e7e0 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Indietro", "RETRY": "Riprova", "STALLED": "L'avvio di questo stream sta richiedendo più tempo del previsto.", "PLAYBACK_FAILED": "Riproduzione con MPV integrato non riuscita.", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 24bed12b9..2d108d99c 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "戻る", "RETRY": "再試行", "STALLED": "このストリームの開始に予想より時間がかかっています。", "PLAYBACK_FAILED": "組み込みMPVでの再生に失敗しました。", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 563e17b7c..25b7226bd 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "뒤로", "RETRY": "다시 시도", "STALLED": "이 스트림의 시작이 예상보다 오래 걸리고 있습니다.", "PLAYBACK_FAILED": "내장 MPV 재생에 실패했습니다.", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 4a7fa9131..fff5dfbee 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Terug", "RETRY": "Opnieuw proberen", "STALLED": "Deze stream doet er langer over dan verwacht om te starten.", "PLAYBACK_FAILED": "Afspelen via Embedded MPV is mislukt.", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index d35d051ca..d853d98cb 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Wstecz", "RETRY": "Spróbuj ponownie", "STALLED": "Uruchamianie tego strumienia trwa dłużej niż zwykle.", "PLAYBACK_FAILED": "Odtwarzanie we wbudowanym MPV nie powiodło się.", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e1cfb1a61..c0cd4ade0 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Voltar", "RETRY": "Tentar novamente", "STALLED": "Este stream está demorando mais do que o esperado para iniciar.", "PLAYBACK_FAILED": "Falha na reprodução do MPV integrado.", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 5ca85485c..414e25772 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Назад", "RETRY": "Повторить", "STALLED": "Запуск этого потока занимает больше времени, чем ожидалось.", "PLAYBACK_FAILED": "Не удалось воспроизвести через встроенный MPV.", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index b0c0fd1c7..24c48b1c3 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Geri", "RETRY": "Tekrar dene", "STALLED": "Bu yayının başlaması beklenenden uzun sürüyor.", "PLAYBACK_FAILED": "Gömülü MPV oynatması başarısız oldu.", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 957581e4f..db2db90ae 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "返回", "RETRY": "重试", "STALLED": "该流的启动时间超出预期。", "PLAYBACK_FAILED": "嵌入式 MPV 播放失败。", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 292ff3b8f..ed43744f8 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "返回", "RETRY": "重試", "STALLED": "此串流的啟動時間比預期長。", "PLAYBACK_FAILED": "內嵌 MPV 播放失敗。", diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 458368467..6adbbaabf 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -246,7 +246,7 @@ service and the adapter): `attachEmbeddedMpvFrameView`/`detachEmbeddedMpvFrameView`. - Renderer: `EmbeddedMpvPlayerComponent` renders the canvas when `support.engine === 'frame-copy'` and skips the compositor workarounds — - no `HIDDEN_BOUNDS` when dialogs open, no popover bottom cutout; dialogs + no `HIDDEN_BOUNDS` when dialogs open; dialogs and the shared `app-player-controls` overlay stack above the canvas as ordinary DOM. The canvas fills the player root; the native dock's reserved controls height is not applied. Legacy embedded-MPV pointer/click, @@ -533,7 +533,7 @@ player component stays a view-oriented orchestrator and engine-specific controls host. The renderer files live under `libs/ui/playback/src/lib/embedded-mpv-player/`: -- `embedded-mpv-format.utils.ts` — pure helpers (`formatTime`, `audioTrackLabel`, `subtitleTrackLabel`, `speedLabel`, `aspectLabel`, `volumeIcon`, `volumeLabel`, `readStoredVolume`, `persistVolume`, `measureBounds`) and preset constants (`SPEED_PRESETS`, `ASPECT_PRESETS`, `HIDDEN_BOUNDS`, `MENU_OPEN_BOTTOM_CUTOUT_PX`). +- `embedded-mpv-format.utils.ts` — pure helpers (`formatTime`, `audioTrackLabel`, `subtitleTrackLabel`, `speedLabel`, `aspectLabel`, `volumeIcon`, `volumeLabel`, `readStoredVolume`, `persistVolume`, `measureBounds`) and preset constants (`SPEED_PRESETS`, `ASPECT_PRESETS`, `HIDDEN_BOUNDS`). - `embedded-mpv-controls.adapter.ts` — component-scoped `PlayerController` adapter for frame-copy. Maps session/support/playback signals to shared controls state and capabilities, delegates commands to @@ -554,7 +554,9 @@ controls host. The renderer files live under - `embedded-mpv-shortcuts.ts` — native-view-only `EmbeddedMpvShortcuts` class with `attach(handlers)` / `detach()`. Owns the legacy document keydown listener and routes through a callback interface; the component supplies - callbacks for Space/K, F, arrow keys, M, and Escape. + callbacks for Space/K, F, arrow keys, M, and Escape. The optional + `arrowKeysBlocked` handler suspends the seek/volume arrows while a dock + chip panel owns them for chip navigation. - `embedded-mpv-overlay-visibility.service.ts` — singleton service that exposes `overlayActive: signal`. Tracks `MatDialog.afterOpened`/ `afterAllClosed` for dialog-shaped overlays and falls back to a @@ -562,9 +564,21 @@ controls host. The renderer files live under backdrop-bearing CDK overlays. Native-view uses it to move the platform host off-screen; frame-copy uses it to gate shared playback shortcuts. - `embedded-mpv-ui-state.ts` — legacy native-view - `EmbeddedMpvMenuState` (single-open popover state machine) and + `EmbeddedMpvMenuState` (single-open menu state machine, incl. the + `dockPanelOpen` chip-panel signal that suspends arrow shortcuts) and `EmbeddedMpvFeedback` (transient keypress feedback). They are not the frame-copy shared-controls state. +- `embedded-mpv-dock-panels.ts` — native-view `EmbeddedMpvDockPanelState`: + builds the active horizontal chip-panel view model (audio, subtitle, speed, + aspect) from the menu state, routes chip selection back to the session + controller, and restores toggle-button focus after a panel closes. +- `embedded-mpv-dock-panel.component.ts` — standalone + `app-embedded-mpv-dock-panel` that morphs the dock row inside the + fixed-height controls strip: back button + title + horizontally scrollable + chip ribbon (`role="menu"` with `aria-orientation="horizontal"`, + `menuitemradio` chips, wheel-to-horizontal-scroll mapping, edge fades, + active-chip reveal/focus, roving arrow keys, RTL-aware). Keeping the panels + inside the strip is what lets menus open without any MPV bounds change. - `embedded-mpv-command-runner.ts` — transport/track/recording IPC delegation; contains addon-side throws; reconciles a returned snapshot only when the current canonical session id and returned snapshot id both match the captured command session id. - `embedded-mpv-session-factory.ts` — side-effect-free loading/error placeholder factories plus `waitForStartupPaint`. - `embedded-mpv-stalled-tracker.ts` — owns the 30-second loading timer and `stalled` signal. @@ -578,21 +592,37 @@ controls host. The renderer files live under ### Bounds compositing strategy -The following cutout strategy applies only to the native-view engine. Its video +The following strategy applies only to the native-view engine. Its video host paints outside the normal DOM stacking model, so any DOM region it covers cannot reliably receive pointer events and any CSS `z-index` competition is unwinnable. The component compensates with a single `boundsProvider(host)` -closure on the controller that returns one of three bound shapes, evaluated +closure on the controller that returns one of two bound shapes, evaluated each time the active bounds-sync runs: - **Modal overlay open** (any MatDialog, including the command palette) → `HIDDEN_BOUNDS`. The MPV video host moves off-screen so the dialog has the full window. -- **Control popover open** (any of the menu states above) → host bounds with `MENU_OPEN_BOTTOM_CUTOUT_PX` (300 px) removed from the bottom. The popover region becomes DOM-receiving while video keeps playing in the upper region. -- **Idle** → full host bounds. +- **Otherwise** → full host bounds. -The viewport DOM element also reserves `--embedded-mpv-controls-height` (64 px) at the bottom when controls are enabled, so the controls strip itself is always DOM and always reachable for hover-to-reveal even before the popover-cutout takes effect. +Control menus never influence bounds: all five (volume, audio, subtitle, +speed, aspect) render horizontally inside the fixed-height controls strip +below the video host. Volume expands as an inline horizontal slider next to +the mute button; the audio/subtitle/speed/aspect menus morph the dock row +into `app-embedded-mpv-dock-panel` — back button, panel title, and a +horizontally scrollable chip ribbon (vertical wheel mapped to horizontal +scroll, edge fades as continuation hints, auto-reveal and focus of the active +chip, roving arrow-key navigation, RTL-aware). Because the strip height never +changes, opening or closing a menu sends no new MPV bounds and the video never +re-letterboxes. The popover-era 300 px bottom cutout +(`MENU_OPEN_BOTTOM_CUTOUT_PX`) is gone; while a chip panel is open, the +global arrow-key shortcuts (seek/volume) are suspended so arrows walk the +chips instead. + +The viewport DOM element reserves `--embedded-mpv-controls-height` (64 px; +88 px under the narrow breakpoint) at the bottom when controls are enabled, so +the controls strip — including the in-dock panels — is always DOM and always +reachable for hover-to-reveal. For frame-copy, `boundsProvider` always returns the measured full host bounds: -there is no `HIDDEN_BOUNDS`, popover cutout, or reserved dock height. Dialogs +there is no `HIDDEN_BOUNDS` or reserved dock height. Dialogs and controls layer naturally over the canvas, while bounds sync still updates the helper's render size. diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index d6e1e52ea..dd565c991 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -506,8 +506,9 @@ back to the command's stale baseline. The native MPV surface paints outside Chromium's DOM stacking model. It keeps the compositor-safe fixed controls dock below the viewport. Modal overlays hide -the native surface with `HIDDEN_BOUNDS`, and control popovers reserve a bottom -cutout so their DOM region remains interactive. +the native surface with `HIDDEN_BOUNDS`; control menus render as horizontal +panels inside the fixed-height dock strip, so they stay interactive without +any bounds change. The transparent BrowserWindow / `NSWindowBelow` tunnel-and-backdrop approach is not the shipped architecture. The shared-controls integration does not add diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.html new file mode 100644 index 000000000..1f8a300d0 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.html @@ -0,0 +1,65 @@ +
+ + + {{ panel().title }} + +
+ + + +
+
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss new file mode 100644 index 000000000..d4a021e3f --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss @@ -0,0 +1,166 @@ +:host { + display: block; + min-width: 0; +} + +.embedded-mpv-dock-panel { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + animation: embedded-mpv-dock-panel-in 160ms ease-out; +} + +@keyframes embedded-mpv-dock-panel-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.embedded-mpv-dock-panel__back { + flex: 0 0 auto; +} + +.embedded-mpv-dock-panel__title { + flex: 0 0 auto; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + color: color-mix(in srgb, var(--mat-sys-on-surface) 70%, transparent); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.embedded-mpv-dock-panel__ribbon-shell { + position: relative; + flex: 1 1 auto; + min-width: 0; +} + +.embedded-mpv-dock-panel__ribbon { + display: flex; + align-items: center; + gap: 8px; + padding: 2px; + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; +} + +.embedded-mpv-dock-panel__ribbon::-webkit-scrollbar { + display: none; +} + +.embedded-mpv-dock-panel__chip { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + max-width: 220px; + height: 34px; + padding: 0 14px; + color: var(--mat-sys-on-surface); + background: transparent; + border: 1px solid var(--embedded-mpv-border); + border-radius: 999px; + cursor: pointer; + font: inherit; + font-size: 0.82rem; + white-space: nowrap; +} + +.embedded-mpv-dock-panel__chip:hover, +.embedded-mpv-dock-panel__chip:focus-visible { + background: color-mix(in srgb, var(--embedded-mpv-accent) 14%, transparent); +} + +.embedded-mpv-dock-panel__chip:focus-visible { + outline: 2px solid var(--embedded-mpv-accent); + outline-offset: 1px; +} + +.embedded-mpv-dock-panel__chip--selected { + background: color-mix(in srgb, var(--embedded-mpv-accent) 18%, transparent); + border-color: color-mix( + in srgb, + var(--embedded-mpv-accent) 55%, + transparent + ); +} + +.embedded-mpv-dock-panel__chip mat-icon { + flex: 0 0 auto; + width: 16px; + height: 16px; + font-size: 16px; + color: var(--embedded-mpv-accent); +} + +.embedded-mpv-dock-panel__chip-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.embedded-mpv-dock-panel__fade { + position: absolute; + top: 0; + bottom: 0; + width: 36px; + opacity: 0; + pointer-events: none; + transition: opacity 140ms ease-out; +} + +.embedded-mpv-dock-panel__fade--visible { + opacity: 1; +} + +.embedded-mpv-dock-panel__fade--start { + inset-inline-start: 0; + background: linear-gradient( + to right, + var(--embedded-mpv-glass), + transparent + ); +} + +.embedded-mpv-dock-panel__fade--end { + inset-inline-end: 0; + background: linear-gradient( + to left, + var(--embedded-mpv-glass), + transparent + ); +} + +:host-context([dir='rtl']) .embedded-mpv-dock-panel__fade--start { + background: linear-gradient( + to left, + var(--embedded-mpv-glass), + transparent + ); +} + +:host-context([dir='rtl']) .embedded-mpv-dock-panel__fade--end { + background: linear-gradient( + to right, + var(--embedded-mpv-glass), + transparent + ); +} + +@media (max-width: 720px) { + .embedded-mpv-dock-panel__title { + display: none; + } +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.spec.ts new file mode 100644 index 000000000..ba51e7313 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.spec.ts @@ -0,0 +1,189 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component'; +import { EmbeddedMpvDockPanelView } from './embedded-mpv-dock-panels'; + +const PANEL: EmbeddedMpvDockPanelView = { + kind: 'audio', + title: 'Audio tracks', + chips: Array.from({ length: 12 }, (_, index) => ({ + id: String(index + 1), + label: `Track ${index + 1} with a fairly long descriptive name`, + selected: index === 3, + })), +}; + +describe('EmbeddedMpvDockPanelComponent', () => { + let fixture: ComponentFixture; + + const chipButtons = (): HTMLButtonElement[] => + fixture.debugElement + .queryAll(By.css('.embedded-mpv-dock-panel__chip')) + .map((chip) => chip.nativeElement); + + const panelRoot = (): HTMLElement => + fixture.debugElement.query(By.css('.embedded-mpv-dock-panel')) + .nativeElement; + + const ribbon = (): HTMLElement => + fixture.debugElement.query(By.css('.embedded-mpv-dock-panel__ribbon')) + .nativeElement; + + const dispatchPanelKey = (key: string): KeyboardEvent => { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }); + (document.activeElement ?? panelRoot()).dispatchEvent(event); + return event; + }; + + const flushMicrotasks = () => new Promise(queueMicrotask); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EmbeddedMpvDockPanelComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(EmbeddedMpvDockPanelComponent); + fixture.componentRef.setInput('panel', PANEL); + fixture.componentRef.setInput('backLabel', 'Back'); + fixture.detectChanges(); + await flushMicrotasks(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('renders chips with tooltips, roving tabindex, and a selected marker', () => { + const chips = chipButtons(); + + expect(chips).toHaveLength(12); + expect(chips[3].getAttribute('aria-checked')).toBe('true'); + expect(chips[3].tabIndex).toBe(0); + expect(chips[0].tabIndex).toBe(-1); + expect(chips[5].getAttribute('title')).toBe(PANEL.chips[5].label); + expect(chips[3].querySelector('mat-icon')).not.toBeNull(); + expect(chips[0].querySelector('mat-icon')).toBeNull(); + }); + + it('focuses the selected chip when the panel opens', () => { + expect(document.activeElement).toBe(chipButtons()[3]); + }); + + it('walks chips with arrow keys, Home, and End', () => { + const chips = chipButtons(); + + dispatchPanelKey('ArrowRight'); + expect(document.activeElement).toBe(chips[4]); + + dispatchPanelKey('ArrowLeft'); + dispatchPanelKey('ArrowLeft'); + expect(document.activeElement).toBe(chips[2]); + + dispatchPanelKey('End'); + expect(document.activeElement).toBe(chips[11]); + dispatchPanelKey('ArrowRight'); + expect(document.activeElement).toBe(chips[11]); + + dispatchPanelKey('Home'); + expect(document.activeElement).toBe(chips[0]); + dispatchPanelKey('ArrowLeft'); + expect(document.activeElement).toBe(chips[0]); + }); + + it('claims arrow keys so document-level shortcuts never see them', () => { + const documentKeydown = jest.fn(); + document.addEventListener('keydown', documentKeydown); + + const horizontal = dispatchPanelKey('ArrowRight'); + const vertical = dispatchPanelKey('ArrowUp'); + const escape = dispatchPanelKey('Escape'); + + expect(horizontal.defaultPrevented).toBe(true); + expect(vertical.defaultPrevented).toBe(true); + expect(escape.defaultPrevented).toBe(false); + + const seenKeys = documentKeydown.mock.calls.map( + ([event]: [KeyboardEvent]) => event.key + ); + expect(seenKeys).toEqual(['Escape']); + + document.removeEventListener('keydown', documentKeydown); + }); + + it('lets Space/Enter activate a chip without leaking to global shortcuts', () => { + // The global shortcut handler's Space case calls preventDefault() + + // togglePaused(); preventing the keydown default would suppress the + // chip button's native activation. The panel must stop Space/Enter + // from reaching the document without cancelling their default action. + const documentKeydown = jest.fn(); + document.addEventListener('keydown', documentKeydown); + + const space = dispatchPanelKey(' '); + const enter = dispatchPanelKey('Enter'); + + // Not defaultPrevented → the focused chip button still activates. + expect(space.defaultPrevented).toBe(false); + expect(enter.defaultPrevented).toBe(false); + // stopPropagation → the global handler never sees them. + expect(documentKeydown).not.toHaveBeenCalled(); + + document.removeEventListener('keydown', documentKeydown); + }); + + it('maps vertical wheel deltas to horizontal ribbon scrolling', () => { + const ribbonEl = ribbon(); + ribbonEl.scrollLeft = 0; + + const wheel = new WheelEvent('wheel', { + deltaY: 120, + deltaX: 0, + cancelable: true, + }); + ribbonEl.dispatchEvent(wheel); + + expect(ribbonEl.scrollLeft).toBe(120); + expect(wheel.defaultPrevented).toBe(true); + + const horizontalSwipe = new WheelEvent('wheel', { + deltaY: 2, + deltaX: 40, + cancelable: true, + }); + ribbonEl.dispatchEvent(horizontalSwipe); + + expect(ribbonEl.scrollLeft).toBe(120); + expect(horizontalSwipe.defaultPrevented).toBe(false); + }); + + it('emits chipSelected and closed', () => { + const selected = jest.fn(); + const closed = jest.fn(); + fixture.componentInstance.chipSelected.subscribe(selected); + fixture.componentInstance.closed.subscribe(closed); + + chipButtons()[7].click(); + expect(selected).toHaveBeenCalledWith('8'); + + fixture.debugElement + .query(By.css('[data-test-id="embedded-mpv-dock-panel-back"]')) + .nativeElement.click(); + expect(closed).toHaveBeenCalledTimes(1); + }); + + it('keeps the first chip tabbable when no chip is selected', () => { + fixture.componentRef.setInput('panel', { + kind: 'aspect', + title: 'Aspect ratio', + chips: PANEL.chips.map((chip) => ({ ...chip, selected: false })), + }); + fixture.detectChanges(); + + const chips = chipButtons(); + expect(chips[0].tabIndex).toBe(0); + expect(chips[1].tabIndex).toBe(-1); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.ts new file mode 100644 index 000000000..712bb1e01 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.ts @@ -0,0 +1,213 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, + effect, + inject, + input, + output, + signal, + untracked, + viewChild, + viewChildren, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import type { + EmbeddedMpvDockPanelKind, + EmbeddedMpvDockPanelView, +} from './embedded-mpv-dock-panels'; + +/** + * Horizontal chip panel that morphs the native-view embedded MPV dock row: + * back button + title on the inline-start side, then a scrollable chip + * ribbon. It lives inside the fixed-height controls strip, so opening it + * never changes the native MPV view bounds (no video re-letterboxing). + */ +@Component({ + selector: 'app-embedded-mpv-dock-panel', + templateUrl: './embedded-mpv-dock-panel.component.html', + styleUrl: './embedded-mpv-dock-panel.component.scss', + imports: [MatButtonModule, MatIconModule, MatTooltipModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmbeddedMpvDockPanelComponent implements OnDestroy { + readonly panel = input.required(); + readonly backLabel = input.required(); + + readonly chipSelected = output(); + readonly closed = output(); + + readonly canScrollStart = signal(false); + readonly canScrollEnd = signal(false); + + private readonly elementRef = inject>(ElementRef); + private readonly ribbon = + viewChild>('ribbon'); + private readonly chips = + viewChildren>('chip'); + + private lastRevealedKind: EmbeddedMpvDockPanelKind | null = null; + private destroyed = false; + + constructor() { + effect(() => { + const panel = this.panel(); + const kind = panel.kind; + const hasChips = panel.chips.length > 0; + untracked(() => { + const isNewPanel = kind !== this.lastRevealedKind; + this.lastRevealedKind = kind; + queueMicrotask(() => { + if (this.destroyed) { + return; + } + this.updateScrollState(); + if (isNewPanel && hasChips) { + this.focusAndRevealSelectedChip(); + } + }); + }); + }); + } + + ngOnDestroy(): void { + this.destroyed = true; + } + + chipTabIndex(selected: boolean, index: number): number { + if (selected) { + return 0; + } + const hasSelected = this.panel().chips.some((chip) => chip.selected); + return !hasSelected && index === 0 ? 0 : -1; + } + + onRibbonScroll(): void { + this.updateScrollState(); + } + + onRibbonWheel(event: WheelEvent): void { + // Vertical wheel drives horizontal ribbon scrolling; dominant + // horizontal deltas (trackpad swipes) keep native scrolling. + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) { + return; + } + const ribbonEl = this.ribbon()?.nativeElement; + if (!ribbonEl) { + return; + } + event.preventDefault(); + ribbonEl.scrollLeft += event.deltaY * (this.isRtl() ? -1 : 1); + this.updateScrollState(); + } + + onPanelKeydown(event: KeyboardEvent): void { + const key = event.key; + if (key === ' ' || key === 'Enter') { + // Let the focused chip button activate natively (Space/Enter → + // click → chipSelected), but stop the keydown before it reaches + // the global shortcut handler, whose Space case would otherwise + // preventDefault() the activation and toggle playback instead. + // No preventDefault here — that would re-suppress the click. + event.stopPropagation(); + return; + } + if (key === 'ArrowUp' || key === 'ArrowDown') { + // The panel owns the keyboard: never let the global volume + // shortcuts fire while a chip panel is open. + event.preventDefault(); + event.stopPropagation(); + return; + } + if ( + key !== 'ArrowLeft' && + key !== 'ArrowRight' && + key !== 'Home' && + key !== 'End' + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const chips = this.chips().map((chip) => chip.nativeElement); + if (!chips.length) { + return; + } + chips[this.nextChipIndex(key, chips)]?.focus(); + } + + private nextChipIndex(key: string, chips: HTMLButtonElement[]): number { + if (key === 'Home') { + return 0; + } + if (key === 'End') { + return chips.length - 1; + } + const forward = (key === 'ArrowRight') !== this.isRtl(); + const currentIndex = chips.indexOf( + document.activeElement as HTMLButtonElement + ); + if (currentIndex === -1) { + return forward ? 0 : chips.length - 1; + } + return Math.max( + 0, + Math.min(chips.length - 1, currentIndex + (forward ? 1 : -1)) + ); + } + + private focusAndRevealSelectedChip(): void { + const chips = this.chips().map((chip) => chip.nativeElement); + const selected = + chips.find((chip) => chip.getAttribute('aria-checked') === 'true') ?? + chips[0]; + if (!selected) { + return; + } + try { + selected.focus({ preventScroll: true }); + } catch { + selected.focus(); + } + this.centerChipInRibbon(selected); + } + + private centerChipInRibbon(chip: HTMLElement): void { + const ribbonEl = this.ribbon()?.nativeElement; + if (!ribbonEl) { + return; + } + const ribbonRect = ribbonEl.getBoundingClientRect(); + const chipRect = chip.getBoundingClientRect(); + // Physical-axis math keeps this correct in RTL; the browser clamps + // scrollLeft to the valid range on both directions. + ribbonEl.scrollLeft += + chipRect.left + + chipRect.width / 2 - + (ribbonRect.left + ribbonRect.width / 2); + this.updateScrollState(); + } + + private updateScrollState(): void { + const ribbonEl = this.ribbon()?.nativeElement; + if (!ribbonEl) { + return; + } + const maxScroll = Math.max( + 0, + ribbonEl.scrollWidth - ribbonEl.clientWidth + ); + const offset = Math.abs(ribbonEl.scrollLeft); + this.canScrollStart.set(maxScroll > 1 && offset > 1); + this.canScrollEnd.set(maxScroll > 1 && offset < maxScroll - 1); + } + + private isRtl(): boolean { + return ( + getComputedStyle(this.elementRef.nativeElement).direction === 'rtl' + ); + } +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panels.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panels.ts new file mode 100644 index 000000000..e12e0c5ae --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panels.ts @@ -0,0 +1,205 @@ +import { Signal, computed } from '@angular/core'; +import { EmbeddedMpvAudioTrack } from '@iptvnator/shared/interfaces'; +import { ASPECT_PRESETS, SPEED_PRESETS } from './embedded-mpv-format.utils'; +import type { EmbeddedMpvMenuState } from './embedded-mpv-ui-state'; + +export type EmbeddedMpvDockPanelKind = + | 'audio' + | 'subtitle' + | 'speed' + | 'aspect'; + +export interface EmbeddedMpvDockChip { + readonly id: string; + readonly label: string; + readonly selected: boolean; +} + +export interface EmbeddedMpvDockPanelView { + readonly kind: EmbeddedMpvDockPanelKind; + readonly title: string; + readonly chips: readonly EmbeddedMpvDockChip[]; +} + +/** Chip id representing the "subtitles off" (-1) selection. */ +export const SUBTITLES_OFF_CHIP_ID = 'off'; + +export interface EmbeddedMpvDockPanelDeps { + readonly menus: EmbeddedMpvMenuState; + readonly audioTracks: Signal; + readonly subtitleTracks: Signal; + readonly selectedSubtitleTrackId: Signal; + readonly playbackSpeed: Signal; + readonly aspectOverride: Signal; + readonly translateLabel: (key: string) => string; + readonly audioTrackLabel: ( + track: EmbeddedMpvAudioTrack, + index: number + ) => string; + readonly subtitleTrackLabel: ( + track: EmbeddedMpvAudioTrack, + index: number + ) => string; + readonly aspectLabel: (aspect: string) => string; + readonly selectAudioTrack: (trackId: number) => void; + readonly selectSubtitleTrack: (trackId: number) => void; + readonly selectSpeed: (speed: number) => void; + readonly selectAspect: (aspect: string) => void; + readonly closePanels: () => void; + readonly playerRoot: () => HTMLElement | null; + readonly revealControls: () => void; +} + +/** + * View model for the horizontal in-dock chip panels of the native-view + * embedded MPV dock (audio, subtitle, speed, aspect). The panels morph the + * controls row inside its fixed-height strip, so opening one never changes + * the native MPV view bounds — unlike the removed popover-era bottom cutout. + */ +export class EmbeddedMpvDockPanelState { + readonly active: Signal; + + private openerKind: EmbeddedMpvDockPanelKind | null = null; + + constructor(private readonly deps: EmbeddedMpvDockPanelDeps) { + this.active = computed(() => this.buildActivePanel()); + } + + toggle(kind: EmbeddedMpvDockPanelKind): void { + // Always remember the button that opened a panel so focus returns to + // it when the panel closes. restoreOpenerFocus() only acts when focus + // has fallen to document.body, so re-toggling a panel with focus still + // on its button never steals focus. + this.openerKind = kind; + this.deps.menus.toggle(kind); + this.deps.revealControls(); + } + + /** + * Call whenever `menus.dockPanelOpen()` changes. When a panel closes it + * takes keyboard focus down with it (the dock row re-renders, so the + * pre-open button instance no longer exists); focus is handed to the + * freshly rendered toggle button of the menu that was open. + */ + handlePanelOpenChange(panelOpen: boolean): void { + if (panelOpen) { + return; + } + const kind = this.openerKind; + this.openerKind = null; + if (!kind) { + return; + } + queueMicrotask(() => this.restoreOpenerFocus(kind)); + } + + select(chipId: string): void { + const panel = this.active(); + if (!panel) { + return; + } + switch (panel.kind) { + case 'audio': + this.deps.selectAudioTrack(Number(chipId)); + return; + case 'subtitle': + this.deps.selectSubtitleTrack( + chipId === SUBTITLES_OFF_CHIP_ID ? -1 : Number(chipId) + ); + return; + case 'speed': + this.deps.selectSpeed(Number(chipId)); + return; + case 'aspect': + this.deps.selectAspect(chipId); + return; + } + } + + close(): void { + this.deps.closePanels(); + } + + private restoreOpenerFocus(kind: EmbeddedMpvDockPanelKind): void { + if ( + document.activeElement !== document.body && + document.activeElement !== null + ) { + return; + } + this.deps + .playerRoot() + ?.querySelector( + `[data-embedded-mpv-menu-button="${kind}"]` + ) + ?.focus(); + } + + private buildActivePanel(): EmbeddedMpvDockPanelView | null { + const { menus } = this.deps; + if (menus.audioOpen()) { + return { + kind: 'audio', + title: this.deps.translateLabel( + 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' + ), + chips: this.deps + .audioTracks() + .map((track, index) => ({ + id: String(track.id), + label: this.deps.audioTrackLabel(track, index), + selected: track.selected === true, + })), + }; + } + if (menus.subtitleOpen()) { + return { + kind: 'subtitle', + title: this.deps.translateLabel('EMBEDDED_MPV.PLAYER.SUBTITLES'), + chips: [ + { + id: SUBTITLES_OFF_CHIP_ID, + label: this.deps.translateLabel( + 'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' + ), + selected: this.deps.selectedSubtitleTrackId() === null, + }, + ...this.deps.subtitleTracks().map((track, index) => ({ + id: String(track.id), + label: this.deps.subtitleTrackLabel(track, index), + selected: track.selected === true, + })), + ], + }; + } + if (menus.speedOpen()) { + const speed = this.deps.playbackSpeed(); + return { + kind: 'speed', + title: this.deps.translateLabel( + 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' + ), + chips: SPEED_PRESETS.map((preset) => ({ + id: String(preset.value), + label: preset.label, + selected: preset.value === speed, + })), + }; + } + if (menus.aspectOpen()) { + const aspect = this.deps.aspectOverride(); + return { + kind: 'aspect', + title: this.deps.translateLabel( + 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' + ), + chips: ASPECT_PRESETS.map((preset) => ({ + id: preset.value, + label: this.deps.aspectLabel(preset.value), + selected: preset.value === aspect, + })), + }; + } + return null; + } +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts index 2e8635b6d..2fb657c2a 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts @@ -10,16 +10,6 @@ export const HIDDEN_BOUNDS: EmbeddedMpvBounds = Object.freeze({ height: 1, }) as EmbeddedMpvBounds; -/** - * Vertical pixels to subtract from the MPV view's height when a control - * popover (volume, audio, subtitle, speed, aspect) is open above the - * controls strip. The native NSView paints over the WebContents, so we - * shrink it from the bottom to expose the popover region in DOM. Sized to - * cover the tallest popover (audio/subtitle list capped at ~240 px plus - * title + padding); video keeps playing in the upper region. - */ -export const MENU_OPEN_BOTTOM_CUTOUT_PX = 300; - export const SPEED_PRESETS: ReadonlyArray<{ value: number; label: string }> = [ { value: 0.5, label: '0.5×' }, { value: 0.75, label: '0.75×' }, diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts index 343da45bf..20b3b3bb0 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts @@ -17,7 +17,6 @@ export interface EmbeddedMpvLegacyInteractionsDeps { readonly statusLabel: () => string; readonly togglePaused: () => void | Promise; readonly toggleFullscreen: () => void | Promise; - readonly triggerBoundsSync: () => void; } export class EmbeddedMpvLegacyInteractions { @@ -165,8 +164,9 @@ export class EmbeddedMpvLegacyInteractions { if (!this.deps.isAvailable() || !this.deps.menus.anyOpen()) { return; } + // Menus live inside the fixed-height dock strip, so closing them + // needs no bounds resync — the native MPV view never moved. this.deps.menus.closeAll(); - this.deps.triggerBoundsSync(); this.scheduleControlsHide(); } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts new file mode 100644 index 000000000..b7d38ed10 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts @@ -0,0 +1,324 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; +import { + EmbeddedMpvSession, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { HIDDEN_BOUNDS } from './embedded-mpv-format.utils'; +import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service'; +import { EmbeddedMpvPlayerComponent } from './embedded-mpv-player.component'; +import { + EmbeddedMpvBoundsProvider, + EmbeddedMpvSessionController, +} from './embedded-mpv-session-controller'; + +@Component({ + imports: [EmbeddedMpvPlayerComponent], + template: ``, +}) +class DockPanelsHostComponent { + playback: ResolvedPortalPlayback = { + streamUrl: 'https://example.test/movie/42.mp4', + title: 'Movie', + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 42, + contentType: 'movie', + }, + }; +} + +const HOST_RECT = { left: 4, top: 8, width: 1280, height: 720 }; +const FULL_BOUNDS = { x: 4, y: 8, width: 1280, height: 720 }; +const HOST_STUB = { + getBoundingClientRect: () => HOST_RECT, +} as unknown as HTMLElement; + +describe('EmbeddedMpvPlayerComponent dock panels', () => { + let fixture: ComponentFixture; + let player: EmbeddedMpvPlayerComponent; + let controller: EmbeddedMpvSessionController; + let boundsProviderSpy: jest.SpyInstance; + const overlayActive = signal(false); + + const boundsProvider = (): EmbeddedMpvBoundsProvider => + boundsProviderSpy.mock.calls[0][0]; + + const query = (selector: string) => + fixture.debugElement.query(By.css(selector)); + const queryAll = (selector: string) => + fixture.debugElement.queryAll(By.css(selector)); + + const configureReadyController = () => { + controller.support.set({ + supported: true, + platform: 'darwin', + engine: 'native', + capabilities: { + subtitles: true, + playbackSpeed: true, + aspectOverride: true, + screenshot: false, + recording: false, + }, + }); + controller.session.set({ + id: 'session-1', + title: 'Movie', + streamUrl: 'https://example.test/movie/42.mp4', + status: 'playing', + positionSeconds: 30, + durationSeconds: 120, + volume: 1, + audioTracks: [ + { + id: 1, + language: 'eng', + selected: true, + defaultTrack: true, + }, + { id: 2, language: 'deu', selected: false }, + ], + selectedAudioTrackId: 1, + subtitleTracks: [ + { id: 21, language: 'eng', selected: false }, + { id: 22, language: 'deu', selected: false }, + ], + selectedSubtitleTrackId: null, + playbackSpeed: 1, + aspectOverride: 'no', + recording: { active: false }, + startedAt: '2026-07-19T12:00:00Z', + updatedAt: '2026-07-19T12:00:00Z', + } satisfies EmbeddedMpvSession); + fixture.detectChanges(); + }; + + beforeEach(async () => { + overlayActive.set(false); + boundsProviderSpy = jest.spyOn( + EmbeddedMpvSessionController.prototype, + 'setBoundsProvider' + ); + + await TestBed.configureTestingModule({ + imports: [DockPanelsHostComponent, TranslateModule.forRoot()], + providers: [ + { + provide: EmbeddedMpvOverlayVisibilityService, + useValue: { overlayActive }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DockPanelsHostComponent); + fixture.detectChanges(); + const playerDebugElement = fixture.debugElement.query( + By.directive(EmbeddedMpvPlayerComponent) + ); + player = playerDebugElement.componentInstance; + controller = playerDebugElement.injector.get( + EmbeddedMpvSessionController + ); + configureReadyController(); + }); + + afterEach(() => { + fixture.destroy(); + boundsProviderSpy.mockRestore(); + }); + + it('keeps full host bounds while every control menu is open', () => { + const provider = boundsProvider(); + + expect(provider(HOST_STUB)).toEqual(FULL_BOUNDS); + + for (const menu of [ + 'volume', + 'audio', + 'subtitle', + 'speed', + 'aspect', + ] as const) { + player.menus.open(menu); + // Core regression: menus render inside the fixed dock strip, so + // the native MPV view must never shrink (no bottom cutout). + expect(provider(HOST_STUB)).toEqual(FULL_BOUNDS); + } + }); + + it('still hides the native view while a modal overlay is active', () => { + overlayActive.set(true); + expect(boundsProvider()(HOST_STUB)).toEqual(HIDDEN_BOUNDS); + + overlayActive.set(false); + expect(boundsProvider()(HOST_STUB)).toEqual(FULL_BOUNDS); + }); + + it('morphs the dock row into a horizontal audio panel with menu roles', () => { + query('[data-embedded-mpv-menu-button="audio"]').nativeElement.click(); + fixture.detectChanges(); + + expect(query('.embedded-mpv-player__transport')).toBeNull(); + expect(query('app-embedded-mpv-dock-panel')).not.toBeNull(); + + const ribbon = query('.embedded-mpv-dock-panel__ribbon'); + expect(ribbon.attributes['role']).toBe('menu'); + expect(ribbon.attributes['aria-orientation']).toBe('horizontal'); + + const chips = queryAll('.embedded-mpv-dock-panel__chip'); + expect(chips).toHaveLength(2); + expect(chips[0].attributes['role']).toBe('menuitemradio'); + expect(chips[0].nativeElement.getAttribute('aria-checked')).toBe( + 'true' + ); + expect(chips[0].nativeElement.tabIndex).toBe(0); + expect(chips[1].nativeElement.getAttribute('aria-checked')).toBe( + 'false' + ); + expect(chips[1].nativeElement.tabIndex).toBe(-1); + expect(chips[1].nativeElement.getAttribute('title')).toContain('deu'); + }); + + it('selects an audio chip, closes the panel, and restores the row', async () => { + const setAudioTrack = jest + .spyOn(controller, 'setAudioTrack') + .mockResolvedValue(undefined); + + query('[data-embedded-mpv-menu-button="audio"]').nativeElement.click(); + fixture.detectChanges(); + queryAll('.embedded-mpv-dock-panel__chip')[1].nativeElement.click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(setAudioTrack).toHaveBeenCalledWith(2); + expect(player.menus.audioOpen()).toBe(false); + expect(query('app-embedded-mpv-dock-panel')).toBeNull(); + expect(query('.embedded-mpv-player__transport')).not.toBeNull(); + }); + + it('renders the subtitles-off chip first and maps it to track -1', async () => { + const setSubtitleTrack = jest + .spyOn(controller, 'setSubtitleTrack') + .mockResolvedValue(undefined); + + query( + '[data-embedded-mpv-menu-button="subtitle"]' + ).nativeElement.click(); + fixture.detectChanges(); + + const chips = queryAll('.embedded-mpv-dock-panel__chip'); + expect(chips).toHaveLength(3); + expect(chips[0].nativeElement.getAttribute('aria-checked')).toBe( + 'true' + ); + + chips[0].nativeElement.click(); + await fixture.whenStable(); + expect(setSubtitleTrack).toHaveBeenCalledWith(-1); + + query( + '[data-embedded-mpv-menu-button="subtitle"]' + ).nativeElement.click(); + fixture.detectChanges(); + queryAll('.embedded-mpv-dock-panel__chip')[2].nativeElement.click(); + await fixture.whenStable(); + expect(setSubtitleTrack).toHaveBeenCalledWith(22); + }); + + it('selects speed and aspect presets from horizontal chip rows', async () => { + const setSpeed = jest + .spyOn(controller, 'setSpeed') + .mockResolvedValue(undefined); + const setAspect = jest + .spyOn(controller, 'setAspect') + .mockResolvedValue(undefined); + + query('[data-embedded-mpv-menu-button="speed"]').nativeElement.click(); + fixture.detectChanges(); + expect(queryAll('.embedded-mpv-dock-panel__chip')).toHaveLength(6); + queryAll('.embedded-mpv-dock-panel__chip')[4].nativeElement.click(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(setSpeed).toHaveBeenCalledWith(1.5); + + query('[data-embedded-mpv-menu-button="aspect"]').nativeElement.click(); + fixture.detectChanges(); + expect(queryAll('.embedded-mpv-dock-panel__chip')).toHaveLength(5); + queryAll('.embedded-mpv-dock-panel__chip')[1].nativeElement.click(); + await fixture.whenStable(); + expect(setAspect).toHaveBeenCalledWith('16:9'); + }); + + it('expands volume inline without morphing the row', () => { + player.menus.open('volume'); + fixture.detectChanges(); + + expect(query('.embedded-mpv-player__volume-inline')).not.toBeNull(); + expect(query('.embedded-mpv-player__transport')).not.toBeNull(); + expect(query('app-embedded-mpv-dock-panel')).toBeNull(); + + const slider = query('.embedded-mpv-player__slider--volume') + .nativeElement as HTMLInputElement; + slider.value = '0.4'; + slider.dispatchEvent(new Event('input', { bubbles: true })); + fixture.detectChanges(); + + expect(player.volume()).toBe(0.4); + }); + + it('closes an open panel with Escape', () => { + query('[data-embedded-mpv-menu-button="speed"]').nativeElement.click(); + fixture.detectChanges(); + expect(player.menus.speedOpen()).toBe(true); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); + fixture.detectChanges(); + + expect(player.menus.anyOpen()).toBe(false); + expect(query('.embedded-mpv-player__transport')).not.toBeNull(); + }); + + it('blocks seek and volume arrow shortcuts while a chip panel is open', () => { + const seekBy = jest + .spyOn(controller, 'seekBy') + .mockResolvedValue(true); + const volumeBefore = player.volume(); + + query('[data-embedded-mpv-menu-button="audio"]').nativeElement.click(); + fixture.detectChanges(); + + for (const key of [ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + ]) { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }) + ); + } + + expect(seekBy).not.toHaveBeenCalled(); + expect(player.volume()).toBe(volumeBefore); + + player.menus.closeAll(); + fixture.detectChanges(); + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true, + }) + ); + expect(seekBy).toHaveBeenCalledWith(5); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index 6377edab6..492ddc6fd 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -111,229 +111,249 @@ [class.embedded-mpv-player__controls--visible]=" controlsAreVisible() " + [class.embedded-mpv-player__controls--panel]=" + dockPanels.active() !== null + " > -
- - - @if (showSeriesNavigation()) { - - - - } - - - - -
- -
- -
- {{ formatTime(timelineValue()) }} - @if (canSeek()) { - {{ - formatTime(session()?.durationSeconds) - }} - } @else if (isLivePlayback()) { - - - LIVE - - } @else { - --:-- - } -
- @if (recordingStatusText(); as recordingStatus) { -
+
- } -
- -
-
- - @if (menus.volumeOpen()) { - - } -
- @if (hasAudioTracks()) { -
+ @if (showSeriesNavigation()) { + + + } + + + + +
+ +
+ +
+ {{ formatTime(timelineValue()) }} + @if (canSeek()) { + {{ + formatTime(session()?.durationSeconds) + }} + } @else if (isLivePlayback()) { + + + LIVE + + } @else { + --:-- + } +
+ @if (recordingStatusText(); as recordingStatus) { +
+ {{ + isRecording() + ? 'fiber_manual_record' + : 'check_circle' + }} + {{ recordingStatus }} +
+ } +
+ +
+
+ + @if (menus.volumeOpen()) { +
+ + {{ volumePercent() }} +
+ } +
+ + @if (hasAudioTracks()) { + - } -
-
- } - - } + } - @if (capabilities().subtitles && hasSubtitleTracks()) { -
+ @if (capabilities().subtitles && hasSubtitleTracks()) { - @for ( - track of subtitleTracks(); - track track.id; - let index = $index - ) { - - } -
- - } - - } + } - @if (capabilities().playbackSpeed) { -
+ @if (capabilities().playbackSpeed) { - } -
- - } - - } + } - @if (capabilities().aspectOverride) { -
+ @if (capabilities().aspectOverride) { - } -
- - } - - } + } + + @if (canRecord()) { + + } - @if (canRecord()) { - } - - - + + } } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss index 23ffa5560..3c4151932 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss @@ -294,17 +294,29 @@ display: flex; align-items: center; gap: 8px; + // Let the actions cell shrink below its content so the inline volume + // (a scroll container) clips its own slider instead of pushing the + // fullscreen button past the overflow:hidden player edge on narrow + // sidebar-constrained widths (viewport wider than the 720px breakpoint). + min-width: 0; } -.embedded-mpv-player__popover-anchor { - position: relative; +.embedded-mpv-player__volume-group { display: flex; + align-items: center; + min-width: 0; } .embedded-mpv-player__control-button { position: relative; } +/* A dock panel morphs the whole controls row into a single full-width + cell; the strip keeps its fixed height so MPV bounds stay untouched. */ +.embedded-mpv-player__controls--panel { + grid-template-columns: minmax(0, 1fr); +} + .embedded-mpv-player__record-button--active { color: var(--mat-sys-error) !important; } @@ -398,115 +410,40 @@ box-shadow: none; } -.embedded-mpv-player__control-panel { - min-width: 0; - color: var(--mat-sys-on-surface); - background: var(--embedded-mpv-glass); - border: 1px solid var(--embedded-mpv-border); - border-radius: 14px; - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); - backdrop-filter: blur(18px); +/* Volume expands inline next to the mute icon, inside the actions row — + never above the strip, so it needs no compositor workarounds. */ +.embedded-mpv-player__volume-inline { + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; + animation: embedded-mpv-volume-in 160ms ease-out; } -.embedded-mpv-player__volume-popover { - position: absolute; - bottom: calc(100% + 6px); - right: 0; - display: grid; - grid-template-columns: minmax(160px, 200px); - gap: 4px; - padding: 12px 14px 10px; - z-index: 3; +@keyframes embedded-mpv-volume-in { + from { + max-width: 0; + opacity: 0; + } + to { + max-width: 200px; + opacity: 1; + } } -.embedded-mpv-player__volume-popover::before { - /* Hover-bridge so the cursor can move from the button to the popover - without crossing a dead zone. */ - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: -8px; - height: 8px; -} - -.embedded-mpv-player__volume-popover .embedded-mpv-player__slider--volume { - width: 100%; +.embedded-mpv-player__volume-inline .embedded-mpv-player__slider--volume { + width: 132px; } .embedded-mpv-player__volume-value { color: color-mix(in srgb, var(--mat-sys-on-surface) 80%, transparent); font-size: 0.74rem; font-variant-numeric: tabular-nums; - text-align: right; + min-width: 34px; + text-align: end; white-space: nowrap; } -.embedded-mpv-player__audio-popover { - position: absolute; - bottom: calc(100% + 6px); - right: 0; - min-width: 220px; - max-width: min(420px, 80vw); - padding: 10px 12px; - z-index: 3; -} - -.embedded-mpv-player__menu-title { - padding: 0 2px 6px; - color: color-mix(in srgb, var(--mat-sys-on-surface) 70%, transparent); - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - white-space: nowrap; -} - -.embedded-mpv-player__audio-track-list { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 240px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent) - transparent; -} - -.embedded-mpv-player__audio-track { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 10px; - color: var(--mat-sys-on-surface); - background: transparent; - border: 0; - border-radius: 10px; - cursor: pointer; - font: inherit; - text-align: left; -} - -.embedded-mpv-player__audio-track span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.embedded-mpv-player__audio-track:hover, -.embedded-mpv-player__audio-track:focus-visible, -.embedded-mpv-player__audio-track--selected { - background: color-mix(in srgb, var(--embedded-mpv-accent) 16%, transparent); -} - -.embedded-mpv-player__audio-track mat-icon { - color: var(--embedded-mpv-accent); - font-size: 18px; - width: 18px; - height: 18px; -} - .embedded-mpv-player :is(button[mat-icon-button]) { color: var(--mat-sys-on-surface); } @@ -531,9 +468,7 @@ min-width: 0; } - .embedded-mpv-player__volume-popover, - .embedded-mpv-player__audio-popover { - right: 0; - left: auto; + .embedded-mpv-player__volume-inline .embedded-mpv-player__slider--volume { + width: 96px; } } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index a5a388c24..74b1a003a 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -26,6 +26,8 @@ import { import { PlayerControlsComponent } from '../player-controls/player-controls.component'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter'; +import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component'; +import { EmbeddedMpvDockPanelState } from './embedded-mpv-dock-panels'; import { EmbeddedMpvLegacyInteractions } from './embedded-mpv-legacy-interactions'; import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service'; import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; @@ -37,7 +39,6 @@ import { import { ASPECT_PRESETS, HIDDEN_BOUNDS, - MENU_OPEN_BOTTOM_CUTOUT_PX, SPEED_PRESETS, aspectLabel, audioTrackLabel, @@ -57,6 +58,7 @@ const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000; templateUrl: './embedded-mpv-player.component.html', styleUrl: './embedded-mpv-player.component.scss', imports: [ + EmbeddedMpvDockPanelComponent, MatButtonModule, MatIconModule, MatProgressSpinnerModule, @@ -108,6 +110,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { private readonly shortcuts = new EmbeddedMpvShortcuts(); readonly menus = new EmbeddedMpvMenuState(); readonly feedback = new EmbeddedMpvFeedback(); + readonly dockPanels: EmbeddedMpvDockPanelState; readonly viewport = viewChild>('viewport'); readonly playerRoot = viewChild>('playerRoot'); @@ -230,6 +233,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { percent: Math.round(this.volume() * 100), }); }); + readonly volumePercent = computed( + () => `${Math.round(this.volume() * 100)}%` + ); + readonly dockPanelBackLabel = computed(() => { + this.translationsTick(); + return this.translate.instant('EMBEDDED_MPV.PLAYER.BACK'); + }); /** * Non-null while the user drags the timeline: the slider and time label * preview this value locally and the single seek IPC call is deferred to @@ -328,10 +338,34 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { statusLabel: this.statusLabel, togglePaused: () => this.togglePaused(), toggleFullscreen: () => this.toggleFullscreen(), - triggerBoundsSync: () => this.controller.triggerBoundsSync(), }); this.legacyInteractions.attach(); + this.dockPanels = new EmbeddedMpvDockPanelState({ + menus: this.menus, + audioTracks: this.audioTracks, + subtitleTracks: this.subtitleTracks, + selectedSubtitleTrackId: this.selectedSubtitleTrackId, + playbackSpeed: this.playbackSpeed, + aspectOverride: this.aspectOverride, + translateLabel: (key) => { + this.translationsTick(); + return this.translate.instant(key); + }, + audioTrackLabel: (track, index) => this.trackLabel(track, index), + subtitleTrackLabel: (track, index) => + this.subtitleLabel(track, index), + aspectLabel: (aspect) => this.aspectLabel(aspect), + selectAudioTrack: (trackId) => void this.selectAudioTrack(trackId), + selectSubtitleTrack: (trackId) => + void this.selectSubtitleTrack(trackId), + selectSpeed: (speed) => void this.selectSpeed(speed), + selectAspect: (aspect) => void this.selectAspect(aspect), + closePanels: () => this.legacyInteractions.closePopovers(), + playerRoot: () => this.playerRoot()?.nativeElement ?? null, + revealControls: () => this.legacyInteractions.revealControls(), + }); + this.sharedControls.configure({ playback: this.playback, seriesNavigation: this.seriesNavigation, @@ -347,32 +381,26 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.controller.setBoundsProvider((host) => { // The frame-copy engine paints into an ordinary DOM canvas: - // dialogs and popovers stack above it natively, so the - // hide-offscreen and popover-cutout compositor workarounds - // must not shrink its render size. + // dialogs and overlays stack above it natively, so the + // hide-offscreen compositor workaround must not shrink its + // render size. if (this.isFrameCopyEngine()) { return measureBounds(host); } if (this.overlayVisibility.overlayActive()) { return HIDDEN_BOUNDS; } - const rect = measureBounds(host); - if (this.menus.anyOpen()) { - return { - ...rect, - height: Math.max( - 1, - rect.height - MENU_OPEN_BOTTOM_CUTOUT_PX - ), - }; - } - return rect; + // Control menus render as horizontal panels inside the + // fixed-height dock strip below the video host, so open menus + // never require shrinking the native MPV view. + return measureBounds(host); }); this.shortcuts.attach({ isAvailable: () => this.legacyInteractions.isAvailable() && !this.overlayVisibility.overlayActive(), + arrowKeysBlocked: () => this.menus.dockPanelOpen(), onEscape: () => this.legacyInteractions.closePopovers(), togglePaused: () => void this.togglePaused(), toggleFullscreen: () => void this.toggleFullscreen(), @@ -411,10 +439,14 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { effect(() => { this.overlayVisibility.overlayActive(); - this.menus.anyOpen(); this.controller.triggerBoundsSync(); }); + effect(() => { + const panelOpen = this.menus.dockPanelOpen(); + untracked(() => this.dockPanels.handlePanelOpenChange(panelOpen)); + }); + effect(() => { const session = this.session(); if (!session) { @@ -601,20 +633,16 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } toggleAudioMenu(): void { - this.menus.toggle('audio'); - this.legacyInteractions.revealControls(); + this.dockPanels.toggle('audio'); } toggleSubtitleMenu(): void { - this.menus.toggle('subtitle'); - this.legacyInteractions.revealControls(); + this.dockPanels.toggle('subtitle'); } toggleSpeedMenu(): void { - this.menus.toggle('speed'); - this.legacyInteractions.revealControls(); + this.dockPanels.toggle('speed'); } toggleAspectMenu(): void { - this.menus.toggle('aspect'); - this.legacyInteractions.revealControls(); + this.dockPanels.toggle('aspect'); } async selectAudioTrack(trackId: number): Promise { diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts index c9140dd44..5c39d60d7 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts @@ -48,6 +48,23 @@ describe('EmbeddedMpvShortcuts', () => { expect(handlers.toggleMute).toHaveBeenCalledTimes(1); }); + it('suspends arrow shortcuts while a dock chip panel owns the keyboard', () => { + shortcuts.detach(); + shortcuts.attach({ ...handlers, arrowKeysBlocked: () => true }); + + expect(dispatchKey('ArrowLeft')).toBe(false); + expect(dispatchKey('ArrowRight')).toBe(false); + expect(dispatchKey('ArrowUp')).toBe(false); + expect(dispatchKey('ArrowDown')).toBe(false); + expect(dispatchKey(' ')).toBe(true); + expect(dispatchKey('m')).toBe(true); + + expect(handlers.seekBy).not.toHaveBeenCalled(); + expect(handlers.adjustVolume).not.toHaveBeenCalled(); + expect(handlers.togglePaused).toHaveBeenCalledTimes(1); + expect(handlers.toggleMute).toHaveBeenCalledTimes(1); + }); + it('always allows escape to close popovers even when playback is unavailable', () => { handlers.isAvailable.mockReturnValue(false); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts index 785cb6131..80f01e253 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts @@ -1,5 +1,10 @@ export interface EmbeddedMpvShortcutHandlers { isAvailable: () => boolean; + /** + * While true, arrow keys stop seeking/adjusting volume — an open dock + * chip panel owns them for chip navigation instead. + */ + arrowKeysBlocked?: () => boolean; onEscape: () => void; togglePaused: () => void; toggleFullscreen: () => void; @@ -41,6 +46,8 @@ export class EmbeddedMpvShortcuts { return; } + const arrowsBlocked = handlers.arrowKeysBlocked?.() === true; + switch (event.key) { case ' ': case 'k': @@ -54,18 +61,30 @@ export class EmbeddedMpvShortcuts { handlers.toggleFullscreen(); return; case 'ArrowLeft': + if (arrowsBlocked) { + return; + } event.preventDefault(); handlers.seekBy(-5); return; case 'ArrowRight': + if (arrowsBlocked) { + return; + } event.preventDefault(); handlers.seekBy(5); return; case 'ArrowUp': + if (arrowsBlocked) { + return; + } event.preventDefault(); handlers.adjustVolume(0.05); return; case 'ArrowDown': + if (arrowsBlocked) { + return; + } event.preventDefault(); handlers.adjustVolume(-0.05); return; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.spec.ts index f4a27a224..ed1a57041 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.spec.ts @@ -19,6 +19,22 @@ describe('EmbeddedMpvMenuState', () => { expect(menus.audioOpen()).toBe(false); expect(menus.anyOpen()).toBe(false); }); + + it('reports dockPanelOpen for chip panels but not the volume slider', () => { + const menus = new EmbeddedMpvMenuState(); + + menus.open('volume'); + expect(menus.anyOpen()).toBe(true); + expect(menus.dockPanelOpen()).toBe(false); + + for (const menu of ['audio', 'subtitle', 'speed', 'aspect'] as const) { + menus.open(menu); + expect(menus.dockPanelOpen()).toBe(true); + } + + menus.closeAll(); + expect(menus.dockPanelOpen()).toBe(false); + }); }); describe('EmbeddedMpvFeedback', () => { diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.ts index 93fe8a967..962c42568 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.ts @@ -8,8 +8,10 @@ export type EmbeddedMpvMenu = | 'aspect'; /** - * Tracks which menu/popover is currently open and exposes individual signals - * the template binds to. Only one menu can be open at a time. + * Tracks which menu is currently open and exposes individual signals the + * template binds to. Only one menu can be open at a time. Menus render as + * horizontal panels inside the fixed-height controls strip, so open state + * never affects the native MPV view bounds. */ export class EmbeddedMpvMenuState { readonly volumeOpen = signal(false); @@ -27,6 +29,19 @@ export class EmbeddedMpvMenuState { this.aspectOpen() ); + /** + * True while a chip panel morphs the dock row (audio, subtitle, speed, + * aspect — not the inline volume slider). While open, arrow keys walk + * the chips instead of seeking or changing the volume. + */ + readonly dockPanelOpen = computed( + () => + this.audioOpen() || + this.subtitleOpen() || + this.speedOpen() || + this.aspectOpen() + ); + toggle(menu: EmbeddedMpvMenu): void { const target = this.signalFor(menu); const next = !target(); From 402382421c29d5b2ca6f5a591e9afdf426c25032 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:42:20 +0200 Subject: [PATCH 14/26] feat(matching): strip appended language/quality tags in title normalization (#1211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(matching): strip appended language/quality tags in title normalization Real-world portal catalogs duplicate one show under dozens of tagged variants ("|ALB| Fallout", "4K-DE - The Pitt (2025) (US)", "Breaking Bad-eng", "Fallout_esp", "The Last of Us (2023) AF"). A third of them normalized to polluted keys, silently skipping TMDB enrichment and staying invisible to cross-portal title matching. normalizeTitleKeys() now handles, conservatively: - wrapped pipe tags: "|ALB| X", "|MULTI| X" - longer/compound leads: "EXYU| X", "4K-DE - X", "AR-SUBS - X", "4K-OSN+ - X" (dash/pipe only; colon stays 2-3 chars so "NCIS: LA" is untouched) - underscore suffixes: "X_eng", "(US)_msub" (single-underscore only, "The_Last_of_Us" stays intact) - double-dash suffixes: "X--esp" - joined dash tags: "X-DE", "X-eng" (vocabulary-gated and case-uniform only; "Spider-Man", "Kick-It", "Peut-être" are untouched) - bare trailing tags: "X (2025) DE", "Breaking Bad ES" (UPPERCASE vocabulary only, skipped for ALL-CAPS titles; "Rocky II", "Made in USA", "Making It" are untouched) Every leading-tag segment must contain a letter, so numeric titles ("1917 - ...") are never treated as tags. The display-side stripCountryPrefix() learns the same compound/plus-sign prefixes and the numeric guard. buildSearchLookupKey() gets a |v2 suffix so cached negative TMDB match resolutions keyed on old polluted titles are invalidated. Measured on 248 real catalog names from four shows (The Pitt, Fallout, The Last of Us, Breaking Bad): clean matching keys 65% -> 99%, display strip 91% -> 100%. The corpora are committed as spec fixtures. Co-Authored-By: Claude Fable 5 * fix(matching): use ES2015-safe trailing trim in title normalization String.prototype.trimEnd is ES2019; the shared-interfaces lib compiles against an older lib target (TS2550 in typecheck:web). Replace with a regex-based trimRight helper. Jest uses its own tsconfig, so this only surfaced in the CI typecheck, not local unit runs. Co-Authored-By: Claude Fable 5 * fix(matching): guard tag stripping against real-title false positives Address code-review findings on the tag-stripping rules: - underscore suffix is now vocabulary-gated, so "Mr_Robot", "Cowboy_Bebop", "Mrs_Davis" keep their second word - leading single-segment tags before a spaced dash stay 2-3 chars (only hyphen-compounds like "4K-DE" and pipe-tags like "EXYU|" may be wider), so "DUNE - Part Two" and "ALIEN - Covenant" are left intact - "IN" is excluded from the weak joined-dash/underscore paths so "drive-in" and "Plug-in" are not truncated (India still strips via the strong "IN| " / "IN - " forms) The display-side stripCountryPrefix() mirrors the narrowed dash rule. Corpus coverage is unchanged at 99% (245/248); new counter-example tests lock in the guards. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../src/lib/tmdb/tmdb-matcher.spec.ts | 4 +- libs/services/src/lib/tmdb/tmdb-matcher.ts | 5 +- .../src/lib/title-normalization.util.spec.ts | 137 ++++++++++++++++ .../src/lib/title-normalization.util.ts | 152 ++++++++++++++++-- .../src/lib/strip-country-prefix.util.spec.ts | 30 ++++ .../src/lib/strip-country-prefix.util.ts | 22 ++- 6 files changed, 334 insertions(+), 16 deletions(-) diff --git a/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts b/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts index 394bc89f4..f61f7ce86 100644 --- a/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-matcher.spec.ts @@ -78,10 +78,10 @@ describe('extractYear', () => { describe('lookup keys', () => { it('builds stable search and details keys', () => { expect(buildSearchLookupKey('the matrix', 1999)).toBe( - 'title:the matrix|year:1999' + 'title:the matrix|year:1999|v2' ); expect(buildSearchLookupKey('the matrix', null)).toBe( - 'title:the matrix|year:' + 'title:the matrix|year:|v2' ); expect(buildDetailsLookupKey(603)).toBe('id:603|v2'); }); diff --git a/libs/services/src/lib/tmdb/tmdb-matcher.ts b/libs/services/src/lib/tmdb/tmdb-matcher.ts index a07d2e16f..2c60cca56 100644 --- a/libs/services/src/lib/tmdb/tmdb-matcher.ts +++ b/libs/services/src/lib/tmdb/tmdb-matcher.ts @@ -70,7 +70,10 @@ export function buildSearchLookupKey( normalizedTitle: string, year: number | null ): string { - return `title:${normalizedTitle}|year:${year ?? ''}`; + // v2: normalizeTitleKeys learned to strip appended language/quality + // tags; the version suffix invalidates cached (incl. negative) match + // resolutions keyed on the old polluted titles + return `title:${normalizedTitle}|year:${year ?? ''}|v2`; } export function buildDetailsLookupKey(tmdbId: number): string { diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts b/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts index 3c60b3d8a..6566ae95c 100644 --- a/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts +++ b/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts @@ -56,6 +56,143 @@ describe('normalizeTitleKeys', () => { }); }); +describe('provider tag stripping', () => { + it('strips wrapped pipe tags', () => { + expect(normalizeTitle('|DE| ARD')).toBe('ard'); + expect(normalizeTitle('|MULTI| Fallout - 4K')).toBe('fallout'); + expect(normalizeTitle('|EXYU| The Pitt')).toBe('the pitt'); + }); + + it('strips long and compound leading tags', () => { + expect(normalizeTitle('EXYU| Fallout')).toBe('fallout'); + expect(normalizeTitle('MULTI| Breaking Bad')).toBe('breaking bad'); + expect(normalizeTitle('4K-DE - The Pitt (2025) (US)')).toBe( + 'the pitt' + ); + expect(normalizeTitle('AR-SUBS - Fallout (2024) (US)')).toBe( + 'fallout' + ); + expect(normalizeTitle('4K-OSN+ - The Last of Us (2023)')).toBe( + 'the last of us' + ); + }); + + it('never treats numeric fragments as leading tags', () => { + expect(normalizeTitle('1917 - Behind the Lines')).toBe( + '1917 behind the lines' + ); + }); + + it('keeps bare 4-5 char words before a spaced dash (real titles)', () => { + expect(normalizeTitle('DUNE - Part Two')).toBe('dune part two'); + expect(normalizeTitle('ALIEN - Covenant')).toBe('alien covenant'); + }); + + it('strips underscore and double-dash suffix tags', () => { + expect(normalizeTitle('Fallout_eng')).toBe('fallout'); + expect(normalizeTitle('Breaking Bad (US)_msub')).toBe('breaking bad'); + expect(normalizeTitle('The Pitt (2025)_sub')).toBe('the pitt'); + expect(normalizeTitle('The Last of Us--esp')).toBe('the last of us'); + }); + + it('keeps underscore-as-space titles intact', () => { + expect(normalizeTitle('The_Last_of_Us')).toBe('the last of us'); + }); + + it('keeps sole-underscore titles whose tail is not a known tag', () => { + expect(normalizeTitle('Mr_Robot')).toBe('mr robot'); + expect(normalizeTitle('Cowboy_Bebop')).toBe('cowboy bebop'); + expect(normalizeTitle('Mrs_Davis')).toBe('mrs davis'); + }); + + it('strips joined dash tags only for case-uniform vocabulary tokens', () => { + expect(normalizeTitle('Breaking Bad-eng')).toBe('breaking bad'); + expect(normalizeTitle('The Last of Us-DE')).toBe('the last of us'); + expect(normalizeTitle('The Pitt (2025)-it')).toBe('the pitt'); + expect(normalizeTitle('Spider-Man')).toBe('spider man'); + expect(normalizeTitle('Kick-It')).toBe('kick it'); + }); + + it('keeps English hyphenated word endings that collide with codes', () => { + expect(normalizeTitle('drive-in')).toBe('drive in'); + expect(normalizeTitle('Plug-in')).toBe('plug in'); + }); + + it('strips bare trailing UPPERCASE vocabulary tags', () => { + expect(normalizeTitle('The Pitt (2025) DE')).toBe('the pitt'); + expect(normalizeTitle('Breaking Bad ES')).toBe('breaking bad'); + expect(normalizeTitle('EN| Breaking Bad SUB')).toBe('breaking bad'); + expect(normalizeTitle('The Last of Us (2023) AF')).toBe( + 'the last of us' + ); + }); + + it('never strips trailing tags that could be real endings', () => { + expect(normalizeTitle('Rocky II')).toBe('rocky ii'); + expect(normalizeTitle('Made in USA')).toBe('made in usa'); + expect(normalizeTitle('NCIS: LA')).toBe('ncis la'); + expect(normalizeTitle('Making It')).toBe('making it'); + expect(normalizeTitle('THE LAST OF US')).toBe('the last of us'); + }); + + const pittCorpus = [ + 'The Pitt (2025)_sub', 'The Pitt (2025)-it', 'The Pitt (2025)', + 'The Pitt (Hindi)', 'The Pitt (2025) 4K', 'The Pitt (2025) DE', + 'The Pitt (2025) ES', 'The Pitt (2025) FR', 'The Pitt (2025)_eng', + 'The Pitt [MULTI-SUB]', 'The Pitt (2025) (4K DV)', 'GR - The Pitt', + '4K-DE - The Pitt (2025) (US)', '4K-TR - The Pitt (2025) (US)', + 'AR-SUBS - The Pitt (2025) (US)', 'DE - The Pitt (2025) (US)', + 'ALB| The Pitt', 'EXYU| The Pitt', '|ALB| The Pitt', '|DE| The Pitt', + ]; + + const falloutCorpus = [ + 'Fallout', 'DE - Fallout (2024)', 'Fallout (2024) - 4K', + 'Fallout (2024) FR-EN', 'Fallout (2024) Multi', 'Fallout (2024)_fr', + 'Fallout_esp', 'Fallout (4K)', '4K-AMZ - Fallout (2024) (US)', + 'AL - Fallout (2024)', 'AMZ - Fallout (2024) (US)', + 'AR-DE - Fallout (US)', 'LA - Fallout', 'EN| Fallout - 4K', + 'MULTI| Fallout - 4K', 'Fallout ( مدبلج )', 'Fallout (Telugu)', + '|EN| Fallout - 4K', '|MULTI| Fallout', '|TR| Fallout', + ]; + + const lastOfUsCorpus = [ + 'The Last of Us', 'The Last Of Us', 'The Last of Us (2023) 4K', + 'The Last of Us (2023) AF', 'The Last of Us_tr', + 'The Last of Us--esp', 'The Last of Us-DE', 'The Last of Us-esp', + 'The Last of Us [L]', 'The Last of Us ( HD )', + '4K-OSN+ - The Last of Us (2023)', 'IS - The Last of Us (2023) (US)', + 'RU - The Last of Us', 'ALB| The Last of Us', + ]; + + const breakingBadCorpus = [ + 'Breaking Bad', 'Breaking Bad (2008)_fr', 'Breaking Bad (US)_msub', + 'Breaking Bad_it', 'Breaking Bad-DE', 'Breaking Bad-eng', + 'Breaking Bad ( عائلي )', 'Breaking Bad (Pure)', + 'Breaking Bad - Multi', 'Breaking Bad ES', 'AR-DE - Breaking Bad', + 'EN| Breaking Bad SUB', 'MULTI| Breaking Bad', 'AR| Breaking Bad', + ]; + + it.each([ + ['the pitt', pittCorpus], + ['fallout', falloutCorpus], + ['the last of us', lastOfUsCorpus], + ['breaking bad', breakingBadCorpus], + ])( + 'normalizes every observed provider variant of "%s" to one key', + (expected, corpus) => { + for (const name of corpus) { + expect(normalizeTitleKeys(name).base).toBe(expected); + } + } + ); + + it('keeps localized subtitles (indistinguishable from real ones)', () => { + expect(normalizeTitle('Breaking Bad: A Química do Mal')).toBe( + 'breaking bad a quimica do mal' + ); + }); +}); + describe('titleYearsCompatible', () => { it('accepts unknown years and ±1 tolerance', () => { expect(titleYearsCompatible(null, 2049)).toBe(true); diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.ts b/libs/shared/interfaces/src/lib/title-normalization.util.ts index 3b746683b..b06c73694 100644 --- a/libs/shared/interfaces/src/lib/title-normalization.util.ts +++ b/libs/shared/interfaces/src/lib/title-normalization.util.ts @@ -28,11 +28,142 @@ const QUALITY_TAGS = new Set([ ]); /** - * Leading channel/language prefix like "EN - ", "DE| ", "FR: ". - * UPPERCASE-only on purpose: a case-insensitive match would amputate real - * title words ("It: Chapter Two" → "Chapter Two"). + * Wrapped tag at the very start of a provider title: "|DE| ARD", + * "|MULTI| Fallout". The lookahead requires a letter in the tag so a + * numeric fragment can never be treated as one. */ -const LANGUAGE_PREFIX = /^[A-Z]{2,3}\s*[-|:]\s+/; +const WRAPPED_TAG_PREFIX = /^\s*\|(?=[0-9+]*[A-Z])[A-Z0-9+]{2,5}\|\s*/; + +/** + * Leading channel/language prefix like "EN - ", "DE| ", "FR: ", including + * compound provider/quality forms ("4K-DE - ", "AR-SUBS - ", "4K-OSN+ - ") + * and longer pipe-tagged forms ("EXYU| ", "MULTI| "). + * UPPERCASE-only on purpose: a case-insensitive match would amputate real + * title words ("It: Chapter Two" → "Chapter Two"). Every segment must + * contain a letter so numeric titles ("1917 - ...") are never tags. + * + * Separator strength gates how wide a single segment may be: + * - dash ("EN - "): compound OR 2–3 chars — a bare 4–5 char word before + * a spaced dash is a real title ("DUNE - Part Two", "ALIEN - Covenant") + * - pipe ("EXYU| "): compound OR 2–5 chars — a pipe is a strong tag signal + * - colon ("EN: "): 2–3 chars — longer acronyms are franchise titles + * ("NCIS: LA") + */ +const SEG = '(?=[0-9+]*[A-Z])[A-Z0-9+]'; +const COMPOUND_TAG = `${SEG}{2,5}(?:-${SEG}{2,6}){1,2}`; +const LANGUAGE_PREFIX = new RegExp( + '^(?:' + + `(?:${COMPOUND_TAG}|${SEG}{2,3})\\s*-\\s+` + + `|(?:${COMPOUND_TAG}|${SEG}{2,5})\\s*\\|\\s+` + + `|${SEG}{2,3}\\s*:\\s+` + + ')' +); + +/** + * Curated whitelist for TRAILING language/subtitle tags ("Fallout_eng", + * "Breaking Bad-DE", "The Pitt (2025) ES"). Trailing stripping must be + * vocabulary-gated: a pattern-only rule would amputate real endings — + * roman numerals ("Rocky II"), acronyms ("Made in USA"), franchise + * suffixes ("NCIS: LA"). US/USA/UK/LA are deliberately absent. + */ +const TRAILING_TAG_VOCABULARY = new Set([ + 'AF', 'AL', 'ALB', 'AR', 'BY', 'DE', 'DUB', 'EN', 'ENG', 'ES', 'ESP', + 'EXYU', 'FR', 'FRA', 'GE', 'GR', 'HU', 'IN', 'IR', 'IS', 'IT', 'ITA', + 'KA', 'KU', 'LAT', 'ML', 'MSUB', 'MULTI', 'NL', 'PL', 'PT', 'RO', 'RU', + 'SC', 'SE', 'SUB', 'SUBS', 'SW', 'TA', 'TL', 'TR', 'TUR', +]); + +/** + * Vocabulary tags that are also common English hyphenated-word endings + * ("drive-in", "plug-in"). Accepted only after a STRONG separator (bare + * spaced/uppercase form), never in the weak joined-dash/underscore forms + * where "X-in"/"X_in" reads as a title, not a tag. + */ +const WEAK_JOIN_EXCLUSIONS = new Set(['IN']); + +const DOUBLE_DASH_SUFFIX = /[-–]{2}[A-Za-z]{2,5}\s*$/; +const UNDERSCORE_SUFFIX = /_([A-Za-z]{2,5})\s*$/; +const JOINED_DASH_SUFFIX = /-([A-Za-z]{2,5})\s*$/; +const TRAILING_TAG_SUFFIX = /\s([A-Z]{2,5})\s*$/; + +/** Tag tokens are case-uniform; real title words are Capitalized. */ +function isCaseUniform(token: string): boolean { + return token === token.toLowerCase() || token === token.toUpperCase(); +} + +/** + * A captured joined-dash/underscore token is provider metadata only when + * it is a known vocabulary tag, case-uniform, and not one of the English + * word-forming exclusions. This keeps "Mr_Robot", "Cowboy_Bebop", + * "drive-in", and "Plug-in" intact while stripping "_eng", "-DE", "-it". + */ +function isJoinedTag(token: string): boolean { + const upper = token.toUpperCase(); + return ( + TRAILING_TAG_VOCABULARY.has(upper) && + !WEAK_JOIN_EXCLUSIONS.has(upper) && + isCaseUniform(token) + ); +} + +/** ES2015-safe trailing-whitespace trim (the lib target predates trimEnd). */ +function trimRight(value: string): string { + return value.replace(/\s+$/, ''); +} + +/** + * Strip appended language/subtitle tags. Runs BEFORE lowercasing — + * casing is the main false-positive guard: ALL-CAPS titles carry no + * casing signal ("THE LAST OF US" must keep its "US"), so caps-gated + * rules are skipped for them, and Capitalized endings ("Making It", + * "Kick-It") never look like tags. Compound tags ("FR-EN") shed one + * token per pass, so stripping repeats to a fixpoint. + */ +function stripTrailingTags(value: string): string { + let result = value; + for (let pass = 0; pass < 3; pass++) { + const next = stripTrailingTagOnce(result); + if (next === result) break; + result = next; + } + return result; +} + +function stripTrailingTagOnce(value: string): string { + const result = trimRight(value); + const hasLowercase = /\p{Ll}/u.test(result); + + // "The Last of Us--esp": no real title contains a double dash. + if (DOUBLE_DASH_SUFFIX.test(result)) { + return trimRight(result.replace(DOUBLE_DASH_SUFFIX, '')); + } + + // "Fallout_eng" — but not "The_Last_of_Us" (underscores as spaces, only + // strip a sole underscore) and not "Mr_Robot"/"Cowboy_Bebop" (the tail + // must be a known tag, so the segment is real-title evidence otherwise). + const underscore = result.match(UNDERSCORE_SUFFIX); + if ( + underscore && + result.indexOf('_') === result.lastIndexOf('_') && + isJoinedTag(underscore[1]) + ) { + return trimRight(result.replace(UNDERSCORE_SUFFIX, '')); + } + + // "Breaking Bad-eng", "The Last of Us-DE" — but not "drive-in"/"Plug-in". + // hasLowercase gates ALL-CAPS titles out (no casing signal to trust). + const joined = result.match(JOINED_DASH_SUFFIX); + if (joined && hasLowercase && isJoinedTag(joined[1])) { + return trimRight(result.replace(JOINED_DASH_SUFFIX, '')); + } + + const trailing = result.match(TRAILING_TAG_SUFFIX); + if (trailing && hasLowercase && TRAILING_TAG_VOCABULARY.has(trailing[1])) { + return trimRight(result.replace(TRAILING_TAG_SUFFIX, '')); + } + + return result; +} const YEAR_PATTERN = /\b(19\d{2}|20\d{2})\b/; @@ -74,11 +205,14 @@ export function normalizeTitleKeys( return { exact: '', base: '', trailingYear: null }; } - const cleaned = raw - // Inner classes exclude the opening delimiter too, so runaway - // inputs like "[[[[[..." backtrack linearly (CodeQL js/polynomial-redos) - .replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ') - .replace(LANGUAGE_PREFIX, '') + const cleaned = stripTrailingTags( + raw + .replace(WRAPPED_TAG_PREFIX, '') + // Inner classes exclude the opening delimiter too, so runaway + // inputs like "[[[[[..." backtrack linearly (CodeQL js/polynomial-redos) + .replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ') + .replace(LANGUAGE_PREFIX, '') + ) .normalize('NFD') .replace(/[\u0300-\u036F]/g, '') .toLowerCase() diff --git a/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.spec.ts b/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.spec.ts index abbf90037..3633d4e2e 100644 --- a/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.spec.ts +++ b/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.spec.ts @@ -43,6 +43,36 @@ describe('stripCountryPrefix', () => { expect(stripCountryPrefix('US: CNN')).toBe('CNN'); }); + it('strips compound quality/provider tags', () => { + expect(stripCountryPrefix('4K-DE - The Pitt (2025)')).toBe( + 'The Pitt (2025)' + ); + expect(stripCountryPrefix('AR-SUBS - Fallout')).toBe('Fallout'); + expect(stripCountryPrefix('4K-OSN+ - The Last of Us')).toBe( + 'The Last of Us' + ); + }); + + it('strips longer pipe-tagged prefixes', () => { + expect(stripCountryPrefix('EXYU| News')).toBe('News'); + expect(stripCountryPrefix('MULTI| Movies')).toBe('Movies'); + }); + + it('never treats numeric fragments as tags', () => { + expect(stripCountryPrefix('1917 - Documentary')).toBe( + '1917 - Documentary' + ); + }); + + it('keeps bare 4-5 char words before a spaced dash (real titles)', () => { + expect(stripCountryPrefix('DUNE - Part Two')).toBe( + 'DUNE - Part Two' + ); + expect(stripCountryPrefix('ALIEN - Covenant')).toBe( + 'ALIEN - Covenant' + ); + }); + it('only strips the first tag segment', () => { expect(stripCountryPrefix('ES - A3 - Sports')).toBe('A3 - Sports'); }); diff --git a/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.ts b/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.ts index f1213996f..d0b8b74dc 100644 --- a/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.ts +++ b/libs/shared/m3u-utils/src/lib/strip-country-prefix.util.ts @@ -3,9 +3,19 @@ * country/group tag — they routinely appear inside real channel names * ("Sky - Sports F1", "Discovery - Science"). Pipes are conventionally * used only as tag separators, so they always count. + * + * Before a dash a tag is either a compound ("4K-DE", "AR-SUBS", "4K-OSN+" + * — the inner hyphen is the tag signal) or a plain 2–3 char code ("US", + * "4K"). A bare 4–5 char word before a spaced dash is a real title + * ("DUNE - Part Two", "ALIEN - Covenant"), so it is not a tag. Colon tags + * stay 2–3 chars — longer acronyms are franchise titles ("NCIS: LA"). + * Every segment must contain a letter so numbers ("1917 - ...") are safe. */ -const DASH_OR_COLON_SEPARATORS = [' - ', '- ', ' -', ': ']; -const TAG_PREFIX_PATTERN = /^[A-Z0-9]{2,3}$/; +const DASH_SEPARATORS = [' - ', '- ', ' -']; +const COLON_SEPARATOR = ': '; +const TAG_PREFIX_PATTERN = + /^(?:(?=[0-9+]*[A-Z])[A-Z0-9+]{2,5}(?:-(?=[0-9+]*[A-Z])[A-Z0-9+]{2,6}){1,2}|(?=[0-9+]*[A-Z])[A-Z0-9+]{2,3})$/; +const COLON_TAG_PATTERN = /^(?=[0-9+]*[A-Z])[A-Z0-9+]{2,3}$/; interface SeparatorMatch { index: number; @@ -68,10 +78,14 @@ function findTagSeparator(name: string): SeparatorMatch | null { best = { index: pipeIndex, length: 1 }; } - for (const separator of DASH_OR_COLON_SEPARATORS) { + for (const separator of [...DASH_SEPARATORS, COLON_SEPARATOR]) { const index = name.indexOf(separator); if (index === -1 || (best && best.index <= index)) continue; - if (!TAG_PREFIX_PATTERN.test(name.slice(0, index).trim())) continue; + const pattern = + separator === COLON_SEPARATOR + ? COLON_TAG_PATTERN + : TAG_PREFIX_PATTERN; + if (!pattern.test(name.slice(0, index).trim())) continue; best = { index, length: separator.length }; } From bc6e7018e0d9e9662bd1080d70f3d9d11dc118ed Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:21:40 +0200 Subject: [PATCH 15/26] fix(epg): harden three latent edges from the #1165 manual-mapping review (#1214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(epg): harden three latent edges from the #1165 manual-mapping review Follow-up to #1165 (manual EPG-to-channel mapping). Three minor but real issues flagged by the bot reviews on #1173, all in already-merged #1165 code rather than the Stalker delta: 1. EPG program dedup ignored source_url. The unique index and upsert key (channel_id, start, title) collapsed programmes imported from different XMLTV sources that shared those columns, and the upsert reassigned source_url to the last importer — so source-scoped queries could miss a programme and source-scoped deletes could drop another source's row. The key and index now include source_url (migrated via a _v2 index that drops the old source-blind one); the upsert no longer overwrites source_url. 2. Xtream getMapping fallback capped candidate streams at an unordered first five, so a mapping saved under a later stream sharing the provider epg_channel_id was silently ignored. Replaced the two-step fetch-then-lookup with a single content⋈categories⋈mappings join that finds a mapping under any matching stream, with no arbitrary cap. 3. The Xtream mapping dialog did not refresh after closing, unlike the Stalker path, so a remapped visible/selected channel kept its stale preview until the 5-minute TTL or a rescroll. Added EpgQueueService.invalidate(streamId) and a before/after mapping compare in the channel-list dialog flow that invalidates the cache and refetches the current viewport when the mapping actually changed. Tests: source-aware dedup index/upsert assertions; join-based getMapping resolution regardless of stream position; EpgQueueService.invalidate. Co-Authored-By: Claude Fable 5 * fix(epg): address review feedback on the #1165 follow-up - portal-channels-list: forward the already-validated playlistId into the mapping dialog instead of re-reading currentPlaylist() after the async getEpgMapping roundtrip (which could return undefined on navigation) - EpgQueueService.invalidate(): bump a per-stream invalidation epoch and clear inFlight so a request already running when the mapping changes has its (pre-change) result discarded via an epoch check in fetchEpg, and the immediate re-enqueue can schedule a fresh mapping-aware fetch - getMapping Xtream fallback: order the join deterministically before limit(1) so the resolved mapping is stable; documented that this backend layer has no caller playlist context and is a best-effort net behind the renderer's playlist-scoped resolution Tests: in-flight staleness discard for invalidate(). Co-Authored-By: Claude Fable 5 * test(epg): split EpgQueueService invalidation specs under the max-lines limit The added invalidate() tests pushed epg-queue.service.spec.ts to 415 lines, over the 400-line ESLint cap (and the baseline must not grow). Moved them to a focused epg-queue-invalidation.spec.ts; both files are now under the limit. Co-Authored-By: Claude Fable 5 * fix(epg): resolve second-order review findings on the mapping follow-up Two P2 issues Codex raised on the previous fixes: - Unscoped program lookups could return the same programme twice now that the dedup index preserves per-source rows: the M3U timeline calls getChannelPrograms without sourceUrls, so two sources sharing channel/start/title both surfaced. Added toEpgProgams(), which collapses duplicate channel|start|title slots after mapping/validation, applied at every getChannelPrograms return. - EpgQueueService.fetchEpg unconditionally cleared the in-flight marker in its finally, which could drop a marker a re-enqueued request took over after invalidate(). It now only releases the marker when the completing request still owns it (epoch unchanged), preserving per-stream dedup and concurrency accounting. Tests: unscoped duplicate-slot collapse; stale request preserving a fresh in-flight marker. Co-Authored-By: Claude Fable 5 * fix(epg): deduplicate program rows in SQL before applying row limits Codex follow-up: the JS-level slot dedup ran after the SQL LIMIT, so duplicate cross-source rows consumed the cap and truncated real data. Moved the dedup into SQL with GROUP BY, applied before the limits: - selectChannelPrograms / selectLegacyChannelPrograms: GROUP BY (channel_id, start, title) before ORDER BY start LIMIT 500, so the timeline cap counts distinct programmes rather than duplicate rows - selectCurrentProgramsForChannelIds: GROUP BY channel_id before LIMIT channelIds.length, so duplicate cross-source current slots can't starve other channels of their current-programme preview The JS toEpgPrograms() dedup stays as a safety net (e.g. legacy NULL-source rows the unique index treats as distinct). Test query-chain mocks updated for the new groupBy link. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../src/app/events/epg-query.service.spec.ts | 122 ++++++++++++++- .../src/app/events/epg-query.service.ts | 109 +++++++++----- .../src/app/events/epg.events.spec.ts | 5 +- .../src/app/workers/epg-database.spec.ts | 23 ++- .../src/app/workers/epg-database.ts | 44 ++++-- .../services/epg-queue-invalidation.spec.ts | 141 ++++++++++++++++++ .../src/lib/services/epg-queue.service.ts | 45 +++++- .../portal-channels-list.component.ts | 66 +++++++- 8 files changed, 495 insertions(+), 60 deletions(-) create mode 100644 libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts diff --git a/apps/electron-backend/src/app/events/epg-query.service.spec.ts b/apps/electron-backend/src/app/events/epg-query.service.spec.ts index 33350e748..577096476 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.spec.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.spec.ts @@ -66,16 +66,35 @@ function createLimitedSelectChain( limitResult: unknown, whereCalls: unknown[] ): { from: jest.Mock } { - const limit = jest.fn().mockResolvedValue(limitResult); + const chain: Record = { + limit: jest.fn().mockResolvedValue(limitResult), + }; + // groupBy/orderBy are optional links in the various query shapes; each + // returns the chain so where().groupBy().orderBy().limit() (and any + // subset) resolves to the same data. + chain.groupBy = jest.fn(() => chain); + chain.orderBy = jest.fn(() => chain); const where = jest.fn((condition: unknown) => { whereCalls.push(condition); - return { limit }; + return chain; }); return { from: jest.fn(() => ({ where })), }; } +/** Program-query chain: from().where().groupBy().orderBy().limit(). */ +function createProgramChain(rows: unknown): { from: jest.Mock } { + const chain: Record = { + limit: jest.fn().mockResolvedValue(rows), + }; + chain.groupBy = jest.fn(() => chain); + chain.orderBy = jest.fn(() => chain); + return { + from: jest.fn(() => ({ where: jest.fn(() => chain) })), + }; +} + describe('EpgQueryService', () => { let service: EpgQueryService; @@ -403,4 +422,103 @@ describe('EpgQueryService', () => { ) ).toBe(true); }); + + it('resolves a manual mapping saved under any stream sharing the epg_channel_id via a single join', async () => { + const whereCalls: unknown[] = []; + const joinLimit = jest + .fn() + .mockResolvedValue([{ epgChannelId: 'BBC.MAPPED' }]); + let innerJoinCount = 0; + const joinChain: Record = {}; + joinChain.innerJoin = jest.fn(() => { + innerJoinCount += 1; + return joinChain; + }); + joinChain.where = jest.fn((condition: unknown) => { + whereCalls.push(condition); + return { orderBy: jest.fn(() => ({ limit: joinLimit })) }; + }); + + // Program lookup for the resolved (mapped) channel id. + const programRow = { + id: 1, + channelId: 'BBC.MAPPED', + start: '2026-06-21T17:00:00.000Z', + stop: '2026-06-21T18:00:00.000Z', + title: 'Mapped Programme', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + sourceUrl: null, + }; + + const select = jest + .fn() + // getMapping direct lookup — miss. + .mockReturnValueOnce(createLimitedSelectChain([], whereCalls)) + // getMapping Xtream fallback — single join across content / + // categories / mappings, no arbitrary candidate cap. + .mockReturnValueOnce({ from: jest.fn(() => joinChain) }) + // selectChannelPrograms for the mapped id. + .mockReturnValueOnce(createProgramChain([programRow])); + + getDatabase.mockResolvedValue({ select }); + + const result = await service.getChannelPrograms('provider.epg.id'); + + expect(innerJoinCount).toBe(2); + expect(joinLimit).toHaveBeenCalledWith(1); + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Mapped Programme'); + }); + + it('collapses duplicate programme slots from multiple sources in an unscoped lookup', async () => { + const whereCalls: unknown[] = []; + const dupRow = { + id: 1, + channelId: 'bbc.one.uk', + start: '2026-06-21T20:00:00.000Z', + stop: '2026-06-21T21:00:00.000Z', + title: 'News', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + }; + const select = jest + .fn() + // getMapping direct + Xtream fallback — both miss. + .mockReturnValueOnce(createLimitedSelectChain([], whereCalls)) + .mockReturnValueOnce({ + from: jest.fn(() => ({ + innerJoin: jest.fn(function inner() { + return this; + }), + where: jest.fn(() => ({ + orderBy: jest.fn(() => ({ + limit: jest.fn().mockResolvedValue([]), + })), + })), + })), + }) + // selectChannelPrograms — two sources, same channel/start/title. + // The SQL GROUP BY would collapse these; the mock returns both to + // prove the JS safety-net dedup also holds. + .mockReturnValueOnce( + createProgramChain([ + { ...dupRow, sourceUrl: 'https://a.example/g.xml' }, + { ...dupRow, id: 2, sourceUrl: 'https://b.example/g.xml' }, + ]) + ); + + getDatabase.mockResolvedValue({ select }); + + const result = await service.getChannelPrograms('bbc.one.uk'); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe('News'); + }); }); diff --git a/apps/electron-backend/src/app/events/epg-query.service.ts b/apps/electron-backend/src/app/events/epg-query.service.ts index 75c8a23c4..8ed16023e 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -1,6 +1,5 @@ import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm'; import { - buildXtreamEpgMappingKey, EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; @@ -69,9 +68,7 @@ export class EpgQueryService { } if (results.length > 0) { - return results - .map(this.transformDbRowToEpgProgram) - .filter(this.isValidEpgProgram); + return this.toEpgPrograms(results); } let channel = await this.selectChannelById( @@ -102,9 +99,7 @@ export class EpgQueryService { } if (results.length > 0) { - return results - .map(this.transformDbRowToEpgProgram) - .filter(this.isValidEpgProgram); + return this.toEpgPrograms(results); } } @@ -154,9 +149,7 @@ export class EpgQueryService { ); } - return results - .map(this.transformDbRowToEpgProgram) - .filter(this.isValidEpgProgram); + return this.toEpgPrograms(results); } return []; @@ -553,6 +546,13 @@ export class EpgQueryService { sourceUrls ) ) + // Collapse identical slots from multiple sources in SQL so the + // 500-row cap counts distinct programmes, not duplicate rows. + .groupBy( + schema.epgPrograms.channelId, + schema.epgPrograms.start, + schema.epgPrograms.title + ) .orderBy(schema.epgPrograms.start) .limit(500); } @@ -576,6 +576,11 @@ export class EpgQueryService { { legacyOnly: true } ) ) + .groupBy( + schema.epgPrograms.channelId, + schema.epgPrograms.start, + schema.epgPrograms.title + ) .orderBy(schema.epgPrograms.start) .limit(500); } @@ -607,6 +612,9 @@ export class EpgQueryService { options ) ) + // One current row per channel so duplicate cross-source slots + // don't consume the per-channel cap and starve other channels. + .groupBy(schema.epgPrograms.channelId) .limit(channelIds.length); } @@ -895,6 +903,31 @@ export class EpgQueryService { ); } + /** + * Map program rows to EpgProgram and drop invalid ones, then collapse + * duplicate programme slots. The dedup index is source-aware, so an + * unscoped lookup (e.g. the M3U timeline, which queries without + * `sourceUrls`) can return the same channel/start/title from two EPG + * sources; keep the first so a programme is never shown twice. + */ + private toEpgPrograms(rows: EpgProgramRow[]): EpgProgram[] { + const seen = new Set(); + const programs: EpgProgram[] = []; + for (const row of rows) { + const program = this.transformDbRowToEpgProgram(row); + if (!this.isValidEpgProgram(program)) { + continue; + } + const key = `${program.channel}|${program.start}|${program.title}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + programs.push(program); + } + return programs; + } + private transformDbRowToEpgProgram(row: EpgProgramRow): EpgProgram { return { start: row.start, @@ -940,39 +973,45 @@ export class EpgQueryService { // Fallback: the key may be an Xtream provider epg_channel_id // while the mapping was saved under the playlist-scoped Xtream // key. Resolve the owning streams (joined through categories for - // the playlist ID) and check their mapping keys. The same - // epg_channel_id can appear in several playlists, so check a few. - const contentRows = await db + // the playlist ID) and look up their mapping keys in a single + // join, so a mapping saved under any matching stream is found — + // an earlier "check the first few" cap could silently miss a + // mapping saved under a later stream sharing this epg_channel_id. + // + // The join key is built in SQL to mirror + // buildXtreamEpgMappingKey(playlistId, xtreamId) → + // `xtream:{playlistId}:{xtreamId}`; keep the two in sync. + // + // This layer only receives the provider epg_channel_id, not the + // caller's playlist, so when the same id exists in several + // playlists it cannot scope to the caller's own mapping — the + // renderer resolves the playlist-scoped key directly and this is + // a best-effort fallback. Order deterministically so the chosen + // mapping is at least stable rather than storage-order dependent. + const mapped = await db .select({ - xtreamId: schema.content.xtreamId, - playlistId: schema.categories.playlistId, + epgChannelId: schema.epgChannelMappings.epgChannelId, }) .from(schema.content) .innerJoin( schema.categories, eq(schema.content.categoryId, schema.categories.id) ) - .where(eq(schema.content.epgChannelId, channelKey)) - .limit(5); - if (contentRows.length > 0) { - const candidateKeys = contentRows.map((row) => - buildXtreamEpgMappingKey(row.playlistId, row.xtreamId) - ); - const mapped = await db - .select({ - epgChannelId: schema.epgChannelMappings.epgChannelId, - }) - .from(schema.epgChannelMappings) - .where( - inArray( - schema.epgChannelMappings.channelKey, - candidateKeys - ) + .innerJoin( + schema.epgChannelMappings, + eq( + schema.epgChannelMappings.channelKey, + sql`'xtream:' || ${schema.categories.playlistId} || ':' || ${schema.content.xtreamId}` ) - .limit(1); - if (mapped.length > 0) { - return mapped[0].epgChannelId; - } + ) + .where(eq(schema.content.epgChannelId, channelKey)) + .orderBy( + schema.categories.playlistId, + schema.content.xtreamId + ) + .limit(1); + if (mapped.length > 0) { + return mapped[0].epgChannelId; } return null; diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index 6f6799a9a..1f2977a66 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -384,6 +384,7 @@ describe('EpgEvents', () => { const chain: Record = {} as Record; chain.where = jest.fn().mockReturnValue(chain); chain.innerJoin = jest.fn().mockReturnValue(chain); + chain.groupBy = jest.fn().mockReturnValue(chain); chain.orderBy = jest.fn().mockReturnValue(chain); chain.limit = jest.fn().mockResolvedValue(data); return chain; @@ -470,12 +471,14 @@ describe('EpgEvents', () => { const select = jest.fn(); const from = jest.fn(); const where = jest.fn(); + const groupBy = jest.fn(); const orderBy = jest.fn(); const limit = jest.fn(); select.mockImplementation(() => ({ from })); from.mockReturnValue({ where }); - where.mockReturnValue({ orderBy }); + where.mockReturnValue({ groupBy }); + groupBy.mockReturnValue({ orderBy }); orderBy.mockReturnValue({ limit }); limit.mockResolvedValue([ { diff --git a/apps/electron-backend/src/app/workers/epg-database.spec.ts b/apps/electron-backend/src/app/workers/epg-database.spec.ts index ed7c5e1a2..d8c5519c2 100644 --- a/apps/electron-backend/src/app/workers/epg-database.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-database.spec.ts @@ -141,19 +141,36 @@ describe('EpgDatabase', () => { sql.startsWith('DELETE FROM epg_programs WHERE id NOT IN') ); const createIndexSql = preparedSql.find((sql) => - sql.startsWith('CREATE UNIQUE INDEX idx_epg_programs_dedup') + sql.startsWith('CREATE UNIQUE INDEX idx_epg_programs_dedup_v2') + ); + const dropOldIndexSql = preparedSql.find((sql) => + sql.startsWith('DROP INDEX IF EXISTS idx_epg_programs_dedup') ); expect(dedupDeleteSql).toBeDefined(); expect(createIndexSql).toBeDefined(); + expect(dropOldIndexSql).toBeDefined(); expect(statements.get(dedupDeleteSql!)).toHaveBeenCalled(); expect(statements.get(createIndexSql!)).toHaveBeenCalled(); + expect(statements.get(dropOldIndexSql!)).toHaveBeenCalled(); + + // The dedup key and index are source-aware so programmes imported + // from different EPG sources are not collapsed. + expect(dedupDeleteSql).toContain( + 'GROUP BY channel_id, start, title, source_url' + ); + expect(createIndexSql).toContain( + 'epg_programs(channel_id, start, title, source_url)' + ); const insertProgramSql = preparedSql.find((sql) => sql.startsWith('INSERT INTO epg_programs') ); expect(insertProgramSql).toContain( - 'ON CONFLICT(channel_id, start, title) DO UPDATE SET' + 'ON CONFLICT(channel_id, start, title, source_url) DO UPDATE SET' + ); + expect(insertProgramSql).not.toContain( + 'source_url = excluded.source_url' ); }); @@ -180,7 +197,7 @@ describe('EpgDatabase', () => { sql.startsWith('INSERT INTO epg_programs') ); expect(insertProgramSql).toContain( - 'ON CONFLICT(channel_id, start, title) DO UPDATE SET' + 'ON CONFLICT(channel_id, start, title, source_url) DO UPDATE SET' ); }); diff --git a/apps/electron-backend/src/app/workers/epg-database.ts b/apps/electron-backend/src/app/workers/epg-database.ts index 669faaddb..3865b4bad 100644 --- a/apps/electron-backend/src/app/workers/epg-database.ts +++ b/apps/electron-backend/src/app/workers/epg-database.ts @@ -31,9 +31,13 @@ export class EpgDatabase { `); // Guard against duplicate entries when the clearFirst logic misses old - // rows (e.g. because the source URL changed between imports). The same - // channel + start + title is treated as the same programme — a later - // import with a corrected stop time simply updates the earlier row. + // rows. The same channel + start + title + source is treated as the + // same programme — a later import with a corrected stop time simply + // updates the earlier row. `source_url` is part of the key so that + // programmes imported from different EPG sources that happen to share + // channel_id/start/title stay isolated: the query layer scopes by + // source_url, and source-scoped deletes must not clobber another + // source's rows. // The upsert (instead of INSERT OR REPLACE) keeps the epg_programs_fts // triggers consistent: REPLACE deletes rows without firing the delete // trigger unless recursive_triggers is enabled, leaving ghost FTS rows. @@ -43,14 +47,13 @@ export class EpgDatabase { dedupIndexReady ? `INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(channel_id, start, title) DO UPDATE SET + ON CONFLICT(channel_id, start, title, source_url) DO UPDATE SET stop = excluded.stop, description = excluded.description, category = excluded.category, icon_url = excluded.icon_url, rating = excluded.rating, - episode_num = excluded.episode_num, - source_url = excluded.source_url` + episode_num = excluded.episode_num` : `INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ); @@ -156,11 +159,19 @@ export class EpgDatabase { } /** - * Create the unique (channel_id, start, title) dedup index. Databases - * that predate the index may already contain duplicate rows — exactly - * the situation the index is meant to prevent — so those are removed - * first; a plain `CREATE UNIQUE INDEX` would otherwise throw in this - * constructor and permanently break EPG imports for upgrading users. + * Create the unique (channel_id, start, title, source_url) dedup index. + * + * Databases that predate the index may already contain duplicate rows — + * exactly the situation the index is meant to prevent — so those are + * removed first; a plain `CREATE UNIQUE INDEX` would otherwise throw in + * this constructor and permanently break EPG imports for upgrading users. + * + * The `_v2` name migrates users off the earlier source-blind index + * (`idx_epg_programs_dedup` on `channel_id, start, title`), which + * collapsed programmes imported from different EPG sources that shared + * those three columns. The old index is dropped so it can no longer + * enforce the source-blind uniqueness. + * * Returns false when the index could not be created, in which case the * insert statement falls back to the previous plain-INSERT behaviour. */ @@ -169,23 +180,26 @@ export class EpgDatabase { const exists = this.db .prepare( `SELECT 1 FROM sqlite_master - WHERE type = 'index' AND name = 'idx_epg_programs_dedup'` + WHERE type = 'index' AND name = 'idx_epg_programs_dedup_v2'` ) .get(); if (!exists) { + this.db + .prepare(`DROP INDEX IF EXISTS idx_epg_programs_dedup`) + .run(); this.db .prepare( `DELETE FROM epg_programs WHERE id NOT IN ( SELECT MIN(id) FROM epg_programs - GROUP BY channel_id, start, title + GROUP BY channel_id, start, title, source_url )` ) .run(); this.db .prepare( - `CREATE UNIQUE INDEX idx_epg_programs_dedup - ON epg_programs(channel_id, start, title)` + `CREATE UNIQUE INDEX idx_epg_programs_dedup_v2 + ON epg_programs(channel_id, start, title, source_url)` ) .run(); } diff --git a/libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts b/libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts new file mode 100644 index 000000000..1318b7729 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts @@ -0,0 +1,141 @@ +import { TestBed } from '@angular/core/testing'; +import { SettingsStore } from '@iptvnator/services'; +import { EpgQueueService } from './epg-queue.service'; +import { XtreamApiService } from './xtream-api.service'; +import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service'; +import type { EpgItem } from '@iptvnator/shared/interfaces'; + +/** + * Covers EpgQueueService.invalidate(), used when a manual EPG mapping for a + * stream changes. Split from epg-queue.service.spec.ts to keep both files + * under the max-lines limit. + */ +describe('EpgQueueService invalidation', () => { + let service: EpgQueueService; + let xtreamApi: { getShortEpg: jest.Mock }; + + const credentials = { + serverUrl: 'https://xtream.example.com', + username: 'user', + password: 'pass', + }; + + type ServicePrivates = { + fetchEpg: ( + credentials: typeof credentials, + streamId: number + ) => Promise; + shouldFetch: (streamId: number) => boolean; + xmltvPreviewByStreamId: Map; + epgChannelByStreamId: Map; + inFlight: Set; + }; + + beforeEach(() => { + xtreamApi = { getShortEpg: jest.fn().mockResolvedValue([]) }; + + TestBed.configureTestingModule({ + providers: [ + EpgQueueService, + { provide: XtreamApiService, useValue: xtreamApi }, + { + provide: XtreamXmltvFallbackService, + useValue: { + getProgramsForChannel: jest.fn().mockResolvedValue([]), + getCurrentProgramsBatch: jest.fn().mockResolvedValue({}), + }, + }, + { + provide: SettingsStore, + useValue: { + preferUploadedEpgOverXtream: jest.fn(() => false), + }, + }, + ], + }); + + service = TestBed.inject(EpgQueueService); + }); + + function priv(): ServicePrivates { + return service as unknown as ServicePrivates; + } + + function makeItem(channelId: string, title: string): EpgItem { + return { + id: `${channelId}|x`, + epg_id: '', + title, + lang: '', + start: '2026-05-07T08:00:00Z', + end: '2026-05-07T09:00:00Z', + stop: '2026-05-07T09:00:00Z', + description: '', + channel_id: channelId, + start_timestamp: '0', + stop_timestamp: '0', + }; + } + + it('drops every cached artifact so the stream refetches', async () => { + xtreamApi.getShortEpg.mockResolvedValue([makeItem('rtl.de', 'Now')]); + await priv().fetchEpg(credentials, 555); + priv().epgChannelByStreamId.set(555, 'rtl.de'); + priv().xmltvPreviewByStreamId.set(555, makeItem('rtl.de', 'now')); + + expect(service.getCached(555)).not.toBeNull(); + expect(priv().shouldFetch(555)).toBe(false); + + service.invalidate(555); + + expect(service.getCached(555)).toBeNull(); + expect(priv().shouldFetch(555)).toBe(true); + expect(priv().epgChannelByStreamId.has(555)).toBe(false); + expect(priv().xmltvPreviewByStreamId.has(555)).toBe(false); + }); + + it('discards an in-flight result when invalidated mid-request', async () => { + let resolveEpg!: (items: EpgItem[]) => void; + xtreamApi.getShortEpg.mockReturnValue( + new Promise((resolve) => { + resolveEpg = resolve; + }) + ); + const emitted: number[] = []; + const sub = service.epgResult$.subscribe(({ streamId }) => + emitted.push(streamId) + ); + + const fetchPromise = priv().fetchEpg(credentials, 707); + // The user changes the mapping while the request is still running. + service.invalidate(707); + // The provider now returns the pre-change (stale) result. + resolveEpg([makeItem('rtl.de', 'Stale')]); + await fetchPromise; + + expect(service.getCached(707)).toBeNull(); + expect(emitted).not.toContain(707); + sub.unsubscribe(); + }); + + it('leaves a fresh in-flight marker intact when a stale request completes', async () => { + let resolveA!: (items: EpgItem[]) => void; + xtreamApi.getShortEpg.mockReturnValue( + new Promise((resolve) => { + resolveA = resolve; + }) + ); + + const aPromise = priv().fetchEpg(credentials, 909); + // Mapping changes mid-flight; a re-enqueue (request B) then starts and + // takes ownership of the in-flight marker. + service.invalidate(909); + priv().inFlight.add(909); + + resolveA([makeItem('rtl.de', 'Stale')]); + await aPromise; + + // Request A was stale, so its finally must not clear B's marker. + expect(priv().inFlight.has(909)).toBe(true); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts index b1f173749..3e3b71e92 100644 --- a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts @@ -55,6 +55,8 @@ export class EpgQueueService implements OnDestroy { private readonly cache = new Map(); private queue: number[] = []; private readonly inFlight = new Set(); + /** Bumped by invalidate() so a stale in-flight result is discarded. */ + private readonly invalidationEpoch = new Map(); private readonly epgChannelByStreamId = new Map(); private readonly xmltvPreviewByStreamId = new Map(); private visibleSet = new Set(); @@ -80,6 +82,29 @@ export class EpgQueueService implements OnDestroy { return entry.data; } + /** + * Drop every cached artifact for a stream so the next enqueue refetches + * it. Used when a manual EPG mapping for the stream changes, since the + * cached preview/resolution was computed for the previous mapping. + * + * Also bumps an invalidation epoch and clears `inFlight`: a request that + * was already running when the mapping changed carries the pre-change + * resolution, so its result is discarded (epoch mismatch in `fetchEpg`) + * and clearing `inFlight` lets the immediate re-enqueue schedule a fresh + * fetch through the mapping-aware `enqueue()` path. + */ + invalidate(streamId: number): void { + this.cache.delete(streamId); + this.failureTimestamps.delete(streamId); + this.epgChannelByStreamId.delete(streamId); + this.xmltvPreviewByStreamId.delete(streamId); + this.inFlight.delete(streamId); + this.invalidationEpoch.set( + streamId, + (this.invalidationEpoch.get(streamId) ?? 0) + 1 + ); + } + private isFailureCoolingDown(streamId: number): boolean { const timestamp = this.failureTimestamps.get(streamId); if (timestamp == null) { @@ -305,6 +330,9 @@ export class EpgQueueService implements OnDestroy { credentials: XtreamCredentials, streamId: number ): Promise { + const startEpoch = this.invalidationEpoch.get(streamId) ?? 0; + const isStale = (): boolean => + (this.invalidationEpoch.get(streamId) ?? 0) !== startEpoch; try { const apiItems = await this.apiService.getShortEpg( credentials, @@ -313,6 +341,11 @@ export class EpgQueueService implements OnDestroy { { suppressErrorLog: true } ); + // A mapping change during the request invalidated this result. + if (isStale()) { + return; + } + if (apiItems.length > 0) { this.recordSuccess(streamId, apiItems); return; @@ -321,13 +354,23 @@ export class EpgQueueService implements OnDestroy { const xmltv = this.xmltvPreviewByStreamId.get(streamId); this.recordSuccess(streamId, xmltv ? [xmltv] : []); } catch (error) { + if (isStale()) { + return; + } this.failureTimestamps.set(streamId, Date.now()); this.logger.error( `Failed to load EPG for stream ${streamId}`, error ); } finally { - this.inFlight.delete(streamId); + // Only release the in-flight marker if this request still owns it. + // When invalidate() cleared it mid-flight, a later re-enqueue may + // already have started a new request for the same stream; an + // unconditional delete here would drop that request's marker and + // let a third concurrent fetch start. + if (!isStale()) { + this.inFlight.delete(streamId); + } } } diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 56c71104b..8a2076a32 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -137,6 +137,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { epgPrograms = new Map(); currentProgramsProgress = new Map(); + /** Last viewport slice, reused to refresh previews after a mapping change. */ + private lastVisibleChannels: XtreamChannelListItem[] = []; + readonly viewport = viewChild(CdkVirtualScrollViewport); private subscriptions = new Subscription(); @@ -237,6 +240,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { range.start, range.end ); + this.lastVisibleChannels = visibleChannels; this.loadEpgForVisibleChannels(visibleChannels); }) ); @@ -509,10 +513,66 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { return; } + const channelKey = buildXtreamEpgMappingKey(playlistId, xtreamId); + void this.openEpgMappingDialog( + channelKey, + channel, + xtreamId, + playlistId + ); + } + + private async openEpgMappingDialog( + channelKey: string, + channel: XtreamChannelListItem, + streamId: number, + playlistId: string + ): Promise { + const mappingBefore = await this.readEpgMapping(channelKey); + EpgMappingDialogComponent.open(this.dialog, { - channelKey: buildXtreamEpgMappingKey(playlistId, xtreamId), - channelName: channel.title ?? channel.name ?? String(xtreamId), + channelKey, + channelName: channel.title ?? channel.name ?? String(streamId), playlistId, - }); + }) + .afterClosed() + .subscribe(async () => { + const mappingAfter = await this.readEpgMapping(channelKey); + if (mappingAfter === mappingBefore) { + return; + } + // The mapping changed (saved or removed) — drop the cached + // preview/resolution and refetch so the row updates now + // instead of after the 5-minute TTL or the next scroll. + this.epgQueueService.invalidate(streamId); + this.epgPrograms.delete(streamId); + this.currentProgramsProgress.delete(streamId); + const visible = this.lastVisibleChannels.length + ? this.lastVisibleChannels + : this.filteredChannels().slice(0, 50); + this.loadEpgForVisibleChannels(visible); + }); + } + + /** Read the current mapped EPG channel id, or null (PWA / no mapping). */ + private async readEpgMapping(channelKey: string): Promise { + if (!this.supportsEpgMapping) { + return null; + } + const bridge = ( + window as unknown as { + electron?: { + getEpgMapping?: ( + key: string + ) => Promise<{ epgChannelId?: string } | null>; + }; + } + ).electron; + try { + const mapping = await bridge?.getEpgMapping?.(channelKey); + return mapping?.epgChannelId?.trim() || null; + } catch { + return null; + } } } From 48ff4b9cc56c144f8339a4c98354cba07ad73701 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:52:00 +0200 Subject: [PATCH 16/26] fix(portal): remove redundant catalog type badges (#1217) Remove redundant LIVE, VOD, and SERIES badges from homogeneous Xtream and Stalker catalog grids while preserving mixed-content badges and type-driven grid behavior. --- ...20-hide-homogeneous-catalog-type-badges.md | 243 ++++++++++++++++++ ...-homogeneous-catalog-type-badges-design.md | 53 ++++ .../grid-list/grid-list.component.scss | 29 --- .../grid-list/grid-list.component.spec.ts | 19 +- .../grid-list/grid-list.component.ts | 10 - 5 files changed, 311 insertions(+), 43 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-20-hide-homogeneous-catalog-type-badges.md create mode 100644 docs/superpowers/specs/2026-07-20-hide-homogeneous-catalog-type-badges-design.md diff --git a/docs/superpowers/plans/2026-07-20-hide-homogeneous-catalog-type-badges.md b/docs/superpowers/plans/2026-07-20-hide-homogeneous-catalog-type-badges.md new file mode 100644 index 000000000..19a13d667 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-hide-homogeneous-catalog-type-badges.md @@ -0,0 +1,243 @@ +# Hide Homogeneous Catalog Type Badges Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove redundant `LIVE`, `VOD`, and `SERIES` badges from homogeneous Stalker and Xtream catalog grids while preserving type badges in mixed-content cards. + +**Architecture:** Remove the type-badge presentation from the shared `GridListComponent`, whose current consumers are homogeneous catalog grids. Keep its `type` input because artwork placeholders and live-title normalization still depend on it; do not modify `ContentCardComponent`, which owns badges for mixed-content surfaces. + +**Tech Stack:** Angular standalone components, Angular signal inputs, SCSS, Jest through Nx, Playwright E2E through Nx. + +--- + +### Task 0: Bootstrap the Nx workspace + +**Files:** +- Verify only: `package.json` +- Verify only: `pnpm-lock.yaml` + +- [ ] **Step 1: Install the locked workspace dependencies** + +Run: + +```bash +pnpm install --frozen-lockfile +``` + +Expected: exit 0 without changing `pnpm-lock.yaml`. + +- [ ] **Step 2: Verify Nx project discovery** + +Run: + +```bash +pnpm nx show projects +``` + +Expected: exit 0 and output containing `portal-shared-ui`, +`portal-catalog-feature`, and `web-e2e`. + +### Task 1: Remove the redundant grid-list badge + +**Files:** +- Modify: `libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts` +- Modify: `libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts` +- Modify: `libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss` + +- [ ] **Step 1: Write the failing regression test** + +In `grid-list.component.spec.ts`, change the existing live-logo test so it only +asserts logo-card rendering, then add this parameterized test inside +`describe('GridListComponent', ...)`: + +```typescript +it.each(['live', 'vod', 'series'] as const)( + 'does not render a redundant %s type badge in homogeneous grids', + (type) => { + fixture.componentRef.setInput('items', [ + { + name: 'Catalog item', + stream_icon: 'catalog-item.png', + }, + ]); + fixture.componentRef.setInput('type', type); + + fixture.detectChanges(); + + expect( + fixture.debugElement.query(By.css('.type-badge')) + ).toBeNull(); + } +); +``` + +The updated live-logo test must retain these assertions: + +```typescript +expect(card.nativeElement.classList).toContain('grid-card--logo'); +expect(image.nativeElement.getAttribute('src')).toBe('channel-logo.png'); +``` + +It must no longer query or assert `.type-badge`. + +- [ ] **Step 2: Run the focused test to verify RED** + +Run: + +```bash +pnpm nx test portal-shared-ui --testPathPattern=grid-list.component.spec.ts --runInBand +``` + +Expected: FAIL for `live`, `vod`, and `series` because +`GridListComponent` still renders `.type-badge`. + +- [ ] **Step 3: Remove the badge markup** + +In the inline template in `grid-list.component.ts`, remove only this block: + +```html +@if (type()) { +
+ {{ type() }} +
+} +``` + +Keep the `type` input and every remaining use of `type()` unchanged. + +- [ ] **Step 4: Remove the now-unused grid badge styles** + +In `grid-list.component.scss`, delete the entire `.type-badge` rule, including +its `.live`, `.movie`, and `.series` variants: + +```scss +.type-badge { + position: absolute; + top: 6px; + left: 6px; + z-index: 1; + padding: 3px 6px; + border-radius: 5px; + font-size: 0.58rem; + font-weight: 700; + line-height: 1; + text-transform: uppercase; + letter-spacing: 0.04em; + background: rgba(0, 0, 0, 0.85); + color: #fff; + + &.live { + color: #ef5350; + } + + &.movie { + color: #42a5f5; + } + + &.series { + color: #66bb6a; + } +} +``` + +Do not change the separate content-card badge mixin or +`content-card.component.*`. + +- [ ] **Step 5: Run the focused test to verify GREEN** + +Run: + +```bash +pnpm nx test portal-shared-ui --testPathPattern=grid-list.component.spec.ts --runInBand +``` + +Expected: PASS, including the three type-badge regression cases and the +existing placeholder/title behavior. + +- [ ] **Step 6: Run affected unit and lint targets** + +Run: + +```bash +pnpm nx test portal-shared-ui +pnpm nx test portal-catalog-feature +pnpm nx lint portal-shared-ui +``` + +Expected: all commands exit 0 with no failed tests or lint errors. + +- [ ] **Step 7: Verify mixed-content badge ownership is untouched** + +Run: + +```bash +git diff -- libs/portal/shared/ui/src/lib/components/content-card libs/portal/xtream/feature/src/lib/search-results libs/portal/stalker/feature/src/lib/stalker-search +``` + +Expected: no output. `ContentCardComponent` and both mixed search surfaces +remain unchanged. + +- [ ] **Step 8: Commit the implementation** + +```bash +git add \ + libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts \ + libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts \ + libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss +git commit -m "fix(portal): remove redundant catalog type badges" +``` + +Expected: one commit containing only the grid-list behavior and regression +coverage. + +### Task 2: Validate the portal workflows + +**Files:** +- Verify only: `apps/web-e2e/src/xtream.e2e.ts` +- Verify only: `apps/web-e2e/src/stalker.e2e.ts` +- Verify only: `docs/architecture/iptvnator-ui-guidelines.md` + +- [ ] **Step 1: Run the Xtream portal E2E target** + +Run: + +```bash +pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts +``` + +Expected: PASS for the Xtream category and content-list workflows. + +- [ ] **Step 2: Run the Stalker portal E2E target** + +Run: + +```bash +pnpm nx run web-e2e:e2e-ci--src/stalker.e2e.ts +``` + +Expected: PASS for the Stalker category and content-list workflows. + +- [ ] **Step 3: Perform the final diff and documentation-impact check** + +Run: + +```bash +git diff HEAD^ --check +git show --stat --oneline HEAD +git status --short +``` + +Expected: + +- `git diff --check` exits 0; +- the implementation commit contains only the three grid-list files; +- the worktree contains only this implementation plan if it has not been + committed separately; +- no canonical documentation update is needed because + `docs/architecture/iptvnator-ui-guidelines.md` already directs contributors + not to add redundant badges or secondary selection systems. diff --git a/docs/superpowers/specs/2026-07-20-hide-homogeneous-catalog-type-badges-design.md b/docs/superpowers/specs/2026-07-20-hide-homogeneous-catalog-type-badges-design.md new file mode 100644 index 000000000..b02705a9a --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-hide-homogeneous-catalog-type-badges-design.md @@ -0,0 +1,53 @@ +# Hide Type Badges in Homogeneous Portal Catalogs + +## Context + +The shared `GridListComponent` renders catalog items after the user has already +selected Live TV, VOD, or Series in the portal rail. Every item in these grids +has the same content type, so the `LIVE`, `VOD`, and `SERIES` badges repeat +information that is already established by the surrounding navigation. + +Mixed-content surfaces such as portal search use `ContentCardComponent`, where +the type badge remains useful for distinguishing results. + +## Design + +Remove the type-badge markup and its dedicated styles from +`GridListComponent`. + +Keep the component's `type` input. It still controls provider-neutral behavior +that is unrelated to the badge: + +- choosing artwork placeholder icons; +- limiting country-prefix stripping to live content; +- selecting poster/logo presentation behavior. + +Do not change `ContentCardComponent` or any search, favorites, recently added, +or dashboard surface. Their badges remain unchanged. + +## Affected Portals and Views + +Because Xtream and Stalker both route their category pages through the shared +portal catalog view, removing the badge from `GridListComponent` covers: + +- Xtream Live TV, VOD, and Series homogeneous grids; +- Stalker Live TV, VOD, and Series homogeneous grids; +- the Xtream Live TV all-items grid. + +## Testing + +Update the focused `GridListComponent` tests to prove that no `.type-badge` is +rendered for `live`, `vod`, or `series`, while the `type` input continues to +drive existing title and placeholder behavior. + +Run the affected shared portal UI test target and the closest catalog feature +test target. Since this is a visible portal workflow change, run the closest +Xtream and Stalker Playwright coverage when available; otherwise perform the +strongest available targeted validation and document any skipped UI automation. + +## Documentation Impact + +The canonical UI guideline already says not to add redundant badges or a +second selection system. No canonical documentation change is required beyond +this design record because no route, architecture boundary, setup workflow, or +subsystem contract changes. diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss index 4c5c04304..aead84cdc 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss @@ -106,35 +106,6 @@ mat-card:hover { } } -// ─── Type badge ────────────────────────────────────────────────────────────── -.type-badge { - position: absolute; - top: 6px; - left: 6px; - z-index: 1; - padding: 3px 6px; - border-radius: 5px; - font-size: 0.58rem; - font-weight: 700; - line-height: 1; - text-transform: uppercase; - letter-spacing: 0.04em; - background: rgba(0, 0, 0, 0.85); - color: #fff; - - &.live { - color: #ef5350; - } - - &.movie { - color: #42a5f5; - } - - &.series { - color: #66bb6a; - } -} - // ─── Rating badge ───────────────────────────────────────────────────────────── .rating { position: absolute; diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts index 20ccfcb16..4bd36a133 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts @@ -59,7 +59,7 @@ describe('GridListComponent', () => { fixture = TestBed.createComponent(GridListComponent); }); - it('renders live logo cards with stream icons and a live badge', () => { + it('renders live logo cards with stream icons', () => { fixture.componentRef.setInput('items', [ { name: 'Live Channel', @@ -73,16 +73,27 @@ describe('GridListComponent', () => { const card = fixture.debugElement.query(By.css('mat-card')); const image = fixture.debugElement.query(By.css('.stream-icon')); - const badge = fixture.debugElement.query(By.css('.type-badge')); expect(card.nativeElement.classList).toContain('grid-card--logo'); expect(image.nativeElement.getAttribute('src')).toBe( 'channel-logo.png' ); - expect(badge.nativeElement.classList).toContain('live'); - expect(badge.nativeElement.textContent.trim()).toBe('live'); }); + it.each(['live', 'vod', 'series'] as const)( + 'does not render a redundant %s type badge in homogeneous grids', + (type) => { + fixture.componentRef.setInput('items', [ + { name: 'Catalog item', stream_icon: 'catalog-item.png' }, + ]); + fixture.componentRef.setInput('type', type); + fixture.detectChanges(); + expect( + fixture.debugElement.query(By.css('.type-badge')) + ).toBeNull(); + } + ); + it('renders the live placeholder for logo cards without artwork', () => { fixture.componentRef.setInput('items', [ { diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts index 1b1d59565..d4be4c1a7 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts @@ -106,16 +106,6 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined { > @let poster = resolvePoster(i);
- @if (type()) { -
- {{ type() }} -
- } @if (poster && !hasArtworkFailed(poster)) { Date: Tue, 21 Jul 2026 07:51:37 +0200 Subject: [PATCH 17/26] fix(stalker): preserve is_series episode metadata (#1218) --- .codex/skills/stalker-portal/SKILL.md | 66 +++ AGENTS.md | 5 + CLAUDE.md | 1 + docs/architecture/stalker-portal.md | 73 ++- docs/architecture/workspace-dashboard.md | 10 +- ...-20-stalker-is-series-playback-metadata.md | 552 ++++++++++++++++++ ...lker-is-series-playback-metadata-design.md | 135 +++++ .../src/lib/stalker-series.adapters.spec.ts | 38 ++ .../src/lib/stalker-series.adapters.ts | 28 +- .../stalker-series-quick-start.ts | 19 +- .../stalker-series-view.component.html | 5 +- .../stalker-series-view.component.spec.ts | 91 ++- .../stalker-series-view.component.ts | 26 +- .../src/lib/dashboard-data.service.spec.ts | 57 ++ 14 files changed, 1065 insertions(+), 41 deletions(-) create mode 100644 .codex/skills/stalker-portal/SKILL.md create mode 100644 docs/superpowers/plans/2026-07-20-stalker-is-series-playback-metadata.md create mode 100644 docs/superpowers/specs/2026-07-20-stalker-is-series-playback-metadata-design.md diff --git a/.codex/skills/stalker-portal/SKILL.md b/.codex/skills/stalker-portal/SKILL.md new file mode 100644 index 000000000..d59fa7a20 --- /dev/null +++ b/.codex/skills/stalker-portal/SKILL.md @@ -0,0 +1,66 @@ +--- +name: stalker-portal +description: Repository guidance for Stalker/Ministra portal catalogs, VOD/series shapes, playback metadata, collections, EPG, and remote control. +--- + +# Stalker Portal + +Use this skill when changing Stalker/Ministra routes, stores, catalog/detail +views, playback, favorites/recent activity, EPG, or remote control. + +## Read First + +- `docs/architecture/stalker-portal.md` +- `docs/architecture/stalker-epg.md` for ITV EPG work +- `docs/architecture/remote-control.md` for live remote-control work + +## Ownership + +- Feature UI: `libs/portal/stalker/feature/src/lib/` +- Store/API data access: `libs/portal/stalker/data-access/src/lib/` +- Electron requests: `apps/electron-backend/src/app/events/stalker.events.ts` +- Shared Stalker item normalization: + `libs/shared/interfaces/src/lib/stalker-item.normalizer.ts` +- Dashboard aggregation: `libs/workspace/dashboard/data-access/src/lib/` + +Keep provider-specific API and normalization behavior in Stalker data access. +Keep shared portal layouts/utilities provider-neutral. Preserve full-portal +session auth and simple IPC request paths. + +## `is_series` Cross-Surface Checklist + +Treat VOD items with `is_series` as series across every downstream surface. +Do not stop after making the detail view render. + +1. Accept portal flags `true`, `1`, and `'1'` through the existing normalizers. + Preserve all three modes: regular `/series`, embedded VOD `series[]`, and + lazy Ministra VOD `is_series`. +2. Build quick-start state through the shared series utility. Preserve + `labelKey`, `labelParams`, and `episodeLabel` when adapting it for Stalker; + translation parameters must reach the template. +3. Preserve `is_series` and the VOD origin in favorites/recent activity. + `extractStalkerItemType()` must normalize that activity to dashboard type + `series`. +4. Before either inline or external episode playback, persist the parent + `seriesXtreamId` plus resolved `seasonNumber` and `episodeNumber`. Keep + generated episode tracking IDs stable for lazy `is_series` episodes. When + `season_number` is absent, derive the coordinate from the same naturally + ordered season list used by quick start; do not default every season to 1. +5. The dashboard reads saved playback positions; it must not infer episode + numbers from provider payloads. Legacy rows without season/episode metadata + remain badge-less until that episode is played again. + +## Regression Coverage + +- Series view/UI and playback handoff: + `pnpm nx test portal-stalker-feature` +- Stalker shape/store behavior: + `pnpm nx test portal-stalker-data-access` +- Dashboard classification and position lookup: + `pnpm nx test workspace-dashboard-data-access` +- Dashboard badge rendering when changed: + `pnpm nx test workspace-dashboard-feature` + +For user-visible workflow changes, run the closest available E2E target. If no +fixture covers the affected portal shape, record that gap and perform the +strongest targeted unit/build validation available. diff --git a/AGENTS.md b/AGENTS.md index fb252fee7..01f664e84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -383,6 +383,11 @@ Key files: Use when moving heavy database work off the main thread, adding worker-backed SQLite operations, or wiring loading/progress UI for Xtream and playlist DB flows. File: `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md` +- `stalker-portal` + Repository-specific guidance for Stalker/Ministra catalogs, all three VOD/series modes, cross-surface `is_series` behavior, playback metadata, collections, EPG, and remote control. + Use when changing Stalker routes, stores, detail views, playback, favorites/recent activity, EPG, or remote control. + File: `.codex/skills/stalker-portal/SKILL.md` + - `xtream-electron` Repository-specific guidance for IPTVnator's Electron-first Xtream implementation, including feature/data-access boundaries, worker-backed DB flows, and Xtream loading/progress UX expectations. Use when working on Xtream routes, store/data-source logic, or Electron-backed Xtream import/search/delete behavior. diff --git a/CLAUDE.md b/CLAUDE.md index 47bbfd6d2..d8c1f2c9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -780,6 +780,7 @@ engine` (restart required) or - Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes) - Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`) - A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort. +- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items: quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again. - Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block - Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream) or TMDB (Stalker) - Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 42b3f4b20..931a54610 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -29,7 +29,8 @@ Stalker support covers: ## Routing Structure -Primary route tree lives in `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`. +Primary route tree lives in +`libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`. - `/stalker/:id/vod` - `/stalker/:id/series` @@ -45,30 +46,31 @@ Primary route tree lives in `/Users/4gray/Code/iptvnator/libs/portal/stalker/fea 1. Angular Stalker screens call methods/resources in `StalkerStore`. 2. `StalkerStore` builds request params based on selected content type and current view state. 3. Requests go through `DataService.sendIpcEvent(STALKER_REQUEST, ...)` or `StalkerSessionService` (full portal auth). -4. Electron main process handles `STALKER_REQUEST` in `/Users/4gray/Code/iptvnator/apps/electron-backend/src/app/events/stalker.events.ts`. +4. Electron main process handles `STALKER_REQUEST` in + `apps/electron-backend/src/app/events/stalker.events.ts`. 5. Axios calls Stalker `load.php` API with required headers/cookies and returns normalized payloads to renderer. ## Main UI Components -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts` - Category + content layout for `vod` and `series` -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` - ITV live playback, radio playback, channel/station navigation, EPG panel integration -- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/audio-player/audio-player.component.ts` +- `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` - Shared inline audio player used by M3U radio channels and Stalker radio stations -- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-series-view/stalker-series-view.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` - Season/episode UI for all Stalker series modes -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts` -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts` -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts` +- `libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` ## Store and Data Flow Stalker store is now feature-composed: -- Facade: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker.store.ts` -- Feature slices: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stores/features/*` -- Shared helpers: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/*` +- Facade: `libs/portal/stalker/data-access/src/lib/stalker.store.ts` +- Feature slices: `libs/portal/stalker/data-access/src/lib/stores/features/*` +- Shared helpers: `libs/portal/stalker/data-access/src/lib/*` Important store responsibilities: @@ -185,6 +187,9 @@ Stalker has multiple real-world data shapes. The current implementation supports - For unloaded VOD-series seasons, the CTA target label is derived from season metadata and rendered as `SxxE01` until episode details are loaded. - Uses unique generated tracking IDs for episode playback position compatibility. +- Quick-start actions preserve both their translation key and interpolation + parameters when adapted for the Stalker CTA. Dropping `labelParams` exposes + the raw `{{episode}}` placeholder. Series inline playback behavior is shared across all three modes: @@ -194,11 +199,30 @@ Series inline playback behavior is shared across all three modes: - Inline series autoplay is enabled by default. On player EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list. - Autoplay and Next stop at the last episode of the current season. They do not jump to the next season and do not lazy-load an unloaded `is_series=1` season. Quick start remains the only flow that may load another VOD-series season before playback. - Previous is disabled on the first episode of the current season and otherwise switches directly to the previous episode. +- Before either inline or external playback starts, the resolved content info + includes the parent `seriesXtreamId` and the mapped `seasonNumber` / + `episodeNumber`. Future playback-position rows therefore carry enough + metadata for workspace surfaces to render an episode badge. Existing rows + without those fields are intentionally not migrated and remain badge-less + until the episode is played again. +- Ministra payloads may omit `season_number`. Episode mapping and lazy + quick-start labels share the same naturally ordered season fallback so later + seasons are not persisted as season 1. + +The VOD-series contract is cross-surface: + +- Favorites and recently viewed records preserve the raw `is_series` flag and + VOD origin so reopening still uses the lazy Ministra resources. +- `extractStalkerItemType()` normalizes those activity records to dashboard + type `series`. +- The dashboard resolves episode progress by the parent `seriesXtreamId` and + renders the saved season/episode metadata. It does not infer episode numbers + from provider payloads. Core decision logic and normalization are centralized in: -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts` -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/models/*.ts` +- `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts` +- `libs/portal/stalker/data-access/src/lib/models/*.ts` ## Favorites and Recently Viewed @@ -213,10 +237,10 @@ Current implementation is shared via Stalker-specific helpers: Where this is used: -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts` -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts` -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` -- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts` +- `libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts` Navigation rule to preserve: @@ -264,7 +288,7 @@ Import rule: Stalker live remote control is implemented in: -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` Supported today: @@ -299,9 +323,12 @@ This reduces duplicate UI logic across portal types and keeps compatibility beha ## Regression Coverage -Focused regression tests for Stalker VOD mode branching live in: +Focused regression tests for Stalker VOD mode branching and the cross-surface +series contract live in: -- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts` +- `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts` +- `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` +- `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts` Covered scenarios include: @@ -310,3 +337,7 @@ Covered scenarios include: - VOD-backed series favorites keep VOD-series loading semantics when opened from favorites/global favorites - Favorite toggle helper path invokes the expected add/remove flow +- Quick-start episode labels interpolate their episode number +- Inline and external episode handoffs carry resolved season/episode metadata +- Dashboard activity classifies `is_series` VOD as series and resolves its + saved episode position diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 913421a86..7e0cb4ee7 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -93,10 +93,14 @@ Render rules: 2. It derives the dashboard surface via `computed()`: 1. `hero` — first item of `globalRecentItems()`. 2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series - cover cards. Xtream playback positions are bulk-loaded per playlist so + cover cards. Portal playback positions are bulk-loaded per playlist so hero and cards can show progress, remaining time, and series season/ - episode badges. Series lookup uses keyed maps for both direct episode ids - and series ids; card renders must not scan the full playback-position map. + episode badges. This includes Stalker VOD activity normalized to series + through `is_series`. Series lookup uses keyed maps for both direct + episode ids and parent series ids; card renders must not scan the full + playback-position map. The badge uses saved `seasonNumber` / + `episodeNumber` metadata and does not infer it from provider payloads; + legacy rows without that metadata remain badge-less until replay. Dashboard-originated Xtream series clicks also carry that exact episode target through the global-recent inline-detail handoff. Once the series metadata and playback positions load, the detail player consumes the diff --git a/docs/superpowers/plans/2026-07-20-stalker-is-series-playback-metadata.md b/docs/superpowers/plans/2026-07-20-stalker-is-series-playback-metadata.md new file mode 100644 index 000000000..a47a677e4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-stalker-is-series-playback-metadata.md @@ -0,0 +1,552 @@ +# Stalker `is_series` Playback Metadata Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Correct Stalker/Ministra VOD-backed series quick-start translations and persist episode coordinates so the workspace dashboard can show its season/episode badge. + +**Architecture:** Keep the provider-neutral dashboard contract unchanged. Preserve the shared quick-start translation parameters in the Stalker button view model, then enrich resolved Stalker series playback at the feature boundary by resolving the selected episode against the existing normalized `mappedSeasons()` data. + +**Tech Stack:** Angular standalone components and signals, ngx-translate, TypeScript, Jest, Nx, Markdown repository documentation. + +--- + +### Task 1: Establish the implementation branch and workspace + +**Files:** +- Verify: `package.json` +- Verify: `pnpm-lock.yaml` + +- [ ] **Step 1: Create the feature branch** + +Run: + +```bash +git switch -c agent/fix-stalker-is-series-metadata +``` + +Expected: Git reports a new branch named +`agent/fix-stalker-is-series-metadata`. + +- [ ] **Step 2: Install the frozen workspace dependencies** + +Run: + +```bash +pnpm install --frozen-lockfile +``` + +Expected: installation completes without changing `pnpm-lock.yaml`. + +- [ ] **Step 3: Verify Nx workspace discovery** + +Run: + +```bash +pnpm nx show projects +``` + +Expected: output includes `portal-stalker-feature`, +`workspace-dashboard-data-access`, and `web`. + +### Task 2: Add failing Stalker quick-start and playback regressions + +**Files:** +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` + +- [ ] **Step 1: Make the test translate pipe expose missing parameters** + +Change the `MockPipe(TranslatePipe, ...)` transform to render the problematic +translation with its parameter: + +```ts +MockPipe( + TranslatePipe, + ( + value: string | null | undefined, + params?: Record + ) => { + if (value === 'XTREAM.PLAY_EPISODE') { + return `Play episode ${params?.['episode'] ?? '{{episode}}'}`; + } + return value ?? ''; + } +), +``` + +- [ ] **Step 2: Add a quick-start interpolation regression** + +Add a component test that uses a loaded `is_series` episode with a non-resume, +non-watched position: + +```ts +it('interpolates the episode number for a recently started VOD is_series episode', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { + name: 'VOD Flagged Series', + description: 'Lazy seasons', + movie_image: 'vod-series.jpg', + }, + }); + serialSeasonsResource.set([]); + vodSeriesSeasonsResource.set([]); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + episodes: [ + { + id: 'episode-1', + series_number: 1, + name: 'Pilot', + }, + ], + isLoading: false, + isExpanded: false, + }, + ]); + const episode = fixture.componentInstance.mappedSeasons()['1'][0]; + fixture.componentInstance.episodePlaybackPositions.set( + new Map([ + [ + Number(episode.id), + { + contentXtreamId: Number(episode.id), + contentType: 'episode', + seriesXtreamId: 50001, + positionSeconds: 5, + durationSeconds: 100, + }, + ], + ]) + ); + fixture.detectChanges(); + + const button: HTMLButtonElement | null = + fixture.nativeElement.querySelector( + '[data-testid="series-quick-start"]' + ); + + expect(button?.textContent).toContain('Play episode 1'); + expect(button?.textContent).not.toContain('{{episode}}'); +}); +``` + +- [ ] **Step 3: Extend the existing external `is_series` quick-start test** + +After clicking the quick-start button, assert that the external playback object +contains episode coordinates: + +```ts +expect(openResolvedPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + contentInfo: expect.objectContaining({ + seasonNumber: 1, + episodeNumber: 1, + }), + }), + true +); +``` + +- [ ] **Step 4: Extend the existing inline `is_series` playback test** + +After `onEpisodeClicked(firstEpisode)`, assert: + +```ts +expect(inlinePlayer.playback()).toEqual( + expect.objectContaining({ + contentInfo: expect.objectContaining({ + seasonNumber: 1, + episodeNumber: 1, + }), + }) +); +``` + +- [ ] **Step 5: Run the Stalker feature tests and verify RED** + +Run: + +```bash +pnpm nx test portal-stalker-feature +``` + +Expected: the new interpolation and playback-coordinate assertions fail for +the missing `labelParams`, `seasonNumber`, and `episodeNumber`. + +### Task 3: Preserve translation parameters and enrich Stalker playback + +**Files:** +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- Test: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` + +- [ ] **Step 1: Preserve label parameters in the Stalker button model** + +Add the optional field: + +```ts +export interface StalkerQuickStartButton { + labelKey: string; + labelParams?: Record; + episodeLabel: string | null; + icon: string; + disabled: boolean; + action: SeriesQuickStartAction | null; + lazySeason: VodSeriesSeasonVm | null; +} +``` + +Copy it from a loaded action: + +```ts +return { + labelKey: action.labelKey, + labelParams: action.labelParams, + episodeLabel: action.episodeLabel, + icon: action.icon, + disabled: action.disabled, + action, + lazySeason: null, +}; +``` + +- [ ] **Step 2: Pass parameters to the Stalker translate pipe** + +Replace the quick-start label expression with: + +```html +{{ + action.labelKey + | translate: action.labelParams +}} +``` + +- [ ] **Step 3: Resolve episode coordinates before playback handoff** + +In `startPlayback(...)`, derive normalized episode state from the existing +mapped seasons and enrich only episode playback: + +```ts +const episodeState = + episodeId === undefined + ? null + : resolveSeriesPlaybackEpisodeState({ + episodesBySeason: this.mappedSeasons(), + currentEpisodeId: episodeId, + fallbackEpisodeNumber: episodeNum, + }); +const resolvedPlayback = + episodeState && playback.contentInfo?.contentType === 'episode' + ? { + ...playback, + contentInfo: { + ...playback.contentInfo, + seasonNumber: episodeState.seasonNumber, + episodeNumber: episodeState.episodeNumber, + }, + } + : playback; +``` + +Use `resolvedPlayback` for both branches: + +```ts +if (this.portalPlayer.isEmbeddedPlayer()) { + this.inlinePlayback.set(resolvedPlayback); + return; +} + +this.closeInlinePlayer(); +void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true); +``` + +- [ ] **Step 4: Run the Stalker feature tests and verify GREEN** + +Run: + +```bash +pnpm nx test portal-stalker-feature +``` + +Expected: all Stalker feature tests pass. + +- [ ] **Step 5: Commit the focused Stalker fix** + +Run: + +```bash +git add libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +git commit -m "fix(stalker): preserve series episode metadata" +``` + +Expected: one commit containing the Stalker tests and minimal production fix. + +### Task 4: Lock the dashboard’s existing `is_series` contract + +**Files:** +- Modify: `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts` + +- [ ] **Step 1: Add Stalker dashboard contract coverage** + +Add a test that supplies a Stalker playlist-backed recent item and its saved +episode position: + +```ts +it('resolves episode metadata for a Stalker VOD is_series recent item', async () => { + playlistsSignal.set([ + ...createDefaultPlaylists(), + { + _id: 'stalker-series', + title: 'Ministra Portal', + count: 1, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + macAddress: '00:11:22:33:44:55', + recentlyViewed: [ + { + id: '50001', + title: 'VOD Flagged Series', + category_id: 'vod', + is_series: '1', + added_at: '2026-07-20T12:00:00.000Z', + }, + ], + }, + ]); + playbackPositionsMock.getAllPlaybackPositions.mockImplementation( + async (playlistId: string) => + playlistId === 'stalker-series' + ? [ + { + playlistId, + contentXtreamId: 5000101, + contentType: 'episode', + seriesXtreamId: 50001, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 120, + durationSeconds: 1800, + }, + ] + : [] + ); + + await service.reloadPlaybackPositions(); + + const item = service + .globalRecentItems() + .find((recent) => recent.playlist_id === 'stalker-series'); + expect(item?.type).toBe('series'); + expect(service.getPlaybackPositionForItem(item!)).toEqual( + expect.objectContaining({ + seasonNumber: 1, + episodeNumber: 1, + }) + ); +}); +``` + +- [ ] **Step 2: Run the dashboard data-access tests** + +Run: + +```bash +pnpm nx test workspace-dashboard-data-access +``` + +Expected: all tests pass, proving that the dashboard already consumes the +metadata without a provider-specific branch. + +- [ ] **Step 3: Commit the dashboard contract test** + +Run: + +```bash +git add libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +git commit -m "test(dashboard): cover Stalker is_series positions" +``` + +Expected: one test-only commit. + +### Task 5: Document the cross-surface `is_series` contract + +**Files:** +- Modify: `docs/architecture/stalker-portal.md` +- Modify: `docs/architecture/workspace-dashboard.md` +- Modify: `.codex/skills/stalker-portal/SKILL.md` + +- [ ] **Step 1: Update Stalker architecture documentation** + +Add these rules to the `VOD/Series Modes` and regression sections: + +```markdown +- All three series modes must preserve shared quick-start translation + parameters. +- Episode playback must carry `seriesXtreamId`, `seasonNumber`, and + `episodeNumber` through both inline and external player handoffs. +- Playlist-backed activity keeps `is_series` so dashboard normalization + classifies the VOD-origin record as `series`. +- Existing playback rows without episode coordinates remain badge-less until + the next episode playback; the dashboard must not query the portal to infer + them. +``` + +- [ ] **Step 2: Update workspace dashboard documentation** + +Generalize the playback-position contract from Xtream-only wording and record: + +```markdown +Stalker VOD-backed `is_series` activity is normalized as series activity. +New Stalker episode positions include season/episode coordinates, so the hero +and Continue Watching cards use the same badge path as Xtream. Legacy rows +without those fields remain valid but do not show a badge. +``` + +- [ ] **Step 3: Add an agent-facing `is_series` checklist** + +In `.codex/skills/stalker-portal/SKILL.md`, require every Stalker series change +to verify: + +```markdown +1. Detail mode selection for regular, embedded `series[]`, and `is_series`. +2. Parameterized quick-start labels. +3. Recent/favorite normalization retaining VOD origin and series identity. +4. Playback `seriesXtreamId` plus season/episode coordinates. +5. Dashboard hero and Continue Watching consumption. +``` + +- [ ] **Step 4: Verify Markdown changes** + +Run: + +```bash +git diff --check +``` + +Expected: no whitespace errors. + +- [ ] **Step 5: Commit the documentation** + +Run: + +```bash +git add docs/architecture/stalker-portal.md docs/architecture/workspace-dashboard.md .codex/skills/stalker-portal/SKILL.md +git commit -m "docs(stalker): record is_series cross-surface contract" +``` + +Expected: one documentation commit. + +### Task 6: Validate the complete change + +**Files:** +- Verify: `libs/portal/stalker/feature/project.json` +- Verify: `libs/workspace/dashboard/data-access/project.json` +- Verify: `apps/web/project.json` + +- [ ] **Step 1: Run targeted unit tests** + +Run: + +```bash +pnpm nx test portal-stalker-feature +pnpm nx test workspace-dashboard-data-access +``` + +Expected: both targets pass. + +- [ ] **Step 2: Run affected lint targets** + +Run: + +```bash +pnpm nx lint portal-stalker-feature +pnpm nx lint workspace-dashboard-data-access +``` + +Expected: both targets pass. + +- [ ] **Step 3: Compile the Angular application** + +Run: + +```bash +pnpm nx build web +``` + +Expected: the web build completes successfully, proving the updated template +and TypeScript compile together. + +- [ ] **Step 4: Perform the test-impact audit** + +Confirm that no Stalker/Ministra fixture-backed E2E target covers lazy +`is_series` playback. Record that targeted unit coverage plus the Angular build +is the strongest automated validation if no such target exists. + +- [ ] **Step 5: Inspect the final diff and history** + +Run: + +```bash +git diff master...HEAD --check +git status --short +git log --oneline master..HEAD +``` + +Expected: no diff errors, a clean worktree, and intentional commits only. + +### Task 7: Independent review, PR creation, and review loop + +**Files:** +- Review: all files in `git diff master...HEAD` + +- [ ] **Step 1: Dispatch an independent subagent review** + +Ask a separate subagent to inspect the complete diff for correctness, +regressions, type safety, test sufficiency, and adherence to the approved +design. Require file/line evidence for every actionable finding. + +- [ ] **Step 2: Address validated subagent findings** + +For each finding, reproduce or confirm it, add or adjust tests first when +behavior changes, implement the smallest correction, and rerun the affected +validation targets. Commit any corrections separately. + +- [ ] **Step 3: Create the pull request** + +Push `agent/fix-stalker-is-series-metadata` and create a ready PR with: + +```text +Summary: +- interpolate Stalker series quick-start episode labels +- persist season/episode coordinates for all Stalker series modes +- document and test the Ministra is_series dashboard contract + +Validation: +- pnpm nx test portal-stalker-feature +- pnpm nx test workspace-dashboard-data-access +- pnpm nx lint portal-stalker-feature +- pnpm nx lint workspace-dashboard-data-access +- pnpm nx build web +``` + +- [ ] **Step 4: Inspect GitHub reviews, comments, and checks** + +Wait for initial PR checks and review-bot feedback. Read every unresolved review +thread and failed check, classify each item as actionable or non-actionable +with evidence, and address all actionable findings. + +- [ ] **Step 5: Repeat until clean** + +After each correction, rerun affected validation, push the new commit, and +re-check PR reviews/comments/checks. Stop only when checks are green and no +actionable unresolved feedback remains. diff --git a/docs/superpowers/specs/2026-07-20-stalker-is-series-playback-metadata-design.md b/docs/superpowers/specs/2026-07-20-stalker-is-series-playback-metadata-design.md new file mode 100644 index 000000000..0d8422ac1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-stalker-is-series-playback-metadata-design.md @@ -0,0 +1,135 @@ +# Stalker `is_series` Playback Metadata Design + +## Context + +Some Stalker/Ministra portals expose series inside the VOD catalog by setting +`is_series=1`. IPTVnator already normalizes this provider-specific shape and +loads its seasons and episodes lazily, but two cross-surface contracts are +incomplete: + +1. The Stalker quick-start button renders `XTREAM.PLAY_EPISODE` without the + translation parameters supplied by the shared series quick-start action. + The result is a visible `{{episode}}` placeholder. +2. Stalker episode playback does not add `seasonNumber` and `episodeNumber` to + `ResolvedPortalPlayback.contentInfo`. Playback positions therefore lack the + metadata used by the workspace dashboard hero and Continue Watching cards + to render their season/episode badge. + +The dashboard already classifies raw Stalker records carrying `is_series` as +series activity. No new dashboard-specific content-type branch is required. + +## Goals + +- Render parameterized Stalker quick-start translations correctly. +- Persist season and episode numbers for future Stalker episode playback, + including VOD-backed `is_series` series. +- Let the existing dashboard position lookup and badge rendering consume that + metadata without provider-specific duplication. +- Document `is_series` as a cross-surface compatibility contract so future + changes cover detail rendering, activity persistence, playback metadata, and + dashboard presentation together. + +## Non-goals + +- Migrating or reconstructing existing playback-position rows that do not + contain season/episode metadata. +- Loading Stalker catalog data from the dashboard to infer missing metadata. +- Changing Stalker season-loading behavior, episode ordering, tracking IDs, or + playback URLs. +- Redesigning the detail-page or dashboard UI. + +## Design + +### Quick-start translation + +`StalkerQuickStartButton` will expose the optional `labelParams` already +provided by `SeriesQuickStartAction`. For loaded episode actions, the Stalker +adapter will copy those parameters into its button view model. Lazy-season +actions will leave the field undefined because their label keys do not require +an episode parameter. + +The Stalker series template will call the translate pipe with +`action.labelParams`, matching the established Xtream series template. This +keeps translation-key selection in the shared quick-start utility and keeps +template behavior consistent across providers. + +### Playback metadata + +`StalkerSeriesViewComponent` already maps all three supported series shapes to +`Record`: + +- regular Stalker series; +- VOD with an embedded `series[]`; +- VOD with `is_series=1`. + +When an episode is selected, the component will use the mapped episode identity +to resolve its normalized season and episode numbers. After +`StalkerStore.resolveVodPlayback(...)` returns, the component will enrich the +episode `contentInfo` with those two fields before handing the playback object +to either the inline player or an external player. + +If no mapped episode state can be resolved, the component will preserve the +existing playback object unchanged. Missing metadata must never block +playback. + +This placement avoids expanding the positional `resolveVodPlayback(...)` +contract and ensures both inline and external playback receive the same +metadata. Subsequent playback-position writes will therefore carry: + +```text +playlistId +contentXtreamId +contentType = episode +seriesXtreamId +seasonNumber +episodeNumber +``` + +### Dashboard behavior + +No new dashboard branching will be introduced. Existing behavior remains: + +1. `extractStalkerItemType(...)` normalizes `is_series` activity to `series`. +2. `DashboardDataService` finds the newest episode position by + `seriesXtreamId`. +3. The dashboard hero and Continue Watching cards render the season/episode + badge when both metadata fields are present. + +Existing positions without these fields will remain badge-less until the user +plays an episode again and a new position is saved. + +## Tests + +Regression coverage will be added before production changes: + +1. Stalker series quick-start view-model/component coverage will demonstrate + that a parameterized `PLAY_EPISODE` action carries and renders the episode + number instead of `{{episode}}`. +2. Stalker `is_series` component coverage will demonstrate that resolved + playback contains the mapped season and episode numbers. +3. Dashboard coverage will use a Stalker `is_series` recent item plus an + episode playback position and assert that the hero exposes the expected + season/episode badge. + +Targeted Nx tests will run for the affected Stalker feature and workspace +dashboard projects. Broader validation will be selected after checking the +available project targets. + +## Documentation + +The implementation will update: + +- `docs/architecture/stalker-portal.md` with the activity/playback/dashboard + contract for all three series modes; +- `docs/architecture/workspace-dashboard.md` with Stalker episode-position + badge behavior and the forward-only limitation; +- `.codex/skills/stalker-portal/SKILL.md` with a cross-surface `is_series` + checklist for future agents. + +## Compatibility and failure handling + +- Raw `true`, `1`, and `"1"` `is_series` forms continue to normalize through + existing Stalker helpers. +- Existing series tracking IDs and saved positions remain valid. +- Playback remains functional when season/episode metadata cannot be derived. +- Old position rows are read unchanged and are not rewritten speculatively. diff --git a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts index 80c6d85b4..6a65d44c6 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts @@ -78,6 +78,44 @@ describe('stalker-series.adapters', () => { expect(firstEpisode.id).not.toBe(mapped['1'][1].id); }); + it('derives missing VOD-series season numbers from natural season order', () => { + const mapped = mapVodSeriesEpisodes([ + { + id: 'season-2', + video_id: 'v1', + name: 'Season 2', + season_number: '', + episodes: [ + { + id: 'episode-2', + series_number: 1, + name: 'Second season pilot', + }, + ], + isLoading: false, + isExpanded: false, + }, + { + id: 'season-1', + video_id: 'v1', + name: 'Season 1', + season_number: '', + episodes: [ + { + id: 'episode-1', + series_number: 1, + name: 'Pilot', + }, + ], + isLoading: false, + isExpanded: false, + }, + ]); + + expect(mapped['Season 1'][0].season).toBe(1); + expect(mapped['Season 2'][0].season).toBe(2); + }); + it('maps embedded series payload into regular season episodes', () => { const regularSeasons = mapRegularSeriesSeasons( { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts index 7a94a40bf..23bd5e1fc 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts @@ -7,6 +7,11 @@ import { } from './models'; import { isStalkerSeriesFlag } from './stalker-vod.utils'; +const naturalSeasonCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}); + export interface VodSeriesSeasonVm { id: string; video_id: string; @@ -154,7 +159,7 @@ export function mapVodSeriesEpisodes( seasons.forEach((season) => { const seasonKey = season.season_number || season.name || season.id; - const seasonNum = toEpisodeNumber(seasonKey) || 1; + const seasonNum = getVodSeriesSeasonNumber(season, seasons); mapped[seasonKey] = (season.episodes ?? []).map((episode) => { const episodeNum = @@ -230,3 +235,24 @@ export function mapRegularSeriesEpisodes( export function getVodSeriesSeasonKey(season: VodSeriesSeasonVm): string { return season.season_number || season.name || season.id; } + +export function getVodSeriesSeasonNumber( + season: VodSeriesSeasonVm, + seasons: ReadonlyArray +): number { + const parsedSeasonNumber = Number(season.season_number); + if (Number.isInteger(parsedSeasonNumber) && parsedSeasonNumber > 0) { + return parsedSeasonNumber; + } + + const orderedSeasons = [...seasons].sort((seasonA, seasonB) => + naturalSeasonCollator.compare( + getVodSeriesSeasonKey(seasonA), + getVodSeriesSeasonKey(seasonB) + ) + ); + const seasonIndex = orderedSeasons.findIndex( + (candidate) => candidate.id === season.id + ); + return seasonIndex >= 0 ? seasonIndex + 1 : 1; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts index a94388d7c..b9f9f2913 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts @@ -6,6 +6,7 @@ import { } from '@iptvnator/portal/shared/util'; import { getVodSeriesSeasonKey, + getVodSeriesSeasonNumber, type VodSeriesSeasonVm, } from '@iptvnator/portal/stalker/data-access'; import type { @@ -15,6 +16,7 @@ import type { export interface StalkerQuickStartButton { labelKey: string; + labelParams?: Record; episodeLabel: string | null; icon: string; disabled: boolean; @@ -72,6 +74,7 @@ export function getStalkerSeriesQuickStartButton( return { labelKey: action.labelKey, + labelParams: action.labelParams, episodeLabel: action.episodeLabel, icon: action.icon, disabled: action.disabled, @@ -158,16 +161,8 @@ function getLazyVodSeriesEpisodeLabel( season: VodSeriesSeasonVm, seasons: ReadonlyArray ): string { - const seasonIndex = seasons.findIndex((item) => item.id === season.id); - const parsedSeasonNumber = Number(season.season_number); - const seasonNumber = - Number.isInteger(parsedSeasonNumber) && parsedSeasonNumber > 0 - ? parsedSeasonNumber - : getFallbackSeasonNumber(seasonIndex); - - return formatSeriesEpisodeCode(seasonNumber, 1); -} - -function getFallbackSeasonNumber(seasonIndex: number): number { - return seasonIndex >= 0 ? seasonIndex + 1 : 1; + return formatSeriesEpisodeCode( + getVodSeriesSeasonNumber(season, seasons), + 1 + ); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 3b90c38b0..861be1769 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -104,7 +104,10 @@ - {{ action.labelKey | translate }} + {{ + action.labelKey + | translate: action.labelParams + }} @if (action.episodeLabel) { diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 77e9599b5..3aaa2899b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -241,7 +241,17 @@ describe('StalkerSeriesViewComponent', () => { StubSeasonContainerComponent, MockPipe( TranslatePipe, - (value: string | null | undefined) => value ?? '' + ( + value: string | null | undefined, + params?: Record + ) => { + if (value === 'XTREAM.PLAY_EPISODE') { + return `Play episode ${ + params?.['episode'] ?? '{{episode}}' + }`; + } + return value ?? ''; + } ), ], }, @@ -290,6 +300,68 @@ describe('StalkerSeriesViewComponent', () => { ); }); + it('interpolates the episode number for a recently started VOD is_series episode', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: '1', + info: { + name: 'VOD Flagged Series', + description: 'Lazy seasons', + movie_image: 'vod-series.jpg', + }, + }); + serialSeasonsResource.set([]); + vodSeriesSeasonsResource.set([]); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + episodes: [ + { + id: 'episode-1', + series_number: 1, + name: 'Pilot', + }, + ], + isLoading: false, + isExpanded: false, + }, + ]); + const episode = fixture.componentInstance.mappedSeasons()['1'][0]; + fixture.componentInstance.episodePlaybackPositions.set( + new Map([ + [ + Number(episode.id), + { + contentXtreamId: Number(episode.id), + contentType: 'episode', + seriesXtreamId: 50001, + positionSeconds: 5, + durationSeconds: 100, + }, + ], + ]) + ); + fixture.detectChanges(); + + const button: HTMLButtonElement | null = + fixture.nativeElement.querySelector( + '[data-testid="series-quick-start"]' + ); + + expect( + fixture.componentInstance.quickStartAction()?.labelParams + ).toEqual({ episode: 1 }); + expect(button?.textContent).toContain('Play episode 1'); + expect(button?.textContent).not.toContain('{{episode}}'); + }); + it('loads the first VOD-series season and starts its first episode from quick start', async () => { selectedContentType.set('vod'); selectedItem.set({ @@ -350,6 +422,15 @@ describe('StalkerSeriesViewComponent', () => { expect.any(Number), undefined ); + expect(openResolvedPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + contentInfo: expect.objectContaining({ + seasonNumber: 1, + episodeNumber: 1, + }), + }), + true + ); }); it('loads an earlier unloaded VOD-series season before showing completed', async () => { @@ -541,6 +622,14 @@ describe('StalkerSeriesViewComponent', () => { By.directive(StubPortalInlinePlayerComponent) ).componentInstance as StubPortalInlinePlayerComponent; + expect(inlinePlayer.playback()).toEqual( + expect.objectContaining({ + contentInfo: expect.objectContaining({ + seasonNumber: 1, + episodeNumber: 1, + }), + }) + ); expect(inlinePlayer.episodeMetadata()).toEqual({ label: 'S01E01', title: 'Pilot', diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 5e7cb5da8..55316a158 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -687,15 +687,37 @@ export class StalkerSeriesViewComponent implements OnDestroy { episodeId, startTime ); + const episodeState = + episodeId === undefined + ? null + : resolveSeriesPlaybackEpisodeState({ + episodesBySeason: this.mappedSeasons(), + currentEpisodeId: episodeId, + fallbackEpisodeNumber: episodeNum, + }); + const resolvedPlayback = + episodeState && playback.contentInfo?.contentType === 'episode' + ? { + ...playback, + contentInfo: { + ...playback.contentInfo, + seasonNumber: episodeState.seasonNumber, + episodeNumber: episodeState.episodeNumber, + }, + } + : playback; this.lastSaveTime = 0; if (this.portalPlayer.isEmbeddedPlayer()) { - this.inlinePlayback.set(playback); + this.inlinePlayback.set(resolvedPlayback); return; } this.closeInlinePlayer(); - void this.portalPlayer.openResolvedPlayback(playback, true); + void this.portalPlayer.openResolvedPlayback( + resolvedPlayback, + true + ); } catch (error) { this.logger.error('Failed to start inline series playback', error); const errorMessage = diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index f2124918c..09cbe4c5d 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -1003,6 +1003,63 @@ describe('DashboardDataService', () => { }); }); + it('resolves episode metadata for a Stalker VOD is_series recent item', async () => { + playlistsSignal.set([ + ...createDefaultPlaylists(), + { + _id: 'stalker-series', + title: 'Ministra Portal', + count: 1, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + macAddress: '00:11:22:33:44:55', + recentlyViewed: [ + { + id: '50001', + title: 'VOD Flagged Series', + category_id: 'vod', + is_series: '1', + added_at: '2026-07-20T12:00:00.000Z', + }, + ], + }, + ]); + playbackPositionsMock.getAllPlaybackPositions.mockImplementation( + async (playlistId: string) => + playlistId === 'stalker-series' + ? [ + { + playlistId, + contentXtreamId: 5000101, + contentType: 'episode', + seriesXtreamId: 50001, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 120, + durationSeconds: 1800, + }, + ] + : [] + ); + + await service.reloadPlaybackPositions(); + + const item = service + .globalRecentItems() + .find((recent) => recent.playlist_id === 'stalker-series'); + if (!item) { + throw new Error('expected the Stalker is_series recent item'); + } + + expect(item.type).toBe('series'); + expect(service.getPlaybackPositionForItem(item)).toEqual( + expect.objectContaining({ + seasonNumber: 1, + episodeNumber: 1, + }) + ); + }); + it('keeps legacy episode-keyed recents detail-only when the position row lacks the parent series id', async () => { dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([ { From e53adcbeaf0dfdcc6cdb1ac84bd8871fa3207730 Mon Sep 17 00:00:00 2001 From: Salem <40477317+SalemOurabi@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:08:37 +0200 Subject: [PATCH 18/26] fix(catchup): parse negative sub-hour XMLTV offsets correctly (#1216) * fix(catchup): parse negative sub-hour XMLTV offsets correctly XMLTV timestamps with offsets like "-0030" were treated as UTC because the hour part "-00" numerically evaluates to -0 and Math.sign(-0) dropped the minutes' sign. Derive the sign from the offset string instead, so sub-hour negative offsets shift the catch-up start time correctly. * test(catchup): cover positive sub-hour XMLTV offsets --------- Co-authored-by: 4gray --- .../m3u-utils/src/lib/catchup.utils.spec.ts | 68 +++++++++++++++++++ .../shared/m3u-utils/src/lib/catchup.utils.ts | 8 ++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts b/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts index 676c415d0..cd9fc21c8 100644 --- a/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts +++ b/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts @@ -217,6 +217,74 @@ describe('catchup.utils', () => { ); }); + it('applies negative sub-hour XMLTV offsets such as -0030', () => { + // Regression: Number('-00') === -0, so a Math.sign()-based conversion + // dropped the minutes' sign and treated -0030 as UTC. + expect( + resolveM3uCatchupUrl( + baseChannel, + { + ...archivedProgram, + start: '202604100800 -0030', + startTimestamp: null, + }, + 1_775_820_000 + ) + ).toBe( + 'https://streams.example.com/live/channel-1.m3u8?utc=1775809800&lutc=1775820000' + ); + }); + + it('applies positive offsets with minutes such as +0530', () => { + expect( + resolveM3uCatchupUrl( + baseChannel, + { + ...archivedProgram, + start: '202604100800 +0530', + startTimestamp: null, + }, + 1_775_820_000 + ) + ).toBe( + 'https://streams.example.com/live/channel-1.m3u8?utc=1775788200&lutc=1775820000' + ); + }); + + it('applies positive sub-hour XMLTV offsets such as +0030', () => { + // Regression: Number('+00') === 0, so a Math.sign()-based conversion + // dropped the minutes and treated +0030 as UTC. + expect( + resolveM3uCatchupUrl( + baseChannel, + { + ...archivedProgram, + start: '202604100800 +0030', + startTimestamp: null, + }, + 1_775_820_000 + ) + ).toBe( + 'https://streams.example.com/live/channel-1.m3u8?utc=1775806200&lutc=1775820000' + ); + }); + + it('treats a -0000 offset as UTC', () => { + expect( + resolveM3uCatchupUrl( + baseChannel, + { + ...archivedProgram, + start: '202604100800 -0000', + startTimestamp: null, + }, + 1_775_820_000 + ) + ).toBe( + 'https://streams.example.com/live/channel-1.m3u8?utc=1775808000&lutc=1775820000' + ); + }); + it('preserves catchup metadata when storing parsed playlist items', () => { const playlist = createPlaylistObject('Catchup playlist', { header: { diff --git a/libs/shared/m3u-utils/src/lib/catchup.utils.ts b/libs/shared/m3u-utils/src/lib/catchup.utils.ts index e65782a7e..612d14113 100644 --- a/libs/shared/m3u-utils/src/lib/catchup.utils.ts +++ b/libs/shared/m3u-utils/src/lib/catchup.utils.ts @@ -112,9 +112,13 @@ function getEpgProgramTimestampSeconds( Number(hour), Number(minute) ); + // Derive the sign from the string: for offsets like "-0030" the hour part + // is "-00", and Number("-00") === -0, so Math.sign() would drop the + // minutes' sign and yield a zero offset instead of -30 minutes. + const offsetSign = offsetHours.startsWith('-') ? -1 : 1; const offsetTotalMinutes = - Number(offsetHours) * 60 + - Math.sign(Number(offsetHours)) * Number(offsetMinutes); + offsetSign * + (Math.abs(Number(offsetHours)) * 60 + Number(offsetMinutes)); return Math.floor((utcMillis - offsetTotalMinutes * 60_000) / 1000); } From b3e130aa652a46bfa2afd98f53bb9c757c9d90d0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:31:49 +0200 Subject: [PATCH 19/26] feat(stalker): full Live TV channel list for complete search, count badges, and all-channels grid (#1209) Load the complete ITV channel list once per portal (Ministra get_all_channels with a paged crawl fallback) and use it for: full local search across every channel, per-genre category count badges, an all-channels paginated grid on Live TV entry, and eager bulk EPG so row previews appear without playing a channel. Censored (adult) genres absent from get_all_channels fall back to the legacy paged flow and show no badge. Includes mock-server support, unit + E2E coverage, and architecture docs. --- .../src/electron-test-fixtures.ts | 9 + apps/electron-backend-e2e/src/search.e2e.ts | 22 +- apps/stalker-mock-server/README.md | 2 + .../src/app/data-generator.ts | 12 + .../app/handlers/get-all-channels.handler.ts | 46 +++ .../src/app/handlers/get-genres.handler.ts | 2 +- .../app/handlers/get-ordered-list.handler.ts | 13 +- .../src/app/routes/dispatch.ts | 4 + apps/stalker-mock-server/src/app/scenarios.ts | 19 + apps/web-e2e/src/provider-target-route.ts | 5 +- apps/web-e2e/src/stalker.e2e.ts | 174 ++++++++ apps/web/src/assets/i18n/ar.json | 5 + apps/web/src/assets/i18n/ary.json | 5 + apps/web/src/assets/i18n/by.json | 5 + apps/web/src/assets/i18n/de.json | 5 + apps/web/src/assets/i18n/el.json | 5 + apps/web/src/assets/i18n/en.json | 5 + apps/web/src/assets/i18n/es.json | 5 + apps/web/src/assets/i18n/fr.json | 5 + apps/web/src/assets/i18n/it.json | 5 + apps/web/src/assets/i18n/ja.json | 5 + apps/web/src/assets/i18n/ko.json | 5 + apps/web/src/assets/i18n/nl.json | 5 + apps/web/src/assets/i18n/pl.json | 5 + apps/web/src/assets/i18n/pt.json | 5 + apps/web/src/assets/i18n/ru.json | 5 + apps/web/src/assets/i18n/tr.json | 5 + apps/web/src/assets/i18n/zh.json | 5 + apps/web/src/assets/i18n/zhtw.json | 5 + docs/architecture/stalker-epg.md | 16 +- docs/architecture/stalker-portal.md | 107 ++++- libs/portal/stalker/data-access/src/index.ts | 1 + .../models/stalker-category-item.interface.ts | 6 + .../models/stalker-itv-channel.interface.ts | 2 + .../src/lib/stalker-itv-cache.service.spec.ts | 391 ++++++++++++++++++ .../src/lib/stalker-itv-cache.service.ts | 223 ++++++++++ .../src/lib/stalker-itv-channel-loader.ts | 283 +++++++++++++ .../with-stalker-content.feature.spec.ts | 293 +++++++++++++ .../features/with-stalker-content.feature.ts | 181 +++++++- .../utils/stalker-content-mappers.spec.ts | 32 ++ .../stores/utils/stalker-content-mappers.ts | 18 + .../stalker-itv-all-items.component.scss | 72 ++++ .../stalker-itv-all-items.component.spec.ts | 123 ++++++ .../stalker-itv-all-items.component.ts | 146 +++++++ .../stalker-live-stream-layout.component.html | 64 ++- .../stalker-live-stream-layout.component.scss | 30 ++ ...alker-live-stream-layout.component.spec.ts | 366 +++++++++++++++- .../stalker-live-stream-layout.component.ts | 200 ++++++++- .../src/lib/stalker-portal-actions.enum.ts | 1 + ...space-context-category-view.component.html | 2 +- ...rkspace-context-category-view.component.ts | 15 + .../workspace-context-panel.component.html | 4 + .../workspace-context-panel.component.spec.ts | 85 ++++ .../workspace-context-panel.component.ts | 15 + .../workspace-shell-search.service.ts | 14 +- .../services/workspace-shell.facade.spec.ts | 19 +- 56 files changed, 3044 insertions(+), 63 deletions(-) create mode 100644 apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index a7c2f6c66..8b4198ab4 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -1596,6 +1596,15 @@ export async function expectWorkspaceSearchStatus( ).toHaveText(expected); } +/** The degraded-search hint chip must be absent (e.g. complete local search). */ +export async function expectNoWorkspaceSearchStatus( + page: Page +): Promise { + await expect( + page.locator('app-workspace-shell-header .search-chip--status') + ).toHaveCount(0); +} + async function startPortalDebugCapture(page: Page): Promise { await page.evaluate(() => { const electronApi = window.electron as typeof window.electron & { diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 9be5dd110..dc96e4b45 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -6,8 +6,8 @@ import { closeElectronApp, defaultStalkerMacAddress, expect, + expectNoWorkspaceSearchStatus, expectWorkspaceSearchScope, - expectWorkspaceSearchStatus, fillWorkspaceSearch, goToDashboard, importM3uPlaylistFromNativeDialog, @@ -987,7 +987,7 @@ test.describe('Electron Workspace Search', () => { } }); - test('@search @stalker shows loaded-only scope and filters channels on ITV routes', async ({ + test('@search @stalker searches the complete channel list on ITV routes', async ({ dataDir, request, }) => { @@ -1030,26 +1030,22 @@ test.describe('Electron Workspace Search', () => { app.mainWindow, `Live TV / ${sample.categoryName}` ); - await expectWorkspaceSearchStatus( - app.mainWindow, - 'Loaded channels only' - ); + // Entering Live TV preloads the COMPLETE channel list (Ministra + // get_all_channels), so search runs locally over every channel: + // no portal-side search request, and no "Loaded channels only" + // degraded hint. await waitForPortalDebugEvent(app.mainWindow, { provider: 'stalker', - operation: 'get_ordered_list', + operation: 'get_all_channels', predicate: (event) => { const requestPayload = event.request as { params?: Record; }; - return ( - requestPayload.params?.['type'] === 'itv' && - String(requestPayload.params?.['category']) === - sample.categoryId && - requestPayload.params?.['search'] === itvQuery - ); + return requestPayload.params?.['type'] === 'itv'; }, }); + await expectNoWorkspaceSearchStatus(app.mainWindow); await expect( channelItemByTitle(app.mainWindow, sample.targetTitle).first() ).toBeVisible(); diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index 68a71617e..fbcda2e2e 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -38,6 +38,7 @@ Then in IPTVnator, add a new Stalker portal: | `00:1A:79:00:00:03` | **minimal** | 2 categories, 5 items — edge case testing (empty states, single items) | | `00:1A:79:00:00:04` | **is-series** | 60% of VOD items have `is_series=1` — tests the Ministra lazy-season flow | | `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow | +| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list | | `` | **auto** | MAC bytes used as seed → deterministic unique dataset | ## Configuration @@ -65,6 +66,7 @@ All endpoints are served at `GET /portal.php?action=&...` matching the r | `get_categories` | Category list filtered by `type` (itv/vod/series) | | `get_genres` | Genre list (mirrors categories) | | `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons | +| `get_all_channels` | Complete ITV channel list in one response (`type=itv` only); excludes censored (adult) genres; disabled in the `legacy-pagination` scenario | | `create_link` | Returns a real public HLS stream URL for playback | | `favorites` | Add / remove / get favorites (in-memory, resets on restart) | | `get_short_epg` | Current-and-upcoming EPG window for a channel (`ch_id`, `size`) | diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index 0f0cb9c68..9e7e1af44 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -9,6 +9,8 @@ export interface RawCategory { id: string; title: string; alias: string; + /** Ministra adult-genre flag ('1' = censored). */ + censored?: string; } export interface RawChannel { @@ -171,6 +173,16 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData // ------ ITV categories + channels ------ data.itvCategories = generateCategories('itv', config.categoryCount.itv); + // Real Ministra portals mark adult genres as censored and EXCLUDE their + // channels from get_all_channels / the "*" listing; the channels are only + // served by paging the specific genre. Mirror that with one extra + // censored category so clients can exercise the fallback path. + data.itvCategories.push({ + id: '1099', + title: 'For adults', + alias: 'for_adults', + censored: '1', + }); let channelIndex = 0; for (const cat of data.itvCategories) { const channels = generateChannels(cat.id, config.itemsPerCategory, channelIndex); diff --git a/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts new file mode 100644 index 000000000..7d37396fb --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts @@ -0,0 +1,46 @@ +import { Request, Response } from 'express'; +import { getPortalData } from '../data-store.js'; +import { getScenario } from '../scenarios.js'; +import { extractMac } from './get-categories.handler.js'; + +/** + * Stalker/Ministra get_all_channels — returns the COMPLETE ITV channel list + * in a single response (no pagination). This is what STB clients use to build + * their local channel list. + * + * Only type=itv is supported, matching the reference Ministra middleware. + * Scenarios with supportsGetAllChannels=false mimic legacy portals and answer + * with an error payload so clients fall back to paginated get_ordered_list. + */ +export function handleGetAllChannels(req: Request, res: Response): void { + const mac = extractMac(req); + const type = (req.query['type'] as string) ?? 'itv'; + const scenario = getScenario(mac); + + if (scenario.supportsGetAllChannels === false || type !== 'itv') { + res.json({ js: { error: 'Unknown action: get_all_channels' } }); + return; + } + + const data = getPortalData(mac); + // Like real Ministra portals: censored (adult) genres are excluded from + // the bulk channel list — clients must page those genres explicitly. + const censoredIds = new Set( + data.itvCategories + .filter((cat) => cat.censored === '1') + .map((cat) => cat.id) + ); + const allChannels = [...data.channels.entries()] + .filter(([categoryId]) => !censoredIds.has(categoryId)) + .flatMap(([, channels]) => channels); + + res.json({ + js: { + data: allChannels, + total_items: allChannels.length, + max_page_items: allChannels.length, + cur_page: 0, + selected_item: 0, + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts index a31002aa0..bc9e30a9a 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts @@ -26,7 +26,7 @@ export function handleGetGenres(req: Request, res: Response): void { id: cat.id, title: cat.title, alias: cat.alias, - censored: '0', + censored: cat.censored ?? '0', })); res.json({ js: genres }); diff --git a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts index 569ead57d..0a60127bd 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts @@ -34,8 +34,17 @@ export function handleGetOrderedList(req: Request, res: Response): void { if (type === 'itv') { if (categoryId === '*') { - for (const items of data.channels.values()) { - allItems.push(...items); + // The "*" listing excludes censored (adult) genres, matching + // real portals; their channels are only served by genre id. + const censoredIds = new Set( + data.itvCategories + .filter((cat) => cat.censored === '1') + .map((cat) => cat.id) + ); + for (const [catId, items] of data.channels.entries()) { + if (!censoredIds.has(catId)) { + allItems.push(...items); + } } } else { allItems = data.channels.get(categoryId) ?? []; diff --git a/apps/stalker-mock-server/src/app/routes/dispatch.ts b/apps/stalker-mock-server/src/app/routes/dispatch.ts index 507ad1bf8..4d8aa2420 100644 --- a/apps/stalker-mock-server/src/app/routes/dispatch.ts +++ b/apps/stalker-mock-server/src/app/routes/dispatch.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { handleHandshake } from '../handlers/handshake.handler.js'; import { handleDoAuth } from '../handlers/do-auth.handler.js'; +import { handleGetAllChannels } from '../handlers/get-all-channels.handler.js'; import { handleGetCategories } from '../handlers/get-categories.handler.js'; import { handleGetOrderedList } from '../handlers/get-ordered-list.handler.js'; import { handleGetSeasons } from '../handlers/get-seasons.handler.js'; @@ -39,6 +40,9 @@ export default function dispatchPortalAction(req: Request, res: Response): void handleGetOrderedList(req, res); } break; + case 'get_all_channels': + handleGetAllChannels(req, res); + break; case 'create_link': handleCreateLink(req, res); break; diff --git a/apps/stalker-mock-server/src/app/scenarios.ts b/apps/stalker-mock-server/src/app/scenarios.ts index 6657990ae..74c271dd6 100644 --- a/apps/stalker-mock-server/src/app/scenarios.ts +++ b/apps/stalker-mock-server/src/app/scenarios.ts @@ -15,6 +15,12 @@ export interface ScenarioConfig { isSeriesFraction: number; /** Fraction of VOD items that have embedded series[] array */ embeddedSeriesFraction: number; + /** + * Whether the portal implements the ITV `get_all_channels` action. + * Defaults to true; set false to force clients onto paginated + * `get_ordered_list` crawling (legacy portals). + */ + supportsGetAllChannels?: boolean; } /** @@ -88,6 +94,19 @@ export const SCENARIOS: Record = { isSeriesFraction: 0, embeddedSeriesFraction: 0.5, // 50% of VOD items have embedded series[] }, + '00:1a:79:00:00:06': { + name: 'legacy-pagination', + description: + 'Portal without get_all_channels — clients must crawl get_ordered_list pages', + seed: 6006, + categoryCount: { itv: 6, radio: 4, vod: 4, series: 4 }, + itemsPerCategory: 40, + seasonsPerSeries: 2, + episodesPerSeason: 5, + isSeriesFraction: 0, + embeddedSeriesFraction: 0, + supportsGetAllChannels: false, + }, }; /** diff --git a/apps/web-e2e/src/provider-target-route.ts b/apps/web-e2e/src/provider-target-route.ts index 50ebd11ef..ccc0cf9ab 100644 --- a/apps/web-e2e/src/provider-target-route.ts +++ b/apps/web-e2e/src/provider-target-route.ts @@ -5,10 +5,13 @@ interface ProviderTargetPayload { } const BACKEND_ORIGIN = 'http://localhost:3000'; +const APP_ORIGIN = process.env['BASE_URL'] + ? new URL(process.env['BASE_URL']).origin + : 'http://localhost:4200'; const CORS_HEADERS = { 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'POST, OPTIONS', - 'access-control-allow-origin': 'http://localhost:4200', + 'access-control-allow-origin': APP_ORIGIN, } as const; export async function interceptProviderTargetRegistration( diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index f7c7c56b3..f381d33cf 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -35,6 +35,9 @@ const DEFAULT_MAC = '00:1A:79:00:00:01'; /** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */ const MINIMAL_MAC = '00:1A:79:00:00:03'; +/** Legacy pagination MAC — portal without get_all_channels support */ +const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06'; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -333,6 +336,177 @@ test('@stalker PWA skips bulk EPG across channel switches', async ({ expect(shortEpgRequests).toHaveLength(0); }); +test('@stalker ITV full channel list loads via get_all_channels and search covers it', async ({ + page, +}) => { + await addStalkerPortal(page); + + const allChannelsRequests: string[] = []; + page.on('request', (request) => { + if (request.url().includes('action=get_all_channels')) { + allChannelsRequests.push(request.url()); + } + }); + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + + // BEFORE any category click (Xtream parity): entering the Live TV section + // preloads the full list, so the main area shows the paginated + // all-channels grid and the categories already carry count badges. + const allItemsGrid = page.locator('app-stalker-itv-all-items'); + await expect(allItemsGrid.locator('mat-card').first()).toBeVisible({ + timeout: 20_000, + }); + await expect( + allItemsGrid.locator('.mat-mdc-paginator-range-label') + ).toContainText('of 320'); + await expect(categories.nth(0).locator('.item-count')).toHaveText('320', { + timeout: 10_000, + }); + await expect(categories.nth(1).locator('.item-count')).toHaveText('40'); + + await categories.nth(1).click(); + + const channels = page.locator('[data-test-id="channel-item"]'); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + + // Regression for "search only finds the first 14 loaded items": once the + // full list is cached, the whole category (40 channels) is available + // without scrolling through 14-item pages. + await expect(page.locator('.category-subtitle')).toHaveText('40 items', { + timeout: 20_000, + }); + await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0); + + // Regression: switching to another category once the full list is cached + // must serve that category from the cache, not get stuck on an empty + // skeleton. (The reset-on-category-change effect used to clobber the + // synchronously served list.) + await categories.nth(2).click(); + await expect(page.locator('.category-subtitle')).toHaveText('40 items', { + timeout: 20_000, + }); + await expect(channels.first()).toBeVisible({ timeout: 10_000 }); + // No further get_all_channels request — it's served from the session cache. + const requestsAfterFirstCategory = allChannelsRequests.length; + await categories.nth(3).click(); + await expect(channels.first()).toBeVisible({ timeout: 10_000 }); + expect(allChannelsRequests.length).toBe(requestsAfterFirstCategory); + + // Back to the first category for the search assertions below. + await categories.nth(1).click(); + await expect(page.locator('.category-subtitle')).toHaveText('40 items', { + timeout: 20_000, + }); + + // Search a channel from deep in the list (beyond the first 14 items). + const deepChannelName = ( + await channels.nth(30).locator('.channel-name').textContent() + )?.trim(); + expect(deepChannelName).toBeTruthy(); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(deepChannelName as string); + await searchInput.press('Enter'); + + await expect( + page + .locator('[data-test-id="channel-item"] .channel-name') + .filter({ hasText: deepChannelName as string }) + .first() + ).toBeVisible({ timeout: 10_000 }); + // The "loaded only" degraded-search hint must be gone in full-list mode. + await expect(page.locator('.search-chip--status')).toHaveCount(0); +}); + +test('@stalker ITV censored category pages from the portal and hides its badge', async ({ + page, +}) => { + await addStalkerPortal(page); + + const adultListRequests: string[] = []; + page.on('request', (request) => { + const url = request.url(); + if ( + url.includes('action=get_ordered_list') && + url.includes('type=itv') && + url.includes('genre=1099') + ) { + adultListRequests.push(url); + } + }); + + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + + const categories = page.locator('.category-item'); + // Wait until the full list is cached (a regular category shows its badge). + await expect(categories.nth(1).locator('.item-count')).toHaveText('40', { + timeout: 20_000, + }); + + // The censored genre is excluded from get_all_channels, so its real count + // is unknown — no badge instead of a misleading "0". + const adultCategory = page.locator('.category-item', { + hasText: 'For adults', + }); + await expect(adultCategory).toBeVisible(); + await expect(adultCategory.locator('.item-count')).toHaveCount(0); + + // Clicking it falls back to the legacy paged flow and still shows channels. + await adultCategory.click(); + const channels = page.locator('[data-test-id="channel-item"]'); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + await expect.poll(() => adultListRequests.length).toBeGreaterThan(0); +}); + +test('@stalker ITV falls back to page crawling on portals without get_all_channels', async ({ + page, +}) => { + await addStalkerPortal(page, { + name: 'Legacy Stalker Portal', + mac: LEGACY_PAGINATION_MAC, + }); + + const allChannelsRequests: string[] = []; + const crawlRequests: string[] = []; + page.on('request', (request) => { + const url = request.url(); + if (url.includes('action=get_all_channels')) { + allChannelsRequests.push(url); + } + // The full-list crawl pages through ALL genres (genre=*). + if ( + url.includes('action=get_ordered_list') && + url.includes('type=itv') && + (url.includes('genre=*') || url.includes('genre=%2A')) + ) { + crawlRequests.push(url); + } + }); + + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + + const channels = page.locator('[data-test-id="channel-item"]'); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + + // The crawl collects all 6 × 40 channels; the selected category then + // shows its full 40 items without manual lazy-load scrolling. + await expect(page.locator('.category-subtitle')).toHaveText('40 items', { + timeout: 30_000, + }); + await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0); + expect(crawlRequests.length).toBeGreaterThan(1); +}); + test('@stalker mock server reset clears cached state', async ({ request }) => { // Generate data for default MAC const before = await request.get( diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index bf4ec546f..833bd2075 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم", "PLAYBACK_ERROR": "فشل الحصول على رابط التشغيل", "LOADING_SEASONS": "جارٍ تحميل المواسم...", + "LOADING_ALL_CHANNELS": "جارٍ تحميل قائمة القنوات الكاملة…", + "REFRESH_CHANNEL_LIST": "تحديث قائمة القنوات", + "NO_CHANNELS_IN_CATEGORY": "لا توجد قنوات في هذه الفئة", + "ITEM": "عنصر", + "ITEMS": "عناصر", "NO_EPISODES": "لا توجد حلقات متاحة", "ALL_RADIO": "كل الراديو" }, diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 34d2bc61a..27188b4e8 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر", "PLAYBACK_ERROR": "فشل جلب رابط التشغيل", "LOADING_SEASONS": "جاري تشريج المواسم...", + "LOADING_ALL_CHANNELS": "كيتم تحميل لائحة القنوات كاملة…", + "REFRESH_CHANNEL_LIST": "تحديث لائحة القنوات", + "NO_CHANNELS_IN_CATEGORY": "ماكاينش قنوات فهاد الصنف", + "ITEM": "عنصر", + "ITEMS": "عناصر", "NO_EPISODES": "ما كاينش حلقات متوفرة", "ALL_RADIO": "All radio" }, diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 307fe897c..ac6cd8081 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы", "PLAYBACK_ERROR": "Не ўдалося атрымаць URL для прайгравання", "LOADING_SEASONS": "Загрузка сезонаў...", + "LOADING_ALL_CHANNELS": "Загрузка поўнага спісу каналаў…", + "REFRESH_CHANNEL_LIST": "Абнавіць спіс каналаў", + "NO_CHANNELS_IN_CATEGORY": "У гэтай катэгорыі няма каналаў", + "ITEM": "элемент", + "ITEMS": "элементаў", "NO_EPISODES": "Няма даступных эпізодаў", "ALL_RADIO": "Усе радыё" }, diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index e530ef81d..b9a3c2d9f 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Dieser Inhalt ist auf diesem Server nicht verfügbar", "PLAYBACK_ERROR": "Wiedergabe-URL konnte nicht abgerufen werden", "LOADING_SEASONS": "Staffeln werden geladen …", + "LOADING_ALL_CHANNELS": "Vollständige Senderliste wird geladen…", + "REFRESH_CHANNEL_LIST": "Senderliste aktualisieren", + "NO_CHANNELS_IN_CATEGORY": "Keine Sender in dieser Kategorie", + "ITEM": "Element", + "ITEMS": "Elemente", "NO_EPISODES": "Keine Episoden verfügbar", "ALL_RADIO": "Alle Radiosender" }, diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 24ad44f67..335cc47bb 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή", "PLAYBACK_ERROR": "Αποτυχία λήψης διεύθυνσης URL αναπαραγωγής", "LOADING_SEASONS": "Φόρτωση σεζόν...", + "LOADING_ALL_CHANNELS": "Φόρτωση πλήρους λίστας καναλιών…", + "REFRESH_CHANNEL_LIST": "Ανανέωση λίστας καναλιών", + "NO_CHANNELS_IN_CATEGORY": "Δεν υπάρχουν κανάλια σε αυτήν την κατηγορία", + "ITEM": "στοιχείο", + "ITEMS": "στοιχεία", "NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια", "ALL_RADIO": "Όλα τα ραδιόφωνα" }, diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index d0e7651a2..92d396baa 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "This content is not available on this server", "PLAYBACK_ERROR": "Failed to get playback URL", "LOADING_SEASONS": "Loading seasons...", + "LOADING_ALL_CHANNELS": "Loading full channel list…", + "REFRESH_CHANNEL_LIST": "Refresh channel list", + "NO_CHANNELS_IN_CATEGORY": "No channels in this category", + "ITEM": "item", + "ITEMS": "items", "NO_EPISODES": "No episodes available", "ALL_RADIO": "All radio" }, diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 752453717..c7cef836c 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Este contenido no está disponible en este servidor", "PLAYBACK_ERROR": "No se pudo obtener la URL de reproducción", "LOADING_SEASONS": "Cargando temporadas…", + "LOADING_ALL_CHANNELS": "Cargando la lista completa de canales…", + "REFRESH_CHANNEL_LIST": "Actualizar lista de canales", + "NO_CHANNELS_IN_CATEGORY": "No hay canales en esta categoría", + "ITEM": "elemento", + "ITEMS": "elementos", "NO_EPISODES": "No hay episodios disponibles", "ALL_RADIO": "Todas las radios" }, diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ae7d75e39..79e523fff 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Ce contenu n'est pas disponible sur ce serveur", "PLAYBACK_ERROR": "Échec de l'obtention de l'URL de lecture", "LOADING_SEASONS": "Chargement des saisons…", + "LOADING_ALL_CHANNELS": "Chargement de la liste complète des chaînes…", + "REFRESH_CHANNEL_LIST": "Actualiser la liste des chaînes", + "NO_CHANNELS_IN_CATEGORY": "Aucune chaîne dans cette catégorie", + "ITEM": "élément", + "ITEMS": "éléments", "NO_EPISODES": "Aucun épisode disponible", "ALL_RADIO": "Toutes les radios" }, diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 4b970e7e0..0421f61d8 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Questo contenuto non è disponibile su questo server", "PLAYBACK_ERROR": "Impossibile ottenere l'URL di riproduzione", "LOADING_SEASONS": "Caricamento stagioni...", + "LOADING_ALL_CHANNELS": "Caricamento dell'elenco completo dei canali…", + "REFRESH_CHANNEL_LIST": "Aggiorna elenco canali", + "NO_CHANNELS_IN_CATEGORY": "Nessun canale in questa categoria", + "ITEM": "elemento", + "ITEMS": "elementi", "NO_EPISODES": "Nessun episodio disponibile", "ALL_RADIO": "Tutte le radio" }, diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2d108d99c..73ea0138e 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません", "PLAYBACK_ERROR": "再生URLの取得に失敗しました", "LOADING_SEASONS": "シーズンを読み込み中...", + "LOADING_ALL_CHANNELS": "全チャンネルリストを読み込み中…", + "REFRESH_CHANNEL_LIST": "チャンネルリストを更新", + "NO_CHANNELS_IN_CATEGORY": "このカテゴリーにチャンネルがありません", + "ITEM": "件", + "ITEMS": "件", "NO_EPISODES": "利用可能なエピソードはありません", "ALL_RADIO": "すべてのラジオ" }, diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 25b7226bd..e43ddc6b3 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다", "PLAYBACK_ERROR": "재생 URL을 가져오지 못했습니다", "LOADING_SEASONS": "시즌을 불러오는 중...", + "LOADING_ALL_CHANNELS": "전체 채널 목록 불러오는 중…", + "REFRESH_CHANNEL_LIST": "채널 목록 새로고침", + "NO_CHANNELS_IN_CATEGORY": "이 카테고리에 채널이 없습니다", + "ITEM": "개", + "ITEMS": "개", "NO_EPISODES": "사용 가능한 에피소드가 없습니다", "ALL_RADIO": "모든 라디오" }, diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index fff5dfbee..6d05aab79 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Deze inhoud is niet beschikbaar op deze server", "PLAYBACK_ERROR": "Ophalen van afspeel-URL mislukt", "LOADING_SEASONS": "Seizoenen laden...", + "LOADING_ALL_CHANNELS": "Volledige zenderlijst wordt geladen…", + "REFRESH_CHANNEL_LIST": "Zenderlijst vernieuwen", + "NO_CHANNELS_IN_CATEGORY": "Geen zenders in deze categorie", + "ITEM": "item", + "ITEMS": "items", "NO_EPISODES": "Geen afleveringen beschikbaar", "ALL_RADIO": "Alle radio" }, diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index d853d98cb..14758b632 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Ta treść jest niedostępna na tym serwerze", "PLAYBACK_ERROR": "Nie udało się uzyskać URL odtwarzania", "LOADING_SEASONS": "Ładowanie sezonów...", + "LOADING_ALL_CHANNELS": "Ładowanie pełnej listy kanałów…", + "REFRESH_CHANNEL_LIST": "Odśwież listę kanałów", + "NO_CHANNELS_IN_CATEGORY": "Brak kanałów w tej kategorii", + "ITEM": "pozycja", + "ITEMS": "pozycji", "NO_EPISODES": "Brak dostępnych odcinków", "ALL_RADIO": "Wszystkie radia" }, diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index c0cd4ade0..89f58e2ec 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Este conteúdo não está disponível neste servidor", "PLAYBACK_ERROR": "Falha ao obter URL de reprodução", "LOADING_SEASONS": "Carregando temporadas...", + "LOADING_ALL_CHANNELS": "A carregar a lista completa de canais…", + "REFRESH_CHANNEL_LIST": "Atualizar lista de canais", + "NO_CHANNELS_IN_CATEGORY": "Nenhum canal nesta categoria", + "ITEM": "item", + "ITEMS": "itens", "NO_EPISODES": "Nenhum episódio disponível", "ALL_RADIO": "Todas as rádios" }, diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 414e25772..f0c007b9d 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере", "PLAYBACK_ERROR": "Не удалось получить URL для воспроизведения", "LOADING_SEASONS": "Загрузка сезонов…", + "LOADING_ALL_CHANNELS": "Загрузка полного списка каналов…", + "REFRESH_CHANNEL_LIST": "Обновить список каналов", + "NO_CHANNELS_IN_CATEGORY": "В этой категории нет каналов", + "ITEM": "элемент", + "ITEMS": "элементов", "NO_EPISODES": "Нет доступных эпизодов", "ALL_RADIO": "Все радио" }, diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 24c48b1c3..d89dbad45 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Bu içerik bu sunucuda mevcut değil", "PLAYBACK_ERROR": "Oynatma URL'si alınamadı", "LOADING_SEASONS": "Sezonlar yükleniyor...", + "LOADING_ALL_CHANNELS": "Tüm kanal listesi yükleniyor…", + "REFRESH_CHANNEL_LIST": "Kanal listesini yenile", + "NO_CHANNELS_IN_CATEGORY": "Bu kategoride kanal yok", + "ITEM": "öğe", + "ITEMS": "öğe", "NO_EPISODES": "Bölüm mevcut değil", "ALL_RADIO": "Tüm radyolar" }, diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index db2db90ae..17ccb0b36 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "此服务器上没有此内容", "PLAYBACK_ERROR": "获取播放 URL 失败", "LOADING_SEASONS": "正在加载季…", + "LOADING_ALL_CHANNELS": "正在加载完整频道列表…", + "REFRESH_CHANNEL_LIST": "刷新频道列表", + "NO_CHANNELS_IN_CATEGORY": "此分类中没有频道", + "ITEM": "项", + "ITEMS": "项", "NO_EPISODES": "没有可用的剧集", "ALL_RADIO": "所有广播" }, diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ed43744f8..87d93aa43 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容", "PLAYBACK_ERROR": "無法取得播放網址", "LOADING_SEASONS": "正在載入季數...", + "LOADING_ALL_CHANNELS": "正在載入完整頻道清單…", + "REFRESH_CHANNEL_LIST": "重新整理頻道清單", + "NO_CHANNELS_IN_CATEGORY": "此分類中沒有頻道", + "ITEM": "項", + "ITEMS": "項", "NO_EPISODES": "沒有可用的集數", "ALL_RADIO": "所有廣播" }, diff --git a/docs/architecture/stalker-epg.md b/docs/architecture/stalker-epg.md index 20367b344..437f251be 100644 --- a/docs/architecture/stalker-epg.md +++ b/docs/architecture/stalker-epg.md @@ -15,9 +15,19 @@ Stalker now uses two EPG paths with different purposes: - The active channel EPG panel uses `get_epg_info` as a bulk endpoint, fetches a 7-day window once per playlist session, caches programs by channel id, and renders the selected channel through the shared `app-epg-timeline` component. -- Channel rows no longer send preview EPG requests during initial category load. - They stay empty until bulk EPG has been fetched once, then derive their - current program and progress bar from the cached bulk map. +- Channel rows never send per-row EPG requests. The bulk EPG load is triggered + **eagerly when a category's channels first render** (a constructor effect in + `StalkerLiveStreamLayoutComponent` calls `ensureBulkItvEpg(168)` once ITV + channels are present) — not only after the first channel is played — so the + row "now playing" previews and the EPG panel populate immediately. Rows derive + their current program and progress bar from the cached bulk map. + - Effect ordering matters: the eager-EPG effect is registered **after** the + playlist-change effect that calls `clearBulkItvEpgCache()`. On a portal + switch the cache is cleared first and then refilled; if the order is + reversed the clear clobbers the just-loaded bulk EPG on initial render. + - `ensureBulkItvEpg` de-duplicates (via `isLoadingBulkItvEpg` / + `bulkItvEpgLoaded` + matching playlist/period), so the eager trigger and the + play-time `loadEpgForChannel` path never double-fetch. - If a portal does not return usable bulk data for the selected channel, the active panel falls back to `get_short_epg`. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 931a54610..c37c44d59 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -76,7 +76,8 @@ Important store responsibilities: - Selected content/category/item state - Category and paginated content resources -- ITV channel list + pagination +- ITV channel list + pagination (full-list session cache when the portal + supports it, legacy 14-per-page lazy loading otherwise) - Radio category/station list + pagination - Regular series seasons resource - VOD-series (`is_series=1`) seasons + episodes resources @@ -142,6 +143,9 @@ The Stalker live route and radio route intentionally share - `itv` uses `type=itv&action=get_ordered_list`, stores results in `itvChannels`, resolves playback through `resolveItvPlayback(...)`, and keeps the EPG panel visible. +- ITV additionally loads the COMPLETE channel list once per portal session (see + "Full ITV channel list cache" below), so category views and search are not + limited to the lazily loaded 14-item pages. - `radio` uses `type=radio&action=get_ordered_list`, stores results in `radioChannels`, resolves playback through `resolveRadioPlayback(...)`, and renders `AudioPlayerComponent` instead of a video player. @@ -157,6 +161,107 @@ The Stalker live route and radio route intentionally share falls back to a synthetic `PORTALS.ALL_RADIO` category with `category_id: '*'` so the station list can still be loaded. +## Full ITV Channel List Cache + +Stalker portals paginate `get_ordered_list` with a server-side page size +(typically 14 items), so lazy loading alone can never power a complete local +search — this used to limit ITV search to whatever pages the user had scrolled +through. `StalkerItvCacheService` +(`libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts`) fixes +this with a per-portal, in-memory session cache of the complete live channel +list: + +- Load strategy: first try the Ministra `get_all_channels` action (`type=itv`, + returns ALL channels in one response — the same call STB clients use); if + the portal does not implement it, crawl `get_ordered_list` pages + (`category=*`, `genre=*`, concurrency 4, one retry per page, early stop on + an empty page **or a page that adds no new channel ids** — some portals + ignore `p` and repeat — 30k-channel hard cap) with progress reporting. The + assembled list is de-duplicated by channel id (both strategies) so it never + collides with the template's `track item.id`. The loading strategy itself is + a stateless helper (`stalker-itv-channel-loader.ts`); the service owns state. +- Outcomes: a well-formed but unusable response marks the portal + `unsupported` for the session (legacy paged flow stays in charge); a + transient failure (network, or a page that failed both attempts) is retried + later but throttled by a per-portal cooldown (`ERROR_COOLDOWN_MS`, 30s) so a + deterministically-failing page can't trigger an unbounded re-crawl loop. +- Per-portal reactivity: the "cache ready / refreshed" trigger is a + **per-portal** version signal (`versionFor(playlist)`), not one global + counter, and the content resource reads it **only for ITV**. This is + load-bearing: a global counter re-fired the resource for whatever was on + screen (radio, another portal), and the legacy paged branch appends at + `pageIndex > 1`, so an unrelated load completing duplicated the visible page + (colliding `track item.id` → NG0955). The `isCurrentRequest` guard is scoped + the same way. +- Integration: the `getContentResource` loader in + `with-stalker-content.feature.ts` serves ITV categories from the cache when + ready (local `tv_genre_id` filtering via `filterItvChannelsByGenre`, + `hasMoreChannels=false`), and otherwise runs the legacy paged fetch while + `ensureLoaded()` fills the cache in the background; the resource re-fires + via the `cacheVersion` signal once the full list arrives. +- UI: `StalkerLiveStreamLayoutComponent` windows the rendered list + (100-item chunks extended by the existing scroll handler) so multi-thousand + channel lists do not blow up the DOM; the header count and search cover the + whole category; a refresh button re-loads the list in place; a progress line + shows crawl status. +- Loading state contract (important — regressions here strand the sidebar on a + skeleton): in full-list mode the content loader serves the filtered list + **synchronously** from the cache. The category-change reset effect therefore + must NOT `setItvChannels([])` while `itvFullListActive()` is true — it runs + after the store resource and would clobber the freshly served list, leaving + every category after the first stuck on a skeleton. The initial-loading + skeleton (`isInitialChannelsLoading`) must key off an actual in-flight load + (`itvFullListLoading()` or `isPaginatedContentLoading()`), not merely an empty + channel list; an empty result once loading has settled is an empty category + and renders `PORTALS.NO_CHANNELS_IN_CATEGORY`, not a spinner. +- Search: with the cache active, the header search spans the ENTIRE portal + (all genres) — filtering the store's `itvFullChannelList`, not just the + selected category — so searching "CNN" while a "Sports" genre is selected + still finds it; clearing the term returns to the selected category. The + workspace shell drops the `degraded-loaded-only` / "loaded only" status for + Stalker ITV once `itvFullListActive`; radio (no full-list cache) always keeps + the loaded-only hint (`workspace-shell-search.service.ts`). +- Windowed selection: remote channel-up/down and numeric select operate over + the full filtered category, so the render window (`renderLimit`) grows to + include a selection beyond it (`ensureChannelWithinRenderWindow`) instead of + drifting off-screen. +- Category count badges: the context panel shows per-genre channel counts on + Stalker **Live TV** categories (like Xtream/M3U), fed by the store computed + `itvCategoryItemCounts` (the full list grouped by numeric `tv_genre_id`; the + `'*'` "All" row's total is stored under the `NaN` key that + `Number('*')` produces). Badges are ITV-only — VOD/series/radio still page + lazily so their per-category totals are unknown — and show a loading shimmer + while the full list is still loading (`workspace-context-panel` → + `stalkerShowCounts` / `stalkerCountDisplayMode`). +- Censored (adult) genres: portals typically EXCLUDE these channels from + `get_all_channels` (sometimes without even flagging the genre `censored` in + `get_genres`), so the cache legitimately has zero channels for them. The + content loader therefore serves a genre from the cache only when the + genre-filtered result is non-empty; otherwise it falls back to the legacy + paged `get_ordered_list` fetch, which still returns those channels. The + store computed `itvSelectedCategoryFromCache` is the single source of truth + for this mode — the live layout keys windowing/infinite-scroll/`loadMore` + and the category-change reset off it, NOT off `itvFullListActive`. Count + badges: genres with no cached channels get NO map entry and the category + view omits their badge (`omitMissingCounts`) instead of showing a + misleading "0". The mock server ships a censored `For adults` ITV category + (id 1099) to exercise this path. +- Eager preload + all-channels view (Xtream parity): entering the Live TV + section immediately starts the full-list load (`preloadItvChannels()`, fired + from an effect in `StalkerLiveStreamLayoutComponent` — not from the first + category click), so the count badges and the all-channels view are available + right away. Before a category is selected, the main area shows + `StalkerItvAllItemsComponent` — a paginated card grid of every channel in + the portal (client-side pagination only; it must never touch the store's + legacy `page` state, which would re-fire portal requests). Clicking a card + runs the same `playChannel` flow as the sidebar. Portals without a usable + full list keep the "select a category" placeholder. +- Scope: ITV only. VOD/series keep server-side search; radio keeps legacy + paging (station lists are small). +- The stalker-mock-server implements `get_all_channels` and provides the + `legacy-pagination` scenario MAC (`00:1A:79:00:00:06`) to exercise the + crawl fallback. + ## VOD/Series Modes Stalker has multiple real-world data shapes. The current implementation supports all three: diff --git a/libs/portal/stalker/data-access/src/index.ts b/libs/portal/stalker/data-access/src/index.ts index b56ac9050..9ea6e474c 100644 --- a/libs/portal/stalker/data-access/src/index.ts +++ b/libs/portal/stalker/data-access/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/models'; export * from './lib/stores'; export * from './lib/stalker-content-types'; +export * from './lib/stalker-itv-cache.service'; export * from './lib/stalker-live-playback.utils'; export * from './lib/stalker-series.adapters'; export * from './lib/stalker-session.service'; diff --git a/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts b/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts index 5f7678593..98103990f 100644 --- a/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts +++ b/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts @@ -1,5 +1,11 @@ export interface StalkerCategoryItem { category_id: string; category_name: string; + /** + * Ministra "censored" genre flag (adult categories). Portals typically + * exclude these channels from `get_all_channels`, so censored categories + * are served via the legacy paged flow and get no count badge. + */ + censored?: boolean; [key: string]: unknown; } diff --git a/libs/portal/stalker/data-access/src/lib/models/stalker-itv-channel.interface.ts b/libs/portal/stalker/data-access/src/lib/models/stalker-itv-channel.interface.ts index 9fae5e652..18fc90dd7 100644 --- a/libs/portal/stalker/data-access/src/lib/models/stalker-itv-channel.interface.ts +++ b/libs/portal/stalker/data-access/src/lib/models/stalker-itv-channel.interface.ts @@ -10,4 +10,6 @@ export interface StalkerItvChannel extends StalkerVodSource { name?: string; o_name?: string; logo?: string; + tv_genre_id?: string | number; + number?: string | number; } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts new file mode 100644 index 000000000..ef647483a --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts @@ -0,0 +1,391 @@ +import { TestBed } from '@angular/core/testing'; +import { DataService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { StalkerItvCacheService } from './stalker-itv-cache.service'; +import { StalkerSessionService } from './stalker-session.service'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +const PLAYLIST = { + _id: 'playlist-1', + title: 'Demo Stalker', + count: 0, + autoRefresh: false, + importDate: '2026-04-14T00:00:00.000Z', + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:01', + isFullStalkerPortal: false, +} as PlaylistMeta; + +type StalkerParams = Record; + +interface RequestHandlers { + allChannels?: () => Promise | unknown; + page?: (page: number) => Promise | unknown; +} + +function channel(id: string, name: string, genreId: string) { + return { + id, + name, + cmd: `ffrt http://stream.example/${id}`, + tv_genre_id: genreId, + }; +} + +function pageOf(items: unknown[], totalItems: number, pageSize = 14) { + return { + js: { + data: items, + total_items: totalItems, + max_page_items: pageSize, + }, + }; +} + +const UNSUPPORTED_ACTION = { js: { error: 'Unknown action: get_all_channels' } }; + +async function flushMicrotasks(times = 5): Promise { + for (let index = 0; index < times; index += 1) { + await Promise.resolve(); + } +} + +describe('StalkerItvCacheService', () => { + let service: StalkerItvCacheService; + let sendIpcEvent: jest.Mock, unknown[]>; + + function mockRequests(handlers: RequestHandlers): void { + sendIpcEvent.mockImplementation( + async (_event: unknown, payload: unknown) => { + const params = (payload as { params: StalkerParams }).params; + if (params['action'] === 'get_all_channels') { + if (!handlers.allChannels) { + throw new Error('unexpected get_all_channels'); + } + return handlers.allChannels(); + } + if (params['action'] === 'get_ordered_list') { + if (!handlers.page) { + throw new Error('unexpected get_ordered_list'); + } + return handlers.page(Number(params['p'])); + } + throw new Error(`unexpected action ${params['action']}`); + } + ); + } + + function callsFor(action: string): number { + return sendIpcEvent.mock.calls.filter( + (call) => + (call[1] as { params: StalkerParams }).params['action'] === + action + ).length; + } + + beforeEach(() => { + sendIpcEvent = jest.fn(); + + TestBed.configureTestingModule({ + providers: [ + { provide: DataService, useValue: { sendIpcEvent } }, + { + provide: StalkerSessionService, + useValue: { makeAuthenticatedRequest: jest.fn() }, + }, + ], + }); + + service = TestBed.inject(StalkerItvCacheService); + }); + + it('loads the full list via get_all_channels in a single request', async () => { + mockRequests({ + allChannels: () => + pageOf( + [ + channel('1', 'News One', '5'), + // stream_id fallback used when id is missing + { + stream_id: '2', + name: 'Sports HD', + cmd: 'ffrt http://stream.example/2', + tv_genre_id: '9', + }, + ], + 2 + ), + }); + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.versionFor(PLAYLIST)).toBe(1); + expect(sendIpcEvent).toHaveBeenCalledTimes(1); + + const channels = service.getChannels(PLAYLIST); + expect(channels?.map((item) => item.id)).toEqual(['1', '2']); + expect(channels?.every((item) => typeof item.cmd === 'string')).toBe( + true + ); + }); + + it('falls back to crawling get_ordered_list pages when get_all_channels is unsupported', async () => { + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: (page) => + pageOf( + Array.from({ length: page === 2 ? 14 : 14 }, (_, index) => + channel( + `${page}-${index}`, + `Channel ${page}-${index}`, + '5' + ) + ), + 28 + ), + }); + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.getChannels(PLAYLIST)).toHaveLength(28); + expect(callsFor('get_all_channels')).toBe(1); + expect(callsFor('get_ordered_list')).toBe(2); + }); + + it('reports crawl progress while loading and clears it afterwards', async () => { + let resolveSecondPage!: (value: unknown) => void; + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: (page) => { + if (page === 1) { + return pageOf( + Array.from({ length: 14 }, (_, index) => + channel(`1-${index}`, `Channel ${index}`, '5') + ), + 28 + ); + } + return new Promise((resolve) => { + resolveSecondPage = resolve; + }); + }, + }); + + const load = service.ensureLoaded(PLAYLIST); + await flushMicrotasks(); + + expect(service.isLoading(PLAYLIST)).toBe(true); + expect(service.progressOf(PLAYLIST)).toEqual({ loaded: 14, total: 28 }); + + resolveSecondPage( + pageOf( + Array.from({ length: 14 }, (_, index) => + channel(`2-${index}`, `Channel ${index}`, '5') + ), + 28 + ) + ); + await load; + + expect(service.isLoading(PLAYLIST)).toBe(false); + expect(service.progressOf(PLAYLIST)).toBeNull(); + expect(service.getChannels(PLAYLIST)).toHaveLength(28); + }); + + it('stops the crawl early when the portal serves an empty page', async () => { + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: (page) => + page === 1 + ? pageOf( + Array.from({ length: 14 }, (_, index) => + channel(`1-${index}`, `Channel ${index}`, '5') + ), + // Portal claims more items than it can serve. + 280 + ) + : pageOf([], 280), + }); + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.getChannels(PLAYLIST)).toHaveLength(14); + }); + + it('memoizes unsupported portals and keeps the legacy flow in charge', async () => { + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: () => pageOf([], 0), + }); + + await service.ensureLoaded(PLAYLIST); + const callsAfterFirstAttempt = sendIpcEvent.mock.calls.length; + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(false); + expect(sendIpcEvent.mock.calls.length).toBe(callsAfterFirstAttempt); + }); + + it('throttles retries after a transient failure, then retries once the cooldown elapses', async () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000_000); + try { + mockRequests({ + allChannels: () => Promise.reject(new Error('network down')), + page: () => Promise.reject(new Error('network down')), + }); + + await service.ensureLoaded(PLAYLIST); + expect(service.isReady(PLAYLIST)).toBe(false); + const callsAfterError = sendIpcEvent.mock.calls.length; + + // Portal is reachable again, but within the cooldown window the + // whole portal must NOT be re-crawled (prevents a hammering loop + // when a page fails deterministically). + mockRequests({ + allChannels: () => pageOf([channel('1', 'News One', '5')], 1), + }); + await service.ensureLoaded(PLAYLIST); + expect(sendIpcEvent.mock.calls.length).toBe(callsAfterError); + expect(service.isReady(PLAYLIST)).toBe(false); + + // After the cooldown elapses the next visit retries. + nowSpy.mockReturnValue(1_000_000 + 31_000); + await service.ensureLoaded(PLAYLIST); + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.getChannels(PLAYLIST)).toHaveLength(1); + } finally { + nowSpy.mockRestore(); + } + }); + + it('a refresh bypasses the error cooldown', async () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(2_000_000); + try { + mockRequests({ + allChannels: () => Promise.reject(new Error('network down')), + page: () => Promise.reject(new Error('network down')), + }); + await service.ensureLoaded(PLAYLIST); + expect(service.isReady(PLAYLIST)).toBe(false); + + mockRequests({ + allChannels: () => pageOf([channel('1', 'News One', '5')], 1), + }); + // Same instant — cooldown still active — but an explicit refresh + // (e.g. the refresh button) must retry immediately. + await service.refresh(PLAYLIST); + expect(service.isReady(PLAYLIST)).toBe(true); + } finally { + nowSpy.mockRestore(); + } + }); + + it('deduplicates channels returned across crawl pages (portal ignoring the page param)', async () => { + const samePage = () => + pageOf( + [ + channel('1', 'News One', '5'), + channel('2', 'Sports HD', '9'), + ], + // Portal claims many items but returns the same two regardless + // of `p`. + 280 + ); + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: () => samePage(), + }); + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.getChannels(PLAYLIST)).toHaveLength(2); + }); + + it('deduplicates channels returned by get_all_channels', async () => { + mockRequests({ + allChannels: () => + pageOf( + [ + channel('1', 'News One', '5'), + channel('1', 'News One dup', '5'), + channel('2', 'Sports HD', '9'), + ], + 3 + ), + }); + + await service.ensureLoaded(PLAYLIST); + + expect( + service.getChannels(PLAYLIST)?.map((c) => c.id) + ).toEqual(['1', '2']); + }); + + it('deduplicates concurrent load requests', async () => { + let resolveAllChannels!: (value: unknown) => void; + mockRequests({ + allChannels: () => + new Promise((resolve) => { + resolveAllChannels = resolve; + }), + }); + + const first = service.ensureLoaded(PLAYLIST); + const second = service.ensureLoaded(PLAYLIST); + await flushMicrotasks(); + + resolveAllChannels(pageOf([channel('1', 'News One', '5')], 1)); + await Promise.all([first, second]); + + expect(callsFor('get_all_channels')).toBe(1); + expect(service.isReady(PLAYLIST)).toBe(true); + }); + + it('keeps serving the previous list while a refresh is in flight', async () => { + mockRequests({ + allChannels: () => pageOf([channel('1', 'News One', '5')], 1), + }); + await service.ensureLoaded(PLAYLIST); + + let resolveRefresh!: (value: unknown) => void; + mockRequests({ + allChannels: () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + }); + + const refresh = service.refresh(PLAYLIST); + await flushMicrotasks(); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.getChannels(PLAYLIST)?.[0]?.name).toBe('News One'); + + resolveRefresh( + pageOf( + [ + channel('1', 'News One', '5'), + channel('2', 'Fresh Channel', '9'), + ], + 2 + ) + ); + await refresh; + + expect(service.getChannels(PLAYLIST)).toHaveLength(2); + expect(service.versionFor(PLAYLIST)).toBe(2); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts new file mode 100644 index 000000000..f16bcbee5 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts @@ -0,0 +1,223 @@ +import { Injectable, WritableSignal, inject, signal } from '@angular/core'; +import { createLogger } from '@iptvnator/portal/shared/util'; +import { DataService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { StalkerItvChannel } from './models'; +import { + StalkerItvLoadProgress, + loadFullItvChannelList, +} from './stalker-itv-channel-loader'; +import { StalkerSessionService } from './stalker-session.service'; +import { StalkerRequestDeps } from './stores/utils'; + +export type { StalkerItvLoadProgress }; + +/** + * After a transient crawl failure, don't immediately re-crawl the whole portal + * on the next resource fire — a deterministically-failing page would otherwise + * trigger an unbounded re-crawl loop that hammers the portal. + */ +const ERROR_COOLDOWN_MS = 30_000; + +/** + * Session cache holding the complete ITV channel list per Stalker portal. + * + * Stalker portals paginate `get_ordered_list` with a server-side page size + * (typically 14), so incremental loading can never power a complete local + * search. This service loads the full list once per portal: it first tries the + * Ministra `get_all_channels` action (all channels in a single response) and + * falls back to crawling `get_ordered_list` pages. Portals where both + * strategies fail are marked unsupported and keep the legacy paged behavior. + */ +@Injectable({ providedIn: 'root' }) +export class StalkerItvCacheService { + private readonly logger = createLogger('StalkerItvCache'); + private readonly requestDeps: StalkerRequestDeps = { + dataService: inject(DataService), + stalkerSession: inject(StalkerSessionService), + }; + + /** Portal keys whose full channel list is loaded. */ + private readonly readyKeys = signal>(new Set()); + private readonly loadingKeys = signal>(new Set()); + private readonly progressByKey = signal< + Readonly> + >({}); + /** + * Per-portal version counters. Kept as independent signals (not one shared + * counter) so a resource that reads `versionFor(portal)` only re-fires when + * THAT portal's list becomes ready or is refreshed — a different portal's + * background load completing must never re-fire an unrelated resource. + */ + private readonly versionSignals = new Map>(); + + private readonly channelsByKey = new Map(); + private readonly unsupportedKeys = new Set(); + private readonly inflight = new Map>(); + /** Wall-clock (ms) until which a portal's crawl error suppresses retries. */ + private readonly errorCooldownUntil = new Map(); + + /** + * Reactive, per-portal readiness token. Changes only when this portal's + * full channel list becomes ready or is refreshed. Resources that serve + * from the cache read this to re-fire at the right moment without being + * disturbed by other portals' loads. + */ + versionFor(playlist: PlaylistMeta | undefined): number { + const key = this.keyFor(playlist); + return key === null ? 0 : this.versionSignalFor(key)(); + } + + isReady(playlist: PlaylistMeta | undefined): boolean { + const key = this.keyFor(playlist); + return key !== null && this.readyKeys().has(key); + } + + isLoading(playlist: PlaylistMeta | undefined): boolean { + const key = this.keyFor(playlist); + return key !== null && this.loadingKeys().has(key); + } + + progressOf( + playlist: PlaylistMeta | undefined + ): StalkerItvLoadProgress | null { + const key = this.keyFor(playlist); + return key !== null ? (this.progressByKey()[key] ?? null) : null; + } + + /** + * Full channel list for the portal, or null while not (yet) loaded. + * Deliberately NON-reactive: it reads only the plain `channelsByKey` map + * (an entry exists iff a load succeeded), so computeds that call it don't + * pick up the shared `readyKeys` signal and re-evaluate when an UNRELATED + * portal's list becomes ready. Reactive consumers establish their own + * per-portal dependency via {@link versionFor}; boolean readiness for UI + * stays on the reactive {@link isReady}. + */ + getChannels(playlist: PlaylistMeta | undefined): StalkerItvChannel[] | null { + const key = this.keyFor(playlist); + return key === null ? null : (this.channelsByKey.get(key) ?? null); + } + + /** + * Loads the full channel list once per portal. No-op when the list is + * already loaded, currently loading, or the portal is unsupported. + */ + async ensureLoaded(playlist: PlaylistMeta | undefined): Promise { + const key = this.keyFor(playlist); + if ( + key === null || + !playlist || + this.readyKeys().has(key) || + this.unsupportedKeys.has(key) || + this.isInErrorCooldown(key) + ) { + return; + } + + await (this.inflight.get(key) ?? this.runLoad(key, playlist)); + } + + /** Reloads the list while keeping the current cache served in the meantime. */ + async refresh(playlist: PlaylistMeta | undefined): Promise { + const key = this.keyFor(playlist); + if (key === null || !playlist) { + return; + } + + const pending = this.inflight.get(key); + if (pending) { + return pending; + } + + this.unsupportedKeys.delete(key); + this.errorCooldownUntil.delete(key); + await this.runLoad(key, playlist); + } + + private keyFor(playlist: PlaylistMeta | undefined): string | null { + return playlist?._id ?? playlist?.portalUrl ?? null; + } + + private versionSignalFor(key: string): WritableSignal { + let versionSignal = this.versionSignals.get(key); + if (!versionSignal) { + versionSignal = signal(0); + this.versionSignals.set(key, versionSignal); + } + return versionSignal; + } + + private isInErrorCooldown(key: string): boolean { + const until = this.errorCooldownUntil.get(key); + if (until === undefined) { + return false; + } + if (Date.now() >= until) { + this.errorCooldownUntil.delete(key); + return false; + } + return true; + } + + private runLoad(key: string, playlist: PlaylistMeta): Promise { + const task = (async () => { + this.patchKeySet(this.loadingKeys, key, true); + try { + const outcome = await loadFullItvChannelList( + this.requestDeps, + playlist, + (loaded, total) => this.reportProgress(key, loaded, total), + this.logger + ); + if (Array.isArray(outcome)) { + this.channelsByKey.set(key, outcome); + this.errorCooldownUntil.delete(key); + this.patchKeySet(this.readyKeys, key, true); + this.versionSignalFor(key).update((version) => version + 1); + } else if (outcome === 'error') { + this.errorCooldownUntil.set( + key, + Date.now() + ERROR_COOLDOWN_MS + ); + } else if (!this.readyKeys().has(key)) { + this.unsupportedKeys.add(key); + } + } finally { + this.inflight.delete(key); + this.patchKeySet(this.loadingKeys, key, false); + this.progressByKey.update((progress) => { + const next = { ...progress }; + delete next[key]; + return next; + }); + } + })(); + + this.inflight.set(key, task); + return task; + } + + private reportProgress(key: string, loaded: number, total: number): void { + this.progressByKey.update((progress) => ({ + ...progress, + [key]: { loaded, total }, + })); + } + + private patchKeySet( + target: ReturnType>>, + key: string, + present: boolean + ): void { + target.update((current) => { + const next = new Set(current); + if (present) { + next.add(key); + } else { + next.delete(key); + } + return next; + }); + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts new file mode 100644 index 000000000..b90efc694 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts @@ -0,0 +1,283 @@ +import { + PlaylistMeta, + StalkerPortalActions, +} from '@iptvnator/shared/interfaces'; +import { StalkerItvChannel } from './models'; +import { + StalkerRequestDeps, + executeStalkerRequest, + toStalkerContentItem, + toStalkerItvChannel, +} from './stores/utils'; + +export interface StalkerItvLoadProgress { + loaded: number; + total: number; +} + +/** + * 'unsupported' — the portal answered but cannot serve a full list; the caller + * should keep the legacy paged flow. 'error' — a transient failure (network, + * timeout, a page that failed both attempts) that is worth retrying later. + */ +export type StalkerItvLoadOutcome = + | StalkerItvChannel[] + | 'unsupported' + | 'error'; + +export interface StalkerItvLoadLogger { + info(...args: unknown[]): void; + warn(...args: unknown[]): void; +} + +interface StalkerChannelListResponse { + js?: { + data?: unknown; + total_items?: number | string; + max_page_items?: number | string; + }; +} + +const FALLBACK_PAGE_SIZE = 14; +const CRAWL_CONCURRENCY = 4; +/** Hard cap so a misbehaving portal cannot make the crawl run forever. */ +const MAX_CHANNELS = 30_000; + +function toPositiveInt(value: unknown): number | null { + const parsed = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +/** + * Loads the complete ITV channel list for a portal. First tries the Ministra + * `get_all_channels` action (all channels in one response — the same call STB + * clients use); if that isn't usable, crawls `get_ordered_list` pages. Stateless + * — the {@link StalkerItvCacheService} owns caching, progress state, and retry + * throttling. + */ +export async function loadFullItvChannelList( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + onProgress: (loaded: number, total: number) => void, + logger: StalkerItvLoadLogger +): Promise { + const viaAllChannels = await tryGetAllChannels(deps, playlist, logger); + if (Array.isArray(viaAllChannels)) { + return viaAllChannels; + } + + const crawled = await crawlOrderedPages(deps, playlist, onProgress, logger); + if (Array.isArray(crawled)) { + return crawled; + } + + return viaAllChannels === 'error' || crawled === 'error' + ? 'error' + : 'unsupported'; +} + +async function tryGetAllChannels( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + logger: StalkerItvLoadLogger +): Promise { + try { + const response = + await executeStalkerRequest( + deps, + playlist, + { + action: StalkerPortalActions.GetAllChannels, + type: 'itv', + } + ); + const data = response?.js?.data; + if (!Array.isArray(data) || data.length === 0) { + logger.info( + 'get_all_channels not usable, falling back to page crawl' + ); + return 'unsupported'; + } + + return mapChannels(data, playlist); + } catch (error) { + logger.warn('get_all_channels failed', error); + return 'error'; + } +} + +async function crawlOrderedPages( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + onProgress: (loaded: number, total: number) => void, + logger: StalkerItvLoadLogger +): Promise { + const firstResponse = await fetchOrderedPageWithRetry(deps, playlist, 1, logger); + if (firstResponse === null) { + return 'error'; + } + + const firstPage = Array.isArray(firstResponse.js?.data) + ? firstResponse.js.data + : null; + if (firstPage === null || firstPage.length === 0) { + return 'unsupported'; + } + + const totalItems = Math.min( + toPositiveInt(firstResponse?.js?.total_items) ?? firstPage.length, + MAX_CHANNELS + ); + const pageSize = + toPositiveInt(firstResponse?.js?.max_page_items) ?? + (firstPage.length || FALLBACK_PAGE_SIZE); + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + + const items: unknown[] = []; + const seen = new Set(); + collectUnique(firstPage, items, seen); + onProgress(items.length, totalItems); + + for ( + let page = 2; + page <= totalPages && items.length < totalItems; + page += CRAWL_CONCURRENCY + ) { + const batch: number[] = []; + for ( + let next = page; + next < page + CRAWL_CONCURRENCY && next <= totalPages; + next++ + ) { + batch.push(next); + } + + const results = await Promise.all( + batch.map((pageNumber) => + fetchOrderedPageWithRetry(deps, playlist, pageNumber, logger) + ) + ); + + let reachedEnd = false; + for (const pageResponse of results) { + const pageItems = Array.isArray(pageResponse?.js?.data) + ? pageResponse.js.data + : null; + if (pageItems === null) { + logger.warn('Aborting ITV page crawl after failures'); + return 'error'; + } + const added = collectUnique(pageItems, items, seen); + if (added === 0) { + // Empty page, or a portal that ignores `p` and keeps returning + // already-seen channels — treat the collected list as complete + // instead of duplicating rows forever. + reachedEnd = true; + break; + } + } + + onProgress(Math.min(items.length, totalItems), totalItems); + if (reachedEnd) { + break; + } + } + + return mapChannels(items, playlist); +} + +/** + * Appends only channels whose id hasn't been seen yet. Returns how many new + * channels were added so the crawl can stop when a page contributes nothing new. + */ +function collectUnique( + rawItems: unknown[], + target: unknown[], + seen: Set +): number { + let added = 0; + for (const item of rawItems) { + const id = rawChannelId(item); + if (id === null || seen.has(id)) { + continue; + } + seen.add(id); + target.push(item); + added += 1; + } + return added; +} + +function rawChannelId(item: unknown): string | null { + if (!item || typeof item !== 'object') { + return null; + } + const source = item as { id?: unknown; stream_id?: unknown }; + const raw = source.id ?? source.stream_id; + return raw === undefined || raw === null || raw === '' ? null : String(raw); +} + +async function fetchOrderedPageWithRetry( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + page: number, + logger: StalkerItvLoadLogger +): Promise { + const firstAttempt = await fetchOrderedPage(deps, playlist, page, logger); + if (firstAttempt !== null) { + return firstAttempt; + } + return fetchOrderedPage(deps, playlist, page, logger); +} + +async function fetchOrderedPage( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + page: number, + logger: StalkerItvLoadLogger +): Promise { + try { + return await executeStalkerRequest( + deps, + playlist, + { + action: StalkerPortalActions.GetOrderedList, + type: 'itv', + category: '*', + genre: '*', + sortby: 'number', + p: page, + } + ); + } catch (error) { + logger.warn(`Failed to fetch ITV page ${page}`, error); + return null; + } +} + +function mapChannels( + items: unknown[], + playlist: PlaylistMeta +): StalkerItvChannel[] { + const mapped: StalkerItvChannel[] = []; + const seen = new Set(); + for (const item of items) { + const channel = toStalkerItvChannel( + toStalkerContentItem( + item as Parameters[0], + playlist.portalUrl ?? '' + ) + ); + // Duplicate channel ids collide with the template's `track item.id` + // (get_all_channels can repeat, and some crawl paths overlap). + const id = String(channel.id ?? ''); + if (id !== '' && seen.has(id)) { + continue; + } + if (id !== '') { + seen.add(id); + } + mapped.push(channel); + } + return mapped; +} diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index 39af80336..00ab9e8da 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -1,8 +1,11 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { TranslateService } from '@ngx-translate/core'; import { DataService } from '@iptvnator/services'; import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces'; +import { StalkerItvChannel } from '../../models'; +import { StalkerItvCacheService } from '../../stalker-itv-cache.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { withStalkerContent } from './with-stalker-content.feature'; @@ -101,6 +104,29 @@ async function waitForCondition( throw new Error('Timed out waiting for resource activity'); } +/** + * Controllable stand-in for StalkerItvCacheService so the legacy paged flow + * can be tested in isolation and the cache-served flow deterministically. + */ +function createItvCacheMock(initialChannels: StalkerItvChannel[] | null = null) { + const version = signal(0); + let channels = initialChannels; + + return { + versionFor: jest.fn(() => version()), + getChannels: jest.fn(() => channels), + ensureLoaded: jest.fn().mockResolvedValue(undefined), + refresh: jest.fn().mockResolvedValue(undefined), + isReady: jest.fn(() => channels !== null), + isLoading: jest.fn(() => false), + progressOf: jest.fn(() => null), + setChannels(next: StalkerItvChannel[] | null) { + channels = next; + version.update((value) => value + 1); + }, + }; +} + describe('withStalkerContent failure states', () => { let store: InstanceType; let dataService: { @@ -116,6 +142,10 @@ describe('withStalkerContent failure states', () => { providers: [ TestContentStore, { provide: DataService, useValue: dataService }, + { + provide: StalkerItvCacheService, + useValue: createItvCacheMock(), + }, { provide: StalkerSessionService, useValue: { @@ -461,3 +491,266 @@ describe('withStalkerContent failure states', () => { expect(store.getPaginatedContent()[0]?.name).toBe('Page 1 restored'); }); }); + +describe('withStalkerContent full ITV channel list cache', () => { + const CACHED_CHANNELS: StalkerItvChannel[] = [ + { id: '1', cmd: 'ffrt http://x/1', name: 'News One', tv_genre_id: '5' }, + { id: '2', cmd: 'ffrt http://x/2', name: 'Sports HD', tv_genre_id: '9' }, + { id: '3', cmd: 'ffrt http://x/3', name: 'News Two', tv_genre_id: '5' }, + ]; + + let store: InstanceType; + let dataService: { + sendIpcEvent: jest.Mock, unknown[]>; + }; + let itvCache: ReturnType; + + function setup(initialChannels: StalkerItvChannel[] | null) { + dataService = { + sendIpcEvent: jest.fn(), + }; + itvCache = createItvCacheMock(initialChannels); + + TestBed.configureTestingModule({ + providers: [ + TestContentStore, + { provide: DataService, useValue: dataService }, + { provide: StalkerItvCacheService, useValue: itvCache }, + { + provide: StalkerSessionService, + useValue: { + makeAuthenticatedRequest: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((key: string) => key), + }, + }, + ], + }); + + store = TestBed.inject(TestContentStore); + } + + function enterItvCategory(categoryId: string) { + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '5', category_name: 'News' }, + { category_id: '9', category_name: 'Sports' }, + ]); + store.setSelectedCategory(categoryId); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + } + + it('serves the whole category from the cache without portal requests', async () => { + setup(CACHED_CHANNELS); + enterItvCategory('5'); + + await waitForCondition(() => store.itvChannels().length === 2); + + expect(store.itvChannels().map((channel) => channel.name)).toEqual([ + 'News One', + 'News Two', + ]); + expect(store.totalCount()).toBe(2); + expect(store.hasMoreChannels()).toBe(false); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('serves all channels for the "*" category so search can cover everything', async () => { + setup(CACHED_CHANNELS); + enterItvCategory('*'); + + await waitForCondition(() => store.itvChannels().length === 3); + + // Regression for the "search only finds the first 14 loaded items" + // bug: the full list is available at once, no paging required. + expect(store.itvChannels()).toHaveLength(3); + expect(store.hasMoreChannels()).toBe(false); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('falls back to the legacy paged fetch for a genre absent from the cache (censored/adult)', async () => { + setup(CACHED_CHANNELS); + dataService.sendIpcEvent.mockResolvedValue({ + js: { + data: [ + { + id: 'adult-1', + name: 'Adult One', + cmd: 'ffrt http://x/adult-1', + }, + ], + total_items: 40, + }, + }); + + // Genre '19' has no channels in the cached full list. + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '5', category_name: 'News' }, + { category_id: '19', category_name: 'For adults', censored: true }, + ]); + store.setSelectedCategory('19'); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + + await waitForCondition(() => store.itvChannels().length === 1); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.GetOrderedList, + type: 'itv', + genre: '19', + p: 1, + }), + }) + ); + expect(store.itvChannels()[0]?.name).toBe('Adult One'); + // Portal pagination stays in charge for this genre. + expect(store.hasMoreChannels()).toBe(true); + }); + + it('reports whether the selected category is served from the cache', () => { + setup(CACHED_CHANNELS); + enterItvCategory('5'); + expect(store.itvSelectedCategoryFromCache()).toBe(true); + + store.setSelectedCategory('*'); + expect(store.itvSelectedCategoryFromCache()).toBe(true); + + // Genre with zero cached channels — e.g. a censored/adult category. + store.setSelectedCategory('19'); + expect(store.itvSelectedCategoryFromCache()).toBe(false); + }); + + it('omits genres without cached channels from the count map (adult genres)', () => { + setup(CACHED_CHANNELS); + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '*', category_name: 'All' }, + { category_id: '5', category_name: 'News' }, + { category_id: '19', category_name: 'For adults', censored: true }, + ]); + store.setCurrentPlaylist(PLAYLIST); + + const counts = store.itvCategoryItemCounts(); + expect(counts.get(5)).toBe(2); + // Adult genres are excluded from get_all_channels — regardless of any + // `censored` flag — so their count is unknown: no entry, no badge. + expect(counts.has(19)).toBe(false); + }); + + it('preloadItvChannels kicks off the cache load for the current playlist', () => { + setup(null); + store.setCurrentPlaylist(PLAYLIST); + + store.preloadItvChannels(); + + expect(itvCache.ensureLoaded).toHaveBeenCalledWith(PLAYLIST); + }); + + it('exposes per-genre channel counts for category badges (with the total under the "All" key)', () => { + setup(CACHED_CHANNELS); + enterItvCategory('*'); + + const counts = store.itvCategoryItemCounts(); + // Two channels in genre 5, one in genre 9. + expect(counts.get(5)).toBe(2); + expect(counts.get(9)).toBe(1); + // "All" category is category_id '*' → Number('*') is NaN; the grand + // total lives under the NaN key so the "All" row shows every channel. + expect(counts.get(Number.NaN)).toBe(3); + }); + + it('has an empty count map when no full list is cached', () => { + setup(null); + enterItvCategory('*'); + + const counts = store.itvCategoryItemCounts(); + // No genre entries at all — counts are unknown without the cache. + expect(counts.get(5)).toBeUndefined(); + expect(counts.get(Number.NaN)).toBe(0); + }); + + it('drops a stale legacy page response that resolves after the cache took over', async () => { + setup(null); + const legacyResponse = createDeferred(); + dataService.sendIpcEvent.mockReturnValue(legacyResponse.promise); + + enterItvCategory('5'); + + await waitForCondition( + () => dataService.sendIpcEvent.mock.calls.length > 0 + ); + + // The background full-list load finishes while the legacy page-1 + // request is still in flight; the resource re-fires and serves the + // complete category from the cache. + itvCache.setChannels(CACHED_CHANNELS); + await waitForCondition(() => store.itvChannels().length === 2); + + // The late legacy response must not overwrite the full list with the + // first 14-item page again. + legacyResponse.resolve({ + js: { + data: [ + { + id: 'legacy-1', + name: 'Legacy page one', + cmd: 'ffrt http://x/legacy', + }, + ], + total_items: 28, + }, + }); + await flushResources(); + + expect(store.itvChannels().map((channel) => channel.name)).toEqual([ + 'News One', + 'News Two', + ]); + expect(store.hasMoreChannels()).toBe(false); + }); + + it('kicks off a background full-list load and swaps it in once ready', async () => { + setup(null); + dataService.sendIpcEvent.mockResolvedValue({ + js: { + data: [ + { + id: 'legacy-1', + name: 'Legacy page one', + cmd: 'ffrt http://x/legacy', + }, + ], + total_items: 28, + }, + }); + + enterItvCategory('5'); + + await waitForCondition(() => store.itvChannels().length === 1); + + // Legacy paged flow stays in charge while the cache is cold. + expect(store.itvChannels()[0]?.name).toBe('Legacy page one'); + expect(store.hasMoreChannels()).toBe(true); + expect(itvCache.ensureLoaded).toHaveBeenCalledWith(PLAYLIST); + + // Full list finished loading in the background. + itvCache.setChannels(CACHED_CHANNELS); + + await waitForCondition(() => store.itvChannels().length === 2); + + expect(store.itvChannels().map((channel) => channel.name)).toEqual([ + 'News One', + 'News Two', + ]); + expect(store.hasMoreChannels()).toBe(false); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index d4b3bffe4..567152ec3 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -17,6 +17,7 @@ import { StalkerVodSource, } from '../../models'; import { StalkerContentTypes } from '../../stalker-content-types'; +import { StalkerItvCacheService } from '../../stalker-itv-cache.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { ResourceState, @@ -26,6 +27,7 @@ import { } from '../stalker-store.contracts'; import { executeStalkerRequest, + filterItvChannelsByGenre, toStalkerContentItem, toStalkerItvChannel, } from '../utils'; @@ -64,6 +66,7 @@ const initialContentState: StalkerContentState = { interface StalkerCategoryResponseItem { id?: string | number; title?: string; + censored?: string | number; } interface StalkerCategoryResponse { @@ -191,7 +194,8 @@ export function withStalkerContent() { store, dataService = inject(DataService), stalkerSession = inject(StalkerSessionService), - translateService = inject(TranslateService) + translateService = inject(TranslateService), + itvCache = inject(StalkerItvCacheService) ) => { const storeContext = store as typeof store & StalkerContentResourceStoreContract; @@ -269,6 +273,9 @@ export function withStalkerContent() { (item): StalkerCategoryItem => ({ category_name: item.title ?? '', category_id: String(item.id), + censored: + item.censored === 1 || + item.censored === '1', }) ); const categories = prependAllCategory( @@ -325,6 +332,17 @@ export function withStalkerContent() { search: storeContext.searchPhrase(), pageIndex: storeContext.page() + 1, currentPlaylist: storeContext.currentPlaylist(), + // Re-fires the loader once THIS portal's full ITV + // channel list finishes loading or is refreshed. + // Read only for ITV and scoped per-portal so a + // different portal's (or a radio/vod) load never + // re-fires and re-appends this resource's page. + itvCacheVersion: + storeContext.selectedContentType() === 'itv' + ? itvCache.versionFor( + storeContext.currentPlaylist() + ) + : 0, availableCategoryCount: getCategoriesByType( store, storeContext.selectedContentType() @@ -376,6 +394,45 @@ export function withStalkerContent() { } const categoryParam = params.category || '*'; + + if (params.contentType === 'itv') { + const cachedChannels = + itvCache.getChannels(playlist); + const channels = + cachedChannels !== null + ? filterItvChannelsByGenre( + cachedChannels, + categoryParam + ) + : null; + // Serve from the cache only when it actually + // has channels for this genre. Censored (adult) + // genres are typically EXCLUDED from + // get_all_channels by the portal, so an empty + // filter result falls through to the legacy + // paged fetch, which still returns them. + if ( + channels !== null && + (categoryParam === '*' || + channels.length > 0) + ) { + patchState(store, { + totalCount: channels.length, + paginatedContent: channels, + itvChannels: channels, + hasMoreChannels: false, + contentError: null, + }); + return channels; + } + + // Full-list load runs in the background; the + // resource re-fires via `itvCacheVersion` once + // the cache is ready. Until then the legacy + // paged flow below serves the first pages. + void itvCache.ensureLoaded(playlist); + } + const paramsPlaylistKey = params.currentPlaylist?._id ?? params.currentPlaylist?.portalUrl ?? @@ -397,7 +454,18 @@ export function withStalkerContent() { storeContext.searchPhrase() && params.pageIndex === storeContext.page() + 1 && - paramsPlaylistKey === currentPlaylistKey + paramsPlaylistKey === currentPlaylistKey && + // A legacy paged response must not overwrite + // the full cached list that a re-fired + // loader served in the meantime. Scoped + // per-portal and to ITV so another portal's + // load never invalidates this response. + params.itvCacheVersion === + (params.contentType === 'itv' + ? itvCache.versionFor( + currentPlaylist + ) + : 0) ); }; const queryParams: Record = @@ -535,8 +603,94 @@ export function withStalkerContent() { withComputed((store) => { const storeContext = store as typeof store & StalkerContentResourceStoreContract; + const itvCache = inject(StalkerItvCacheService); + + /** + * The whole portal's ITV channel list (all categories) when + * cached. `versionFor` establishes the reactive dependency so this + * recomputes when the list becomes ready or is refreshed. + */ + const itvFullChannelList = computed(() => { + const playlist = storeContext.currentPlaylist(); + itvCache.versionFor(playlist); + return itvCache.getChannels(playlist) ?? []; + }); + + /** + * Per-genre channel counts for ITV category badges, keyed by + * numeric `tv_genre_id` (mirrors the Xtream count map). Only + * populated when the full list is cached. The "All" pseudo + * category has id `'*'` → `Number('*')` is NaN, and a Map keys + * NaN by SameValueZero, so the grand total under `NaN` makes the + * "All" row show every channel. Genres with NO cached channels + * get no entry at all: adult genres are excluded from + * `get_all_channels` (with or without a `censored` flag from + * `get_genres`), so their real count is unknown and the badge + * is omitted rather than showing a misleading "0". + */ + const itvCategoryItemCounts = computed(() => { + const counts = new Map(); + const channels = itvFullChannelList(); + for (const channel of channels) { + const genreId = Number(channel.tv_genre_id); + if (!Number.isNaN(genreId)) { + counts.set(genreId, (counts.get(genreId) ?? 0) + 1); + } + } + counts.set(Number.NaN, channels.length); + return counts; + }); return { + /** True when the complete ITV channel list is cached, so local search covers all channels. */ + itvFullListActive: computed(() => + itvCache.isReady(storeContext.currentPlaylist()) + ), + itvFullListLoading: computed(() => + itvCache.isLoading(storeContext.currentPlaylist()) + ), + itvFullListProgress: computed(() => + itvCache.progressOf(storeContext.currentPlaylist()) + ), + /** Exposes the whole portal's ITV channel list so search can span every channel. */ + itvFullChannelList, + /** + * True when the currently selected ITV category can be served + * from the full-list cache. Censored (adult) genres are usually + * excluded from `get_all_channels`, so a genre with zero cached + * channels stays on the legacy paged flow. O(1): reuses the + * memoized per-genre count map instead of re-scanning the list + * on every category click. + */ + itvSelectedCategoryFromCache: computed(() => { + const playlist = storeContext.currentPlaylist(); + if (!itvCache.isReady(playlist)) { + return false; + } + + const categoryId = storeContext.selectedCategoryId(); + if (!categoryId) { + return false; + } + if (categoryId === '*') { + return true; + } + + const genreId = Number(categoryId); + if (Number.isNaN(genreId)) { + // Non-numeric genre id would collide with the NaN + // "All" total key — fall back to the direct scan. + return ( + filterItvChannelsByGenre( + itvFullChannelList(), + categoryId + ).length > 0 + ); + } + + return (itvCategoryItemCounts().get(genreId) ?? 0) > 0; + }), + itvCategoryItemCounts, getTotalPages: computed(() => Math.ceil(store.totalCount() / storeContext.limit()) ), @@ -600,7 +754,25 @@ export function withStalkerContent() { isCategoryResourceFailed: computed(() => store.categoryError()), }; }), - withMethods((store) => ({ + withMethods((store) => { + const storeContext = store as typeof store & + StalkerContentResourceStoreContract; + const itvCache = inject(StalkerItvCacheService); + + return { + /** + * Kicks off the full ITV channel list load as soon as the Live TV + * section is entered (instead of waiting for the first category + * click), so the all-channels view and category count badges are + * available immediately. Safe to call repeatedly — the cache + * de-duplicates in-flight loads and memoizes unsupported portals. + */ + preloadItvChannels(): void { + void itvCache.ensureLoaded(storeContext.currentPlaylist()); + }, + async refreshItvChannels(): Promise { + await itvCache.refresh(storeContext.currentPlaylist()); + }, setCategories( type: StalkerContentType, categories: StalkerCategoryItem[] @@ -622,6 +794,7 @@ export function withStalkerContent() { setRadioChannels(channels: StalkerItvChannel[]) { patchState(store, { radioChannels: channels }); }, - })) + }; + }) ); } diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts new file mode 100644 index 000000000..3533e68ec --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts @@ -0,0 +1,32 @@ +import { StalkerItvChannel } from '../../models'; +import { filterItvChannelsByGenre } from './stalker-content-mappers'; + +describe('filterItvChannelsByGenre', () => { + const CHANNELS: StalkerItvChannel[] = [ + { id: '1', cmd: 'ffrt http://x/1', name: 'News One', tv_genre_id: '5' }, + { id: '2', cmd: 'ffrt http://x/2', name: 'Sports HD', tv_genre_id: 9 }, + { id: '3', cmd: 'ffrt http://x/3', name: 'No Genre' }, + ]; + + it('returns all channels for the "*" category', () => { + expect(filterItvChannelsByGenre(CHANNELS, '*')).toHaveLength(3); + }); + + it('returns all channels when no category is selected', () => { + expect(filterItvChannelsByGenre(CHANNELS, null)).toHaveLength(3); + expect(filterItvChannelsByGenre(CHANNELS, undefined)).toHaveLength(3); + }); + + it('matches numeric and string genre ids', () => { + expect( + filterItvChannelsByGenre(CHANNELS, '5').map((item) => item.id) + ).toEqual(['1']); + expect( + filterItvChannelsByGenre(CHANNELS, '9').map((item) => item.id) + ).toEqual(['2']); + }); + + it('hides channels without a genre from specific categories', () => { + expect(filterItvChannelsByGenre(CHANNELS, '404')).toEqual([]); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts index 937ce5920..359dcd5d1 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts @@ -61,6 +61,24 @@ export function toStalkerContentItem( }; } +/** + * Local category filter for fully cached ITV channel lists. + * Mirrors the portal-side `genre=` filter of `get_ordered_list`. + */ +export function filterItvChannelsByGenre( + channels: StalkerItvChannel[], + categoryId: string | null | undefined +): StalkerItvChannel[] { + if (!categoryId || categoryId === '*') { + return channels; + } + + const target = String(categoryId); + return channels.filter( + (channel) => String(channel.tv_genre_id ?? '') === target + ); +} + export function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel { return { ...item, diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss new file mode 100644 index 000000000..14fc6ebc1 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss @@ -0,0 +1,72 @@ +@use '../../../../../../ui/styles/panel-header' as panel; + +:host { + align-self: stretch; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + width: 100%; +} + +.category-content-header { + @include panel.standard-panel-header($sticky: true); +} + +.category-meta { + @include panel.standard-panel-meta(8px); +} + +.category-title { + @include panel.standard-panel-title(0.94rem); + color: var(--mat-sys-on-surface); +} + +.category-subtitle { + @include panel.standard-panel-subtitle( + $font-size: 0.72rem, + $padding: 2px 8px, + $color: var(--mat-sys-on-surface-variant), + $background: var(--mat-sys-surface-container) + ); +} + +.all-items-progress { + align-items: center; + color: var(--mat-sys-on-surface-variant); + display: inline-flex; + font-size: 0.72rem; + gap: 6px; + + &__count { + font-variant-numeric: tabular-nums; + opacity: 0.85; + } +} + +// Let the paginator inherit the panel header's tinted background instead of +// painting its own (mismatched) Material surface color. +mat-paginator { + background: transparent !important; + flex-shrink: 0; + --mat-paginator-container-size: 40px; + --mat-paginator-enabled-icon-color: var( + --app-body-color, + var(--mat-sys-on-surface-variant) + ); + --mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18); +} + +app-grid-list.all-items-grid { + --cover-grid-min-width: var(--live-channel-grid-min-width, 148px); + + display: block; + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 20px 20px; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent) + transparent; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts new file mode 100644 index 000000000..fa54f9531 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts @@ -0,0 +1,123 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { StalkerItvChannel } from '@iptvnator/portal/stalker/data-access'; +import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component'; + +function buildChannels(count: number): StalkerItvChannel[] { + return Array.from({ length: count }, (_, index) => ({ + id: `ch-${index}`, + cmd: `ffrt4://itv/${index}`, + name: index === count - 1 ? 'Needle TV' : `Channel ${index}`, + o_name: index === count - 1 ? 'Needle TV' : `Channel ${index}`, + logo: `logo-${index}.png`, + is_series: null, + })); +} + +describe('StalkerItvAllItemsComponent', () => { + let fixture: ComponentFixture; + let component: StalkerItvAllItemsComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + StalkerItvAllItemsComponent, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(StalkerItvAllItemsComponent); + component = fixture.componentInstance; + }); + + it('renders the first client-side page of channels with a paginator', () => { + fixture.componentRef.setInput('channels', buildChannels(60)); + fixture.detectChanges(); + + expect(component.pagedGridItems()).toHaveLength(25); + expect(fixture.nativeElement.querySelectorAll('mat-card')).toHaveLength( + 25 + ); + expect(fixture.nativeElement.querySelector('mat-paginator')).toBeTruthy(); + expect( + fixture.nativeElement + .querySelector('.category-subtitle') + ?.textContent?.trim() + ).toContain('60'); + }); + + it('slices the next page on paginator change without touching the source', () => { + fixture.componentRef.setInput('channels', buildChannels(60)); + fixture.detectChanges(); + + component.onPageChange({ + pageIndex: 2, + pageSize: 25, + length: 60, + } as never); + fixture.detectChanges(); + + // Third page holds the remaining 10 channels. + expect(component.pagedGridItems()).toHaveLength(10); + expect(component.pagedGridItems()[0]['id']).toBe('ch-50'); + }); + + it('filters by the search term across ALL channels and resets to page one', () => { + fixture.componentRef.setInput('channels', buildChannels(60)); + fixture.detectChanges(); + component.onPageChange({ + pageIndex: 1, + pageSize: 25, + length: 60, + } as never); + + fixture.componentRef.setInput('searchTerm', 'needle'); + fixture.detectChanges(); + + expect(component.pageIndex()).toBe(0); + expect( + component.pagedGridItems().map((item) => item['name']) + ).toEqual(['Needle TV']); + }); + + it('maps the stalker logo to stream_icon and drops null is_series for the grid', () => { + fixture.componentRef.setInput('channels', buildChannels(1)); + fixture.detectChanges(); + + const [item] = component.pagedGridItems(); + expect(item['stream_icon']).toBe('logo-0.png'); + expect('is_series' in item).toBe(false); + }); + + it('emits channelActivated when a card is clicked', () => { + const activated = jest.fn(); + fixture.componentRef.setInput('channels', buildChannels(3)); + fixture.componentInstance.channelActivated.subscribe(activated); + fixture.detectChanges(); + + ( + fixture.nativeElement.querySelector('mat-card') as HTMLElement + ).click(); + + expect(activated).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ch-0' }) + ); + }); + + it('shows skeletons and load progress while loading', () => { + fixture.componentRef.setInput('channels', []); + fixture.componentRef.setInput('loading', true); + fixture.componentRef.setInput('progress', { loaded: 140, total: 400 }); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.grid-skeleton-card') + ).toBeTruthy(); + expect( + fixture.nativeElement + .querySelector('.all-items-progress') + ?.textContent?.replace(/\s+/g, '') + ).toContain('140/400'); + expect(fixture.nativeElement.querySelector('mat-paginator')).toBeNull(); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts new file mode 100644 index 000000000..fc596bc1a --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts @@ -0,0 +1,146 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + linkedSignal, + output, + signal, +} from '@angular/core'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslatePipe } from '@ngx-translate/core'; +import { GridListComponent } from '@iptvnator/portal/shared/ui'; +import { + StalkerItvChannel, + StalkerItvLoadProgress, +} from '@iptvnator/portal/stalker/data-access'; + +/** + * "All channels" grid shown in the Live TV main area before a category is + * selected — mirrors the Xtream live "All Items" view. Fed by the full ITV + * channel list cache; pagination is purely client-side so it never touches the + * store's legacy page state (which would re-fire portal requests). + */ +@Component({ + selector: 'app-stalker-itv-all-items', + imports: [ + GridListComponent, + MatPaginatorModule, + MatProgressSpinnerModule, + TranslatePipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

+ {{ 'PORTALS.ALL_CATEGORIES' | translate }} +

+ @if (loading()) { + + + @if (progress(); as loadProgress) { + + {{ loadProgress.loaded }}/{{ + loadProgress.total + }} + + } + + } @else if (filteredChannels().length > 0) { + {{ filteredChannels().length }} + {{ + (filteredChannels().length === 1 + ? 'PORTALS.ITEM' + : 'PORTALS.ITEMS' + ) | translate + }} + } +
+ @if (!loading() && filteredChannels().length > 0) { + + } +
+ + `, + styleUrl: './stalker-itv-all-items.component.scss', +}) +export class StalkerItvAllItemsComponent { + readonly channels = input([]); + readonly loading = input(false); + readonly progress = input(null); + readonly searchTerm = input(''); + + readonly channelActivated = output(); + + readonly pageSizeOptions = [10, 25, 50, 100]; + readonly pageSize = signal(25); + /** Resets to the first page whenever the source list or search changes. */ + readonly pageIndex = linkedSignal({ + source: () => ({ + term: this.searchTerm(), + channelCount: this.channels().length, + }), + computation: () => 0, + }); + + readonly filteredChannels = computed(() => { + const term = this.searchTerm().trim().toLowerCase(); + const channels = this.channels(); + if (!term) { + return channels; + } + + return channels.filter((channel) => + `${channel.o_name ?? ''} ${channel.name ?? ''}` + .toLowerCase() + .includes(term) + ); + }); + + /** The current page, mapped so GridListComponent can resolve the logo. */ + readonly pagedGridItems = computed(() => { + const start = this.pageIndex() * this.pageSize(); + return this.filteredChannels() + .slice(start, start + this.pageSize()) + .map((channel) => { + // GridListItem forbids null is_series; Stalker payloads may + // carry it — drop the nullish form (same as toPlayableChannel). + const { is_series, ...rest } = channel; + return { + ...rest, + ...(is_series == null ? {} : { is_series }), + stream_icon: channel.logo, + }; + }); + }); + + onPageChange(event: PageEvent): void { + this.pageSize.set(event.pageSize); + this.pageIndex.set(event.pageIndex); + } + + onItemClicked(item: unknown): void { + this.channelActivated.emit(item as StalkerItvChannel); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index a187c2db6..f9780a282 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -15,15 +15,30 @@

{{ selectedCategoryTitle() }}

- @if (visibleChannels().length > 0) { + @if (totalChannelCount() > 0) { {{ visibleChannels().length }} + >{{ totalChannelCount() }} {{ - visibleChannels().length === 1 ? 'item' : 'items' + (totalChannelCount() === 1 + ? 'PORTALS.ITEM' + : 'PORTALS.ITEMS' + ) | translate }} }
+ @if (isFullListMode()) { + + } } - @if (!stalkerStore.selectedCategoryId()) { - + @if (!stalkerStore.selectedItem() && !stalkerStore.selectedCategoryId()) { + @if (showItvAllItems()) { + + + } @else { + + } } @else if (stalkerStore.selectedItem()) { @if (streamUrl() && (isEmbeddedPlayer || isRadioMode())) {
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss index 57ccc3f1c..27fa099ec 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss @@ -36,6 +36,36 @@ ); } +.full-list-progress { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 0.72rem; + line-height: 1.2; + color: var(--mat-sys-on-surface-variant); + background: var(--mat-sys-surface-container); + border-bottom: 1px solid var(--app-separator); + + mat-spinner { + flex: 0 0 auto; + } + + &__label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__count { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; + opacity: 0.85; + } +} + .channels-list { flex: 1; overflow-y: auto; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index dac8cf39c..79a12e7a9 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -1,4 +1,11 @@ -import { Component, Directive, input, output, signal } from '@angular/core'; +import { + Component, + Directive, + EventEmitter, + input, + output, + signal, +} from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -138,7 +145,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { }); const selectedCategoryId = signal('1001'); const searchPhrase = signal(''); - const itvChannels = signal([ + const defaultItvChannels = () => [ { id: '10001', cmd: 'ffrt4://itv/10001', @@ -153,7 +160,8 @@ describe('StalkerLiveStreamLayoutComponent', () => { o_name: 'Beta TV', logo: 'beta.png', }, - ]); + ]; + const itvChannels = signal(defaultItvChannels()); const radioChannels = signal([ { id: 'radio-1', @@ -186,6 +194,17 @@ describe('StalkerLiveStreamLayoutComponent', () => { const isLoadingBulkItvEpg = signal(false); const hasMoreChannels = signal(false); const page = signal(0); + const itvFullListActive = signal(false); + const itvFullListLoading = signal(false); + const itvFullListProgress = signal<{ + loaded: number; + total: number; + } | null>(null); + const itvFullChannelList = signal< + ReturnType + >([]); + const itvSelectedCategoryFromCache = signal(false); + const isPaginatedContentLoading = signal(false); const stalkerStore = { getSelectedCategoryName: signal('News'), @@ -193,6 +212,14 @@ describe('StalkerLiveStreamLayoutComponent', () => { radioChannels, searchPhrase, hasMoreChannels, + itvFullListActive, + itvFullListLoading, + itvFullListProgress, + itvFullChannelList, + itvSelectedCategoryFromCache, + isPaginatedContentLoading, + preloadItvChannels: jest.fn(), + refreshItvChannels: jest.fn().mockResolvedValue(undefined), selectedItvId, currentPlaylist: playlist, selectedItvEpgPrograms, @@ -260,6 +287,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { resolveItvPlayback = stalkerStore.resolveItvPlayback; resolveRadioPlayback = stalkerStore.resolveRadioPlayback; + itvChannels.set(defaultItvChannels()); selectedCategoryId.set('1001'); searchPhrase.set(''); stalkerStore.selectedContentType.set('itv'); @@ -273,6 +301,13 @@ describe('StalkerLiveStreamLayoutComponent', () => { isLoadingBulkItvEpg.set(false); hasMoreChannels.set(false); page.set(0); + itvFullListActive.set(false); + itvFullListLoading.set(false); + itvFullListProgress.set(null); + itvFullChannelList.set([]); + itvSelectedCategoryFromCache.set(false); + isPaginatedContentLoading.set(false); + stalkerStore.preloadItvChannels.mockClear(); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); settingsStore.openStreamOnDoubleClick.set(false); @@ -343,6 +378,13 @@ describe('StalkerLiveStreamLayoutComponent', () => { provide: TranslateService, useValue: { instant: jest.fn((value: string) => value), + // The real TranslatePipe (used by child components + // that aren't stubbed) needs these members too. + get: jest.fn((value: string) => of(value)), + stream: jest.fn((value: string) => of(value)), + onTranslationChange: new EventEmitter(), + onLangChange: new EventEmitter(), + onDefaultLangChange: new EventEmitter(), }, }, { @@ -645,6 +687,282 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect(stalkerStore.setItvChannels).not.toHaveBeenCalled(); }); + it('windows the rendered list in full-list mode while search covers everything', () => { + const initialChannels = itvChannels(); + try { + const full = Array.from({ length: 250 }, (_, index) => ({ + id: `ch-${index}`, + cmd: `ffrt4://itv/${index}`, + name: index === 249 ? 'Needle TV' : `Channel ${index}`, + o_name: index === 249 ? 'Needle TV' : `Channel ${index}`, + logo: '', + })); + itvFullListActive.set(true); + itvSelectedCategoryFromCache.set(true); + itvChannels.set(full); + itvFullChannelList.set(full); + fixture.detectChanges(); + + // Only the first render window hits the DOM… + expect(component.visibleChannels()).toHaveLength(100); + // …but the header count reflects the whole list. + expect(component.totalChannelCount()).toBe(250); + expect(component.hasMoreItems()).toBe(true); + + stalkerStore.setPage.mockClear(); + component.loadMore(); + + // Scrolling extends the window from memory without portal paging. + expect(component.visibleChannels()).toHaveLength(200); + expect(stalkerStore.setPage).not.toHaveBeenCalled(); + + // Regression: search matches channels far beyond the first page. + searchPhrase.set('needle'); + fixture.detectChanges(); + expect( + component.visibleChannels().map((channel) => channel.name) + ).toEqual(['Needle TV']); + } finally { + itvChannels.set(initialChannels); + } + }); + + it('searches the whole portal (all categories) in full-list mode, not just the current category', () => { + // The current category (itvChannels) has only 'Alpha TV'/'Beta TV', but + // the portal's full list contains a News channel in another genre. + itvFullListActive.set(true); + itvFullChannelList.set([ + ...defaultItvChannels(), + { + id: '55', + cmd: 'ffrt4://itv/55', + name: 'CNN International', + o_name: 'CNN International', + logo: '', + }, + ]); + searchPhrase.set('cnn'); + fixture.detectChanges(); + + expect( + component.filteredChannels().map((channel) => channel.name) + ).toEqual(['CNN International']); + }); + + it('includes paged censored-category channels in full-list search results', () => { + // The adult category's channels come from the legacy paged flow and + // are intentionally absent from the full-list cache — searching for a + // currently visible channel must still find it (merged source). + itvFullListActive.set(true); + itvSelectedCategoryFromCache.set(false); + itvChannels.set([ + { + id: 'adult-1', + cmd: 'ffrt4://itv/adult-1', + name: 'Erox HD', + o_name: 'Erox HD', + logo: '', + }, + ]); + itvFullChannelList.set(defaultItvChannels()); + searchPhrase.set('erox'); + fixture.detectChanges(); + + expect( + component.filteredChannels().map((channel) => channel.name) + ).toEqual(['Erox HD']); + + // And the cached portal-wide channels remain searchable too. + searchPhrase.set('alpha'); + fixture.detectChanges(); + expect( + component.filteredChannels().map((channel) => channel.name) + ).toEqual(['Alpha TV']); + }); + + it('grows the render window to include a channel selected beyond it (remote/numeric nav)', async () => { + const full = Array.from({ length: 250 }, (_, index) => ({ + id: `ch-${index}`, + cmd: `ffrt4://itv/${index}`, + name: `Channel ${index}`, + o_name: `Channel ${index}`, + logo: '', + })); + itvFullListActive.set(true); + itvSelectedCategoryFromCache.set(true); + itvChannels.set(full); + itvFullChannelList.set(full); + fixture.detectChanges(); + + expect(component.visibleChannels()).toHaveLength(100); + + // Selecting channel index 150 (outside the 100-item window) must grow + // the window so it is rendered and can be highlighted/scrolled to. + await component.playChannel(full[150], false); + fixture.detectChanges(); + + expect(component.visibleChannels().length).toBeGreaterThan(150); + expect( + component + .visibleChannels() + .some((channel) => channel.id === 'ch-150') + ).toBe(true); + }); + + it('does not clear cached channels when switching category in full-list mode', async () => { + // Regression: the category-change reset effect used to setItvChannels([]) + // unconditionally, clobbering the list the content loader had just + // served synchronously from the full-list cache — leaving the sidebar + // stuck on an infinite skeleton for every category after the first. + itvFullListActive.set(true); + itvSelectedCategoryFromCache.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + + stalkerStore.setItvChannels.mockClear(); + selectedCategoryId.set('42'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(stalkerStore.setItvChannels).not.toHaveBeenCalled(); + }); + + it('still clears channels on category change when the full list is not active', async () => { + itvFullListActive.set(false); + fixture.detectChanges(); + await fixture.whenStable(); + + stalkerStore.setItvChannels.mockClear(); + selectedCategoryId.set('99'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(stalkerStore.setItvChannels).toHaveBeenCalledWith([]); + }); + + it('shows an empty state (not a skeleton) for a loaded but empty category', () => { + // Full list is loaded, nothing is loading, and the selected category + // filters down to zero channels — this must read as "empty", not "stuck". + itvFullListActive.set(true); + itvFullListLoading.set(false); + isPaginatedContentLoading.set(false); + itvChannels.set([]); + searchPhrase.set(''); + fixture.detectChanges(); + + expect(component.isInitialChannelsLoading()).toBe(false); + expect(component.isCategoryEmpty()).toBe(true); + expect( + fixture.nativeElement.querySelector('app-channel-list-skeleton') + ).toBeNull(); + expect( + fixture.nativeElement.querySelector( + '.empty-search-state app-portal-empty-state' + ) + ).toBeTruthy(); + }); + + it('shows the skeleton only while a full-list load is genuinely in flight', () => { + itvChannels.set([]); + searchPhrase.set(''); + itvFullListActive.set(false); + isPaginatedContentLoading.set(false); + + itvFullListLoading.set(true); + fixture.detectChanges(); + expect(component.isInitialChannelsLoading()).toBe(true); + + itvFullListLoading.set(false); + fixture.detectChanges(); + expect(component.isInitialChannelsLoading()).toBe(false); + }); + + it('shows the skeleton while the legacy paged fetch is loading', () => { + itvChannels.set([]); + searchPhrase.set(''); + itvFullListActive.set(false); + itvFullListLoading.set(false); + + isPaginatedContentLoading.set(true); + fixture.detectChanges(); + expect(component.isInitialChannelsLoading()).toBe(true); + }); + + it('keeps portal pagination for a censored category absent from the cache', async () => { + // Full list IS active, but the selected (adult) genre has no cached + // channels — infinite scroll must request the next portal page instead + // of only growing the client-side render window. + itvFullListActive.set(true); + itvSelectedCategoryFromCache.set(false); + hasMoreChannels.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + + page.set(0); + stalkerStore.setPage.mockClear(); + component.loadMore(); + + expect(stalkerStore.setPage).toHaveBeenCalledWith(1); + }); + + it('preloads the full channel list as soon as the Live TV section is entered', () => { + // No category selected — the preload must not wait for a category click. + selectedCategoryId.set(null); + fixture.detectChanges(); + + expect(stalkerStore.preloadItvChannels).toHaveBeenCalled(); + }); + + it('shows the all-channels grid instead of the placeholder when the full list is available', () => { + selectedCategoryId.set(null); + selectedItem.set(null); + itvFullListActive.set(true); + itvFullChannelList.set(defaultItvChannels()); + fixture.detectChanges(); + + const grid = fixture.nativeElement.querySelector( + 'app-stalker-itv-all-items' + ); + expect(grid).toBeTruthy(); + expect(grid.querySelectorAll('mat-card')).toHaveLength(2); + expect( + fixture.nativeElement.querySelector('app-portal-empty-state') + ).toBeNull(); + }); + + it('keeps the select-a-category placeholder on portals without a usable full list', () => { + selectedCategoryId.set(null); + selectedItem.set(null); + itvFullListActive.set(false); + itvFullListLoading.set(false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-stalker-itv-all-items') + ).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-portal-empty-state') + ).toBeTruthy(); + }); + + it('shows the refresh button in full-list mode and delegates to the store', () => { + itvFullListActive.set(true); + fixture.detectChanges(); + + const buttons = Array.from( + fixture.nativeElement.querySelectorAll( + '.category-content-header button' + ) + ) as HTMLButtonElement[]; + const refreshButton = buttons.find((button) => + button.textContent?.includes('refresh') + ); + + expect(refreshButton).toBeTruthy(); + refreshButton?.click(); + expect(stalkerStore.refreshItvChannels).toHaveBeenCalled(); + }); + it('persists the channels sidebar width under a dedicated storage key', () => { // The shell context panel (category sidebar) is visible at the same // time as this sidebar and persists its width under the shared @@ -659,12 +977,38 @@ describe('StalkerLiveStreamLayoutComponent', () => { ); }); - it('keeps row previews empty before bulk epg is loaded', async () => { + async function settleEagerEpg(): Promise { + for (let i = 0; i < 5; i += 1) { + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + } + fixture.detectChanges(); + } + + it('loads bulk EPG and shows row previews on category entry, before any playback', async () => { + await settleEagerEpg(); + + // The list EPG previews appear as soon as the category's channels are + // shown — the user no longer has to play a channel first. + expect(ensureBulkItvEpg).toHaveBeenCalledWith(168); + expect(component.epgPreviewPrograms.get('10001')?.title).toBe( + 'Current Show' + ); + expect(component.epgPreviewPrograms.get('10002')?.title).toBe( + 'Next Channel Show' + ); + // The per-channel fallback is reserved for the playing channel. + expect(fetchChannelEpg).not.toHaveBeenCalled(); + }); + + it('does not load bulk EPG for radio (no EPG data)', async () => { + stalkerStore.selectedContentType.set('radio'); + selectedCategoryId.set('radio-all'); + selectedItem.set(null); fixture.detectChanges(); await fixture.whenStable(); - expect(component.epgPreviewPrograms.size).toBe(0); - expect(fetchChannelEpg).not.toHaveBeenCalled(); expect(ensureBulkItvEpg).not.toHaveBeenCalled(); }); @@ -684,16 +1028,18 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect(fetchChannelEpg).not.toHaveBeenCalled(); }); - it('ensures bulk EPG only once across channel switches for the same playlist', async () => { - fixture.detectChanges(); + it('does not re-fetch bulk EPG when switching channels once it is loaded', async () => { + await settleEagerEpg(); + // Bulk EPG has loaded (eagerly, on entry). + ensureBulkItvEpg.mockClear(); await component.playChannel(itvChannels()[0]); await fixture.whenStable(); await component.playChannel(itvChannels()[1]); await fixture.whenStable(); - expect(ensureBulkItvEpg).toHaveBeenCalledTimes(1); - expect(ensureBulkItvEpg).toHaveBeenCalledWith(168); + // Playing/switching channels reuses the cached bulk EPG. + expect(ensureBulkItvEpg).not.toHaveBeenCalled(); }); it('renders inline audio playback and no EPG for radio stations', async () => { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 02e33442d..3ccbe97a2 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -8,6 +8,7 @@ import { effect, ElementRef, inject, + linkedSignal, OnDestroy, signal, untracked, @@ -73,12 +74,16 @@ import { StalkerStore, normalizeStalkerEntityId, } from '@iptvnator/portal/stalker/data-access'; +import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component'; type StalkerPlayableChannel = StalkerPortalItem & { cmd?: string; has_files?: unknown; }; +/** Channels rendered per "page" when the full list is served from the cache. */ +const FULL_LIST_RENDER_CHUNK = 100; + @Component({ selector: 'app-stalker-live-stream-layout', templateUrl: './stalker-live-stream-layout.component.html', @@ -97,6 +102,7 @@ type StalkerPlayableChannel = StalkerPortalItem & { NgTemplateOutlet, PortalEmptyStateComponent, ResizableDirective, + StalkerItvAllItemsComponent, TranslatePipe, WebPlayerViewComponent, ], @@ -130,27 +136,115 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly searchTerm = computed(() => this.stalkerStore.searchPhrase().trim().toLowerCase() ); - readonly visibleChannels = computed(() => { - const channels = this.channels(); + /** Full-list mode: the complete channel list is cached, so search covers everything. */ + readonly isFullListMode = computed( + () => !this.isRadioMode() && this.stalkerStore.itvFullListActive() + ); + /** + * True when the CURRENT category is actually served from the cache. + * Censored (adult) genres are excluded from `get_all_channels` on most + * portals, so they stay on the legacy paged flow (portal pagination, + * infinite scroll) even while the full-list cache is active. + */ + readonly isCategoryFromCache = computed( + () => + !this.isRadioMode() && + this.stalkerStore.itvSelectedCategoryFromCache() + ); + /** + * Channels matching the search phrase. Without a term, the current + * category. With a term in full-list mode, the WHOLE portal's channel list + * (every category) so search behaves like "search all channels" — merged + * with the currently loaded channels, because a censored (adult) category + * is paged from the portal and its channels are intentionally absent from + * the full-list cache. Otherwise the loaded channels of the current + * category. + */ + readonly filteredChannels = computed(() => { const term = this.searchTerm(); - if (!term) { - return channels; + return this.channels(); } - return channels.filter((item) => + let source = this.channels(); + if (this.isFullListMode()) { + const merged = new Map(); + for (const channel of source) { + merged.set(normalizeStalkerEntityId(channel.id), channel); + } + for (const channel of this.stalkerStore.itvFullChannelList()) { + const id = normalizeStalkerEntityId(channel.id); + if (!merged.has(id)) { + merged.set(id, channel); + } + } + source = [...merged.values()]; + } + + return source.filter((item) => `${item.o_name ?? ''} ${item.name ?? ''}` .toLowerCase() .includes(term) ); }); - readonly hasMoreItems = this.stalkerStore.hasMoreChannels; + readonly isFullListLoading = computed( + () => !this.isRadioMode() && this.stalkerStore.itvFullListLoading() + ); + readonly fullListProgress = this.stalkerStore.itvFullListProgress; + readonly itvFullChannelList = this.stalkerStore.itvFullChannelList; + /** + * All-channels grid in the main area when no category is selected yet + * (Xtream "All Items" parity). Falls back to the "select a category" + * placeholder on portals without a usable full list. + */ + readonly showItvAllItems = computed( + () => + !this.isRadioMode() && + !this.stalkerStore.selectedCategoryId() && + (this.stalkerStore.itvFullListActive() || + this.stalkerStore.itvFullListLoading()) + ); + /** Windowed render limit keeps the DOM bounded for multi-thousand channel lists. */ + private readonly renderLimit = linkedSignal({ + source: () => ({ + term: this.searchTerm(), + category: this.stalkerStore.selectedCategoryId(), + contentType: this.stalkerStore.selectedContentType(), + }), + computation: () => FULL_LIST_RENDER_CHUNK, + }); + readonly visibleChannels = computed(() => + this.isCategoryFromCache() + ? this.filteredChannels().slice(0, this.renderLimit()) + : this.filteredChannels() + ); + readonly totalChannelCount = computed(() => this.filteredChannels().length); + readonly hasMoreItems = computed(() => + this.isCategoryFromCache() + ? this.visibleChannels().length < this.filteredChannels().length + : this.stalkerStore.hasMoreChannels() + ); readonly isLoadingMore = signal(false); + /** + * Skeleton shows only while a load is genuinely in flight. An empty result + * once loading has settled is an empty category, not a stuck spinner — the + * full-list cache can legitimately filter a genre down to zero channels. + */ readonly isInitialChannelsLoading = computed( () => !!this.stalkerStore.selectedCategoryId() && this.channels().length === 0 && - !this.searchTerm() + !this.searchTerm() && + (this.isFullListLoading() || + this.stalkerStore.isPaginatedContentLoading()) + ); + /** Category is loaded but has no channels (and the user isn't searching). */ + readonly isCategoryEmpty = computed( + () => + !!this.stalkerStore.selectedCategoryId() && + !this.searchTerm() && + this.channels().length === 0 && + !this.isInitialChannelsLoading() ); readonly selectedChannelId = this.stalkerStore.selectedItvId; @@ -291,14 +385,33 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { }); } - // Reset channels/page on category change + // Start the full ITV channel list load as soon as the Live TV section + // is entered (not on the first category click), so the all-channels + // grid and the category count badges are available immediately. + effect(() => { + const contentType = this.stalkerStore.selectedContentType(); + const playlist = this.stalkerStore.currentPlaylist(); + if (contentType === 'itv' && playlist) { + untracked(() => this.stalkerStore.preloadItvChannels()); + } + }); + + // Reset channels/page on category change. When the new category is + // served from the cache, the content loader re-serves the filtered + // list synchronously, so clearing here would just clobber it (the + // reset effect runs after the store resource) and leave the list stuck + // empty. Categories on the legacy paged flow — cold cache AND censored + // genres missing from the cache — still clear to avoid flashing the + // previous category's channels during the async fetch. effect(() => { const contentType = this.stalkerStore.selectedContentType(); this.stalkerStore.selectedCategoryId(); untracked(() => { if (contentType === 'radio') { this.stalkerStore.setRadioChannels([]); - } else { + } else if ( + !this.stalkerStore.itvSelectedCategoryFromCache() + ) { this.stalkerStore.setItvChannels([]); } this.stalkerStore.setPage(0); @@ -350,6 +463,28 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.stalkerStore.clearBulkItvEpgCache(); }); + // Load the bulk ITV EPG as soon as a category's channels are available + // — not only after the first channel is played — so the per-channel + // "now playing" previews in the list and the EPG panel populate + // immediately. Registered AFTER the playlist-change effect above so a + // portal switch clears the stale cache first and this then refills it; + // ensureBulkItvEpg de-duplicates and reuses the cache, so this is safe + // to fire on every channel-list change. + effect(() => { + const hasChannels = this.itvChannels().length > 0; + const playlistId = this.stalkerStore.currentPlaylist()?._id; + if ( + this.isRadioMode() || + !this.supportsEpg || + !hasChannels || + !playlistId + ) { + return; + } + + untracked(() => void this.stalkerStore.ensureBulkItvEpg(168)); + }); + // Setup scroll listener when container becomes available effect(() => { const container = this.scrollContainer(); @@ -366,7 +501,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const selectedItem = this.stalkerStore.selectedItem(); const selectedType = this.stalkerStore.selectedContentType(); - const channels = this.visibleChannels(); + const channels = this.filteredChannels(); if (selectedType !== 'itv' || !selectedItem?.id) { remoteControl.updateRemoteControlStatus({ @@ -436,6 +571,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const requestId = ++this.playbackRequestId; const channelId = normalizeStalkerEntityId(item.id); this.stalkerStore.setSelectedItem(item); + this.ensureChannelWithinRenderWindow(channelId); try { const isRadioMode = this.isRadioMode(); @@ -525,13 +661,53 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } + /** + * In full-list mode the rendered list is windowed to `renderLimit`. When a + * channel beyond that window is selected (remote channel-up/down, numeric + * select), grow the window so the selection is actually in the DOM and can + * be highlighted/scrolled to instead of drifting off-window. + */ + private ensureChannelWithinRenderWindow(channelId: string): void { + if (!this.isCategoryFromCache()) { + return; + } + + const index = this.filteredChannels().findIndex( + (item) => normalizeStalkerEntityId(item.id) === channelId + ); + if (index < 0 || index < this.renderLimit()) { + return; + } + + const needed = + Math.ceil((index + 1) / FULL_LIST_RENDER_CHUNK) * + FULL_LIST_RENDER_CHUNK; + this.renderLimit.set(Math.max(this.renderLimit(), needed)); + } + loadMore() { + if (this.isCategoryFromCache()) { + // Extends the render window over the in-memory list — no request. + if (this.hasMoreItems()) { + this.renderLimit.update( + (limit) => limit + FULL_LIST_RENDER_CHUNK + ); + } + return; + } + + // Legacy portal pagination — also used for censored (adult) genres + // that are absent from the full-list cache. if (this.isLoadingMore() || !this.hasMoreItems()) return; this.isLoadingMore.set(true); const nextPage = this.stalkerStore.page() + 1; this.stalkerStore.setPage(nextPage); } + refreshChannels(): void { + void this.stalkerStore.refreshItvChannels(); + } + onLiveEpgPanelCollapsedChange(collapsed: boolean): void { const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; this.liveEpgPanelState.set(state); @@ -927,7 +1103,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { return; } - const channels = this.visibleChannels(); + const channels = this.filteredChannels(); const nextItem = getAdjacentChannelItem( channels, activeItem.id, @@ -955,7 +1131,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } const channel = getChannelItemByNumber( - this.visibleChannels(), + this.filteredChannels(), command.number ); if (!channel) { diff --git a/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts b/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts index 73d5a2f60..ef582acf4 100644 --- a/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts +++ b/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts @@ -3,6 +3,7 @@ export enum StalkerPortalActions { GetGenres = 'get_genres', CreateLink = 'create_link', GetOrderedList = 'get_ordered_list', + GetAllChannels = 'get_all_channels', Favorites = 'favorites', Handshake = 'handshake', DoAuth = 'do_auth', diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html index 64a70da92..7dde143f2 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html @@ -33,7 +33,7 @@ class="item-count item-count--loading" aria-hidden="true" >
- } @else { + } @else if (hasItemCount(item)) {
{{ getItemCount(item) }}
diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts index 61fe42a58..676e399d0 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts @@ -32,6 +32,12 @@ export class WorkspaceContextCategoryViewComponent { readonly itemCounts = input>(new Map()); readonly showCounts = input(false); readonly countDisplayMode = input<'loading' | 'ready'>('ready'); + /** + * When true, items without an entry in `itemCounts` render no badge at + * all instead of "0" — used for Stalker censored (adult) genres whose + * real channel count is unknown to the full-list cache. + */ + readonly omitMissingCounts = input(false); readonly interactionEnabled = input(true); readonly statusText = input(''); @@ -93,6 +99,15 @@ export class WorkspaceContextCategoryViewComponent { return this.itemCounts().get(itemId) ?? 0; } + hasItemCount(item: WorkspaceCategoryViewItem): boolean { + if (!this.omitMissingCounts()) { + return true; + } + + // NaN (from the "*" all-category id) is a valid Map key here. + return this.itemCounts().has(Number(item.id ?? item.category_id)); + } + onCategoryClick(item: WorkspaceCategoryViewItem): void { if (!this.interactionEnabled()) { return; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index c812c82d5..39eefd3e7 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -166,6 +166,10 @@ } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 86bde639c..0aa9e3457 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -94,6 +94,9 @@ describe('WorkspaceContextPanelComponent', () => { selectedCategoryId: signal(null), isCategoryResourceLoading: signal(false), isCategoryResourceFailed: signal(false), + itvFullListActive: signal(false), + itvFullListLoading: signal(false), + itvCategoryItemCounts: signal>(new Map()), setSelectedCategory: jest.fn(), setPage: jest.fn(), clearSelectedItem: jest.fn(), @@ -398,6 +401,88 @@ describe('WorkspaceContextPanelComponent', () => { ]); }); + it('shows per-genre count badges on stalker Live TV categories when the full list is cached', () => { + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'itv'); + stalkerStore.getCategoryResource.set([ + { category_id: '*', category_name: 'All' }, + { category_id: '1', category_name: 'Documentary' }, + { category_id: '2', category_name: 'Sports' }, + ]); + stalkerStore.itvFullListActive.set(true); + stalkerStore.itvCategoryItemCounts.set( + new Map([ + [1, 190], + [2, 38], + [Number.NaN, 228], + ]) + ); + fixture.detectChanges(); + + const counts = Array.from( + fixture.nativeElement.querySelectorAll('.category-item .item-count') + ).map((el) => (el as HTMLElement).textContent?.trim()); + // "All" (NaN key → total), Documentary, Sports. + expect(counts).toEqual(['228', '190', '38']); + }); + + it('omits the badge for censored stalker genres missing from the count map', () => { + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'itv'); + stalkerStore.getCategoryResource.set([ + { category_id: '1', category_name: 'Documentary' }, + { + category_id: '19', + category_name: 'For adults', + censored: true, + } as never, + ]); + stalkerStore.itvFullListActive.set(true); + // Censored genre 19 has no entry — its real count is unknown. + stalkerStore.itvCategoryItemCounts.set( + new Map([[1, 190]]) + ); + fixture.detectChanges(); + + const categoryButtons = Array.from( + fixture.nativeElement.querySelectorAll('.category-item') + ) as HTMLElement[]; + const documentary = categoryButtons.find((el) => + el.textContent?.includes('Documentary') + ); + const adults = categoryButtons.find((el) => + el.textContent?.includes('For adults') + ); + + expect(documentary?.querySelector('.item-count')?.textContent).toContain( + '190' + ); + expect(adults?.querySelector('.item-count')).toBeNull(); + }); + + it('hides count badges for stalker categories without a cached full list (e.g. VOD)', () => { + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'vod'); + stalkerStore.getCategoryResource.set([ + { category_id: '1', category_name: 'Action' }, + ]); + stalkerStore.itvFullListActive.set(false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.category-item .item-count') + ).toBeNull(); + }); + it('preserves the active xtream live item when switching live categories', () => { fixture.componentRef.setInput('section', 'live'); xtreamSelectedTypeContentState.set('ready'); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 0ff997510..5ac3d3035 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -144,6 +144,21 @@ export class WorkspaceContextPanelComponent { this.stalkerStore.isCategoryResourceLoading; readonly isStalkerCategoryFailed = this.stalkerStore.isCategoryResourceFailed; + // Category count badges are only available for Stalker Live TV, where the + // full channel list is cached — VOD/series/radio still page lazily, so + // their per-category totals are unknown. + readonly stalkerCategoryItemCounts = + this.stalkerStore.itvCategoryItemCounts; + readonly stalkerShowCounts = computed( + () => + this.isStalkerCategories() && + this.section() === 'itv' && + (this.stalkerStore.itvFullListActive() || + this.stalkerStore.itvFullListLoading()) + ); + readonly stalkerCountDisplayMode = computed<'loading' | 'ready'>(() => + this.stalkerStore.itvFullListActive() ? 'ready' : 'loading' + ); readonly skeletonRows = Array.from({ length: 14 }, (_, index) => index); readonly skeletonLabelWidths = [ 78, 66, 74, 59, 83, 69, 76, 62, 81, 64, 72, 67, 79, 61, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts index a45a1ed3c..5d374dc15 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts @@ -123,11 +123,15 @@ export class WorkspaceShellSearchService { }; } - const isDegradedStalkerItv = + // Stalker live search is "loaded only" (limited to fetched pages) + // unless the full ITV channel list is cached. ITV with the full list + // active covers everything; radio always pages, so it stays degraded. + const isDegradedStalkerLive = context?.provider === 'stalker' && - section === 'itv' && - appliedQuery.length > 0; - const behavior = isDegradedStalkerItv + appliedQuery.length > 0 && + ((section === 'itv' && !this.stalkerStore.itvFullListActive()) || + section === 'radio'); + const behavior = isDegradedStalkerLive ? 'degraded-loaded-only' : route.searchMode; @@ -151,7 +155,7 @@ export class WorkspaceShellSearchService { stalkerCategoryName: this.stalkerStore.getSelectedCategoryName(), }), - statusLabel: isDegradedStalkerItv + statusLabel: isDegradedStalkerLive ? this.translateText(SEARCH_LOADED_ONLY_STATUS) : '', minLength: route.searchMode === 'remote-search' ? 3 : 1, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 32aad8089..06585d642 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -72,6 +72,7 @@ class MockXtreamStore { class MockStalkerStore { readonly searchPhrase = signal(''); readonly getSelectedCategoryName = signal('All Items'); + readonly itvFullListActive = signal(false); setSearchPhrase = jest.fn((term: string) => this.searchPhrase.set(term)); } @@ -594,7 +595,17 @@ describe('WorkspaceShellFacade', () => { ); }); - it('treats stalker radio search as a remote section search', () => { + it('drops the loaded-only status once the full ITV channel list is cached', () => { + stalkerStore.itvFullListActive.set(true); + facade.currentUrl.set('/workspace/stalker/pl-1/itv?q=cnn'); + searchSync.syncSearchFromRoute(); + TestBed.flushEffects(); + + expect(facade.canUseSearch()).toBe(true); + expect(facade.searchStatusLabel()).toBe(''); + }); + + it('marks stalker radio search as loaded-only (radio always pages)', () => { facade.currentUrl.set('/workspace/stalker/pl-1/radio?q=jazz'); searchSync.syncSearchFromRoute(); TestBed.flushEffects(); @@ -604,7 +615,11 @@ describe('WorkspaceShellFacade', () => { expect(facade.searchScopeLabel()).toBe( 'WORKSPACE.SHELL.RAIL_RADIO / All Items' ); - expect(facade.searchStatusLabel()).toBe(''); + // Radio has no full-list cache, so its local search only covers loaded + // stations — surface the same "loaded only" hint as degraded ITV. + expect(facade.searchStatusLabel()).toBe( + 'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY' + ); }); it('applies q to Xtream category search on vod routes', () => { From db70b070939eb715b03ce215351b57ed16628d69 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:02:11 +0200 Subject: [PATCH 20/26] feat(playback): theater stage and opt-in ambient fill for the inline portal player (#1223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tmdb): purge obsolete search cache rows * feat(playback): theater stage and opt-in ambient fill for the inline portal player On wide-short windows the VOD/series inline player left a strip of app surface next to the video: with `width: auto`, the viewport's `max-height` transferred through `aspect-ratio` into a max-width (CSS transferred size constraints), re-clamping the stage to 16:9 and leaving the leftover outside it. - Theater stage: give `.player-shell__viewport` a definite `width: 100%` so it always fills the content row; the player renders as the largest 16:9 box that fits the stage height, centered — the leftover is always the stage's black background, never app surface (YouTube-style letterbox). Applies to every inline engine. - Ambient fill: new `playerAmbientMode` setting (default off, Settings > Playback, web players only) renders a blurred, dimmed copy of the poster behind the player, filling the letterbox margins. Enforced at runtime too: Embedded MPV never gets the extra DOM layer. Live channels and non-http(s) poster URLs are excluded. Verified live via CDP at 1720x760 (stage 1362x532, player 946x532 with symmetric 208px margins) and 1280x950 (stage exactly 16:9, no bars). Co-Authored-By: Claude Opus 4.8 * fix(i18n): add ambient-mode setting keys to all remaining locales The i18n drift gate requires SETTINGS.PLAYER_AMBIENT_MODE and its description in every locale; the feature commit only covered en and ru. Translated via the i18n-fill workflow (per-locale patch + mechanical merge, glossary-matched against each existing file). Co-Authored-By: Claude Opus 4.8 * test(settings): include playerAmbientMode in expected default settings settings.component.spec asserts the persisted settings object with toEqual; the new default-off field has to be part of the fixture. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- CLAUDE.md | 3 +- .../src/app/settings/settings-form.utils.ts | 2 + .../settings-playback-section.component.html | 18 +++ ...ettings-playback-section.component.spec.ts | 1 + .../app/settings/settings.component.spec.ts | 1 + apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/embedded-inline-playback.md | 23 ++++ docs/architecture/tmdb-metadata-enrichment.md | 36 +++--- .../src/lib/settings-store.service.spec.ts | 18 +++ .../src/lib/settings-store.service.ts | 4 + .../src/lib/connection-migrations.spec.ts | 56 ++++++++- libs/shared/database/src/lib/connection.ts | 53 +++++++++ .../interfaces/src/lib/settings.interface.ts | 6 + .../portal-inline-player.component.html | 7 ++ .../portal-inline-player.component.scss | 35 +++++- .../portal-inline-player.component.spec.ts | 111 ++++++++++++++++++ .../portal-inline-player.component.ts | 39 ++++++ 34 files changed, 426 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d8c1f2c9a..659ab4a3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -778,6 +778,7 @@ engine` (restart required) or **VOD/Series Detail Pages (two-state layout)**: - Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes) +- The inline player (`PortalInlinePlayerComponent`) renders a full-width **theater stage** (`.player-shell__viewport`): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in `playerAmbientMode` setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style) - Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`) - A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort. - Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items: quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again. @@ -814,7 +815,7 @@ engine` (restart required) or - Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) - Match confidence: provider `tmdb_id` trusted fully; otherwise normalized-title + year (±1) search with a strict gate — no confident match means no enrichment - Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded) -- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting +- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker. - Service layer: `libs/services/src/lib/tmdb/`; store glue: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts` and `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts` (hooked in `withStalkerSelection().setSelectedItem`) - TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About - See `docs/architecture/tmdb-metadata-enrichment.md` diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 47ec38bb9..594c2e951 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -32,6 +32,7 @@ export function createSettingsForm( return formBuilder.group({ player: [VideoPlayer.VideoJs], webPlayerSharedControls: false, + playerAmbientMode: false, ...(supportsEpg ? { epgUrl: new FormArray>([]) } : {}), @@ -117,6 +118,7 @@ export function createSettingsFromFormValue( return { player: value.player ?? VideoPlayer.VideoJs, webPlayerSharedControls: value.webPlayerSharedControls ?? false, + playerAmbientMode: value.playerAmbientMode ?? false, streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false, language: value.language ?? Language.ENGLISH, 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 75551c14a..36a44043d 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -95,6 +95,24 @@ > + +
+
+

+ {{ 'SETTINGS.PLAYER_AMBIENT_MODE' | translate }} +

+

+ {{ 'SETTINGS.PLAYER_AMBIENT_MODE_DESCRIPTION' | translate }} +

+
+
+ +
+
} @if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) { 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 5c6ce2662..51aaf30b8 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 @@ -381,6 +381,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup { return new FormGroup({ player: new FormControl(player), webPlayerSharedControls: new FormControl(false), + playerAmbientMode: new FormControl(false), streamFormat: new FormControl(StreamFormat.AutoStreamFormat), openStreamOnDoubleClick: new FormControl(false), showExternalPlaybackBar: new FormControl(true), diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 86c86339f..ca40a5840 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -85,6 +85,7 @@ export class MockRouter { const DEFAULT_SETTINGS = { player: VideoPlayer.VideoJs, webPlayerSharedControls: false, + playerAmbientMode: false, streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 833bd2075..7c9cde711 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "اختر مشغل الفيديو الافتراضي", "WEB_PLAYER_SHARED_CONTROLS": "عناصر تحكم موحّدة لمشغلات الويب (تجريبي)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استخدم عناصر التحكم المشتركة في IPTVnator مع HTML5 وVideo.js وArtPlayer.", + "PLAYER_AMBIENT_MODE": "ملء الخلفية المحيطة", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "تعبئة المساحة حول المشغل بنسخة مشوشة ومعتمة من الملصق بدلاً من الخطوط السوداء.", "STREAM_FORMAT_DESCRIPTION": "اختر تنسيق البث الافتراضي (xtream)", "LANGUAGE_DESCRIPTION": "اختر لغة التطبيق", "THEME_DESCRIPTION": "اختر السمة البصرية للتطبيق", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 27188b4e8..f83fe2f02 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "اختار مشغل الفيديو الافتراضي", "WEB_PLAYER_SHARED_CONTROLS": "عناصر تحكم موحدة لمشغلات الويب (تجريبية)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استعمل عناصر التحكم المشتركة ديال IPTVnator مع HTML5 وVideo.js وArtPlayer.", + "PLAYER_AMBIENT_MODE": "خلفية محيطة", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "ملي المساحة حول المشغل بغلاف معتم وطابت بلاصة الشرائط السوداء.", "STREAM_FORMAT_DESCRIPTION": "اختار صيغة البث الافتراضية (xtream)", "LANGUAGE_DESCRIPTION": "اختار لغة التطبيق", "THEME_DESCRIPTION": "اختار المظهر المرئي للتطبيق", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index ac6cd8081..bfc239cc5 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Выбраць стандартны відэапрайгравальнік", "WEB_PLAYER_SHARED_CONTROLS": "Адзіныя элементы кіравання для вэб-прайгравальнікаў (эксперыментальна)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Выкарыстоўваць агульныя элементы кіравання IPTVnator у HTML5, Video.js і ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Атмасфернае запаўненне", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Запаўніць прастор вакол плэйра размытай, затемнёнай вокладкай замяст чорных палос.", "STREAM_FORMAT_DESCRIPTION": "Выберыце фармат патоку па змаўчанні (xtream)", "LANGUAGE_DESCRIPTION": "Выбраць мову праграмы", "THEME_DESCRIPTION": "Выбраць візуальную тэму афармлення", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index b9a3c2d9f..8b3a4b2cc 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Wählen Sie den Standard-Videoplayer aus", "WEB_PLAYER_SHARED_CONTROLS": "Einheitliche Steuerung für Web-Player (experimentell)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Verwendet die gemeinsamen IPTVnator-Steuerelemente in HTML5, Video.js und ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Umgebungs-Hintergrund", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Füllt den Bereich um den Player mit einem unscharfen, gedimmten Poster auf, anstelle von einfachen schwarzen Balken.", "STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)", "LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus", "THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 335cc47bb..414ea0d4b 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Επιλογή προεπιλεγμένου προγράμματος αναπαραγωγής βίντεο", "WEB_PLAYER_SHARED_CONTROLS": "Ενοποιημένα στοιχεία ελέγχου για web players (πειραματικό)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Χρησιμοποιήστε τα κοινά στοιχεία ελέγχου του IPTVnator σε HTML5, Video.js και ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Περιβαλλοντικό γέμισμα", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Γεμίστε το χώρο γύρω από τον αναπαραγωγό με θολωμένη, σκοτεινή αφίσα αντί για απλές μαύρες ράβδους.", "STREAM_FORMAT_DESCRIPTION": "Επιλογή προεπιλεγμένης μορφής ροής (xtream)", "LANGUAGE_DESCRIPTION": "Επιλέξτε τη γλώσσα της εφαρμογής", "THEME_DESCRIPTION": "Επιλέξτε οπτικό θέμα της εφαρμογής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 92d396baa..aca191d45 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Select default video player", "WEB_PLAYER_SHARED_CONTROLS": "Unified controls for web players (experimental)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Use IPTVnator's shared controls in HTML5, Video.js, and ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Ambient background fill", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.", "STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)", "LANGUAGE_DESCRIPTION": "Select language of the application", "THEME_DESCRIPTION": "Select visual theme of the application", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index c7cef836c..81b329133 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Seleccionar reproductor de vídeo predeterminado", "WEB_PLAYER_SHARED_CONTROLS": "Controles unificados para reproductores web (experimental)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa los controles compartidos de IPTVnator en HTML5, Video.js y ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Relleno de fondo ambiental", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Rellena el espacio alrededor del reproductor con una imagen de póster desenfocada y oscurecida en lugar de barras negras simples.", "STREAM_FORMAT_DESCRIPTION": "Selecciona el formato de transmision predeterminado (Xtream)", "LANGUAGE_DESCRIPTION": "Seleccione el idioma de la aplicación", "THEME_DESCRIPTION": "Seleccione el tema visual de la aplicación.", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 79e523fff..eb5e74cb1 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Sélectionnez le lecteur vidéo par défaut", "WEB_PLAYER_SHARED_CONTROLS": "Commandes unifiées pour les lecteurs web (expérimental)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Utilise les commandes partagées d’IPTVnator dans HTML5, Video.js et ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Mode ambiant", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Remplit l'espace autour du lecteur avec une affiche floue et assombrie au lieu de barres noires simples.", "STREAM_FORMAT_DESCRIPTION": "Sélectionnez le format de flux par défaut (xtream)", "LANGUAGE_DESCRIPTION": "Sélectionnez la langue de l'application", "THEME_DESCRIPTION": "Sélectionnez le thème visuel de l'application", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 0421f61d8..5e790f94b 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Seleziona lettore video predefinito", "WEB_PLAYER_SHARED_CONTROLS": "Controlli unificati per i player web (sperimentale)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa i controlli condivisi di IPTVnator in HTML5, Video.js e ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Riempimento ambientale dello sfondo", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Riempie lo spazio intorno al lettore con un poster sfocato e scurito invece delle semplici barre nere.", "STREAM_FORMAT_DESCRIPTION": "Seleziona il formato di streaming predefinito (xtream)", "LANGUAGE_DESCRIPTION": "Seleziona la lingua dell'applicazione", "THEME_DESCRIPTION": "Seleziona il tema visivo dell'applicazione", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 73ea0138e..179db53b3 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "デフォルトのビデオプレーヤーを選択", "WEB_PLAYER_SHARED_CONTROLS": "Webプレーヤーの統一コントロール(試験的)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5、Video.js、ArtPlayerでIPTVnator共通のコントロールを使用します。", + "PLAYER_AMBIENT_MODE": "プレーヤー周囲のぼかし背景", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "プレーヤーの周囲をぼかしたポスター画像で埋め、通常の黒い枠の代わりに表示します。", "STREAM_FORMAT_DESCRIPTION": "デフォルトのストリーム形式を選択(Xtream)", "LANGUAGE_DESCRIPTION": "アプリケーションの言語を選択", "THEME_DESCRIPTION": "アプリケーションの視覚テーマを選択", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index e43ddc6b3..f861aab78 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "기본 비디오 플레이어 선택", "WEB_PLAYER_SHARED_CONTROLS": "웹 플레이어 통합 컨트롤(실험적)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js 및 ArtPlayer에서 IPTVnator 공통 컨트롤을 사용합니다.", + "PLAYER_AMBIENT_MODE": "주변 배경 채우기", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "플레이어 주변을 검은 막대 대신 흐려지고 어두워진 포스터로 채웁니다.", "STREAM_FORMAT_DESCRIPTION": "기본 스트림 형식 선택 (xtream)", "LANGUAGE_DESCRIPTION": "애플리케이션 언어 선택", "THEME_DESCRIPTION": "애플리케이션의 시각적 테마 선택", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 6d05aab79..15b75ce94 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Selecteer de standaard videospeler", "WEB_PLAYER_SHARED_CONTROLS": "Uniforme bediening voor webspelers (experimenteel)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Gebruik de gedeelde IPTVnator-bediening in HTML5, Video.js en ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Ambient achtergrond", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Vult de ruimte rond de speler met een vervaagde, gedimde posterafbeelding in plaats van zwarte balken.", "STREAM_FORMAT_DESCRIPTION": "Selecteer het standaard streamformaat (xtream)", "LANGUAGE_DESCRIPTION": "Selecteer de taal van de applicatie", "THEME_DESCRIPTION": "Kies het visuele thema van de applicatie", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 14758b632..e629135ed 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Wybierz domyślny odtwarzacz wideo", "WEB_PLAYER_SHARED_CONTROLS": "Ujednolicone sterowanie odtwarzaczami internetowymi (eksperymentalne)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Używaj wspólnych elementów sterowania IPTVnator w HTML5, Video.js i ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Rozmyte tło", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Wypełnij przestrzeń wokół odtwarzacza rozmytym, przyciemnianym plakatem zamiast zwykłych czarnych pasków.", "STREAM_FORMAT_DESCRIPTION": "Wybierz domyślny format strumienia (xtream)", "LANGUAGE_DESCRIPTION": "Wybierz język aplikacji", "THEME_DESCRIPTION": "Wybierz motyw wizualny aplikacji", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 89f58e2ec..8572320aa 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Selecionar reprodutor de vídeo padrão", "WEB_PLAYER_SHARED_CONTROLS": "Controlos unificados para leitores web (experimental)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa os controlos partilhados do IPTVnator em HTML5, Video.js e ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Preenchimento de fundo ambiente", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Preenche o espaço ao redor do reprodutor com um pôster desfocado e escurecido em vez de barras pretas simples.", "STREAM_FORMAT_DESCRIPTION": "Selecionar formato de stream padrão (xtream)", "LANGUAGE_DESCRIPTION": "Selecionar idioma do aplicativo", "THEME_DESCRIPTION": "Selecionar tema visual do aplicativo", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index f0c007b9d..dc3377518 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Выбрать стандартный видео плеер", "WEB_PLAYER_SHARED_CONTROLS": "Единые элементы управления для веб-плееров (экспериментально)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Использовать общие элементы управления IPTVnator в HTML5, Video.js и ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Заливка фона за плеером", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Заполнять пространство вокруг плеера размытым затемнённым постером вместо чёрных полос.", "STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)", "LANGUAGE_DESCRIPTION": "Выбрать язык приложения", "THEME_DESCRIPTION": "Выбрать визуальную тему оформления", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index d89dbad45..ac4a86246 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Varsayılan video oynatıcıyı seçin", "WEB_PLAYER_SHARED_CONTROLS": "Web oynatıcıları için birleşik kontroller (deneysel)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js ve ArtPlayer'da IPTVnator'ın ortak kontrollerini kullan.", + "PLAYER_AMBIENT_MODE": "Ortam ışığı dolgusu", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Oynatıcının çevresindeki boşluğu siyah çubuklar yerine bulanık ve karartılmış bir poster ile doldurur.", "STREAM_FORMAT_DESCRIPTION": "Varsayılan yayın formatını seçin (xtream)", "LANGUAGE_DESCRIPTION": "Uygulamanın dilini seçin", "THEME_DESCRIPTION": "Uygulamanın görünüm temasını seçin", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 17ccb0b36..d40d29f9a 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "选择默认视频播放器", "WEB_PLAYER_SHARED_CONTROLS": "Web 播放器统一控件(实验性)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共享控件。", + "PLAYER_AMBIENT_MODE": "环境背景填充", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、暗淡的海报填充播放器周围的空白,而不是黑条纹。", "STREAM_FORMAT_DESCRIPTION": "选择默认的流格式(xtream)", "LANGUAGE_DESCRIPTION": "选择应用程序的语言", "THEME_DESCRIPTION": "选择应用程序的视觉主题", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 87d93aa43..2e048b4dd 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "選擇預設影片播放器", "WEB_PLAYER_SHARED_CONTROLS": "Web 播放器統一控制項(實驗性)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共用控制項。", + "PLAYER_AMBIENT_MODE": "環境背景填充", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、昏暗的海報填充播放器周圍的空間,取代純黑色邊框。", "STREAM_FORMAT_DESCRIPTION": "選擇預設串流格式(xtream)", "LANGUAGE_DESCRIPTION": "選擇應用程式語言", "THEME_DESCRIPTION": "選擇應用程式視覺主題", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 0348653e0..4e16c2e4b 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -121,6 +121,29 @@ Contracts: - Entering watch scrolls the shell to the top; leaving keeps the scroll position. +### Inline player stage (theater + ambient fill) + +`PortalInlinePlayerComponent` +(`libs/ui/playback/src/lib/portal-inline-player/`) wraps the projected +`WebPlayerViewComponent` in a `.player-shell__viewport` "theater stage". +The stage spans the full content width and is capped at +`min(70vh, 720px)`; on wide-short windows it becomes wider than 16:9. The +player is sized as the largest 16:9 box that fits the stage height and is +centered, so the leftover is always the stage's own black background — never +a stray strip of app surface. This is the YouTube-style letterbox and is the +default behavior for every inline engine. + +The optional `playerAmbientMode` setting (Settings → Playback, default off, +shown only for the built-in web players) renders a blurred, dimmed copy of the +poster (`ResolvedPortalPlayback.thumbnail`) behind the player via the +`--ambient-image` custom property, turning the letterbox margins into +atmosphere (YouTube "Ambient mode" / Netflix backdrops). The component also +enforces the web-player scope at runtime (HTML5, Video.js, ArtPlayer): with +Embedded MPV selected, a persisted `playerAmbientMode=true` never renders the +layer, keeping extra DOM out of the native-video compositing path. Live +channels are excluded (their `thumbnail` is a logo), and only +`http(s):`/`data:` poster URLs are accepted to avoid CSS `url()` breakout. + Season navigation inside `SeasonContainerComponent` uses season tabs (`SeasonTabsComponent`; a dropdown beyond 6 seasons) instead of the old seasons-grid + "Back to seasons" level. A season is auto-selected diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index cac28e6ba..b28de8208 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -34,19 +34,19 @@ without creating dependency cycles (`portal/shared/data-access` already depends on `portal/xtream/data-access`, so it cannot host code the Xtream store imports): -| File | Responsibility | -| --- | --- | -| `tmdb-config.ts` | API/image base URLs, embedded default API key, cache TTLs, app-language → TMDB-language mapping | -| `tmdb.types.ts` | TMDB v3 response shapes (search, details with credits) | -| `tmdb-api.service.ts` | Thin `fetch`-based client (TMDB supports CORS; works in Electron renderer and PWA). Accepts v3 keys (`api_key` param) and v4 tokens (Bearer) | -| `tmdb-matcher.ts` | Title normalization, year extraction, and the match-confidence gate (pure functions) | -| `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory LRU capped at 300 entries) with caller-supplied TTLs | -| `tmdb-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) | -| `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution | -| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups | -| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:` rows) | -| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:\|season:` rows) | -| `tmdb-trending.service.ts` | Weekly trending (movie + tv merged by popularity, `trending:week` rows, 1-day TTL) | +| File | Responsibility | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `tmdb-config.ts` | API/image base URLs, embedded default API key, cache TTLs, app-language → TMDB-language mapping | +| `tmdb.types.ts` | TMDB v3 response shapes (search, details with credits) | +| `tmdb-api.service.ts` | Thin `fetch`-based client (TMDB supports CORS; works in Electron renderer and PWA). Accepts v3 keys (`api_key` param) and v4 tokens (Bearer) | +| `tmdb-matcher.ts` | Title normalization, year extraction, and the match-confidence gate (pure functions) | +| `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory LRU capped at 300 entries) with caller-supplied TTLs | +| `tmdb-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) | +| `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution | +| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups | +| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:` rows) | +| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:\|season:` rows) | +| `tmdb-trending.service.ts` | Weekly trending (movie + tv merged by popularity, `trending:week` rows, 1-day TTL) | Integration glue per portal: @@ -101,7 +101,7 @@ The year filter is applied client-side rather than via TMDB's strict when the provider's year is off by one. **Non-Latin titles**: TMDB matches translated titles but returns `title` in -the *request* language, so a Cyrillic query issued with `en-US` would come +the _request_ language, so a Cyrillic query issued with `en-US` would come back with an English title and fail the exact-match gate. `tmdbSearchLanguageForTitle` detects Cyrillic queries and issues the search with `ru-RU` (unless the app language is already Cyrillic-based); details @@ -216,7 +216,7 @@ Single table with two row kinds discriminated by `lookup_key` prefix: tmdb_metadata ( media_type 'movie' | 'tv' | 'person', lookup_key 'id:' -- details payload row - 'title:|year:' -- search resolution row + 'title:|year:|v2' -- search resolution row 'person:' -- person payload row language TEXT, -- TMDB language code tmdb_id INTEGER, -- NULL on a search row = negative cache @@ -229,6 +229,12 @@ tmdb_metadata ( TTLs (enforced at read time in `TmdbCacheService.isFresh`): details and positive matches 30 days, negative matches 7 days. +Search keys carry a version suffix so normalization changes cannot reuse stale +positive or negative resolutions. Database startup deletes the obsolete +unversioned search rows once and records +`migration:tmdb-search-lookup-v2-cache-cleanup:v1` in `app_state`; details and +person cache rows are unaffected. + Electron IPC path (follows the standard DB worker contract, see [SQLite DB Worker](./sqlite-db-worker.md)): diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index ef5eb5fe0..1f43e8940 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -145,6 +145,24 @@ describe('SettingsStore dashboard rail settings', () => { expect(store.getSettings().stripCountryPrefix).toBe(false); }); + it('defaults ambient player mode to false when the stored field is missing', async () => { + storedSettings = {}; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + + expect(store.getSettings().playerAmbientMode).toBe(false); + }); + + it('restores a persisted true ambient player mode preference', async () => { + storedSettings = { playerAmbientMode: true }; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + + expect(store.getSettings().playerAmbientMode).toBe(true); + }); + it('restores a persisted true strip country prefix preference', async () => { storedSettings = { stripCountryPrefix: true, diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 9ed1187dc..8999ba85e 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -27,6 +27,7 @@ import { const DEFAULT_SETTINGS: Settings = { player: VideoPlayer.VideoJs, webPlayerSharedControls: false, + playerAmbientMode: false, streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, @@ -188,6 +189,9 @@ export const SettingsStore = signalStore( player: store.player(), webPlayerSharedControls: store.webPlayerSharedControls?.() === true, + playerAmbientMode: + store.playerAmbientMode?.() ?? + DEFAULT_SETTINGS.playerAmbientMode, streamFormat: store.streamFormat(), openStreamOnDoubleClick: store.openStreamOnDoubleClick(), language: store.language(), diff --git a/libs/shared/database/src/lib/connection-migrations.spec.ts b/libs/shared/database/src/lib/connection-migrations.spec.ts index 07a0485fc..ed067e7a9 100644 --- a/libs/shared/database/src/lib/connection-migrations.spec.ts +++ b/libs/shared/database/src/lib/connection-migrations.spec.ts @@ -6,6 +6,7 @@ const { createTableStatements, indexMigrationStatements, runMigrations, + cleanupLegacyTmdbSearchCache, } = __databaseConnectionTestHooks; type SqliteHandle = Parameters[0]; @@ -22,10 +23,7 @@ type StatementHandler = { type HandlerRule = [pattern: string, handler: StatementHandler]; -function createSqliteMock( - rules: HandlerRule[], - exec: jest.Mock = jest.fn() -) { +function createSqliteMock(rules: HandlerRule[], exec: jest.Mock = jest.fn()) { const prepare = jest.fn((statement: string) => { const compact = compactSql(statement); const rule = rules.find(([pattern]) => compact.includes(pattern)); @@ -129,6 +127,45 @@ describe('runMigrations error tolerance', () => { }); }); +describe('TMDB search lookup v2 cache cleanup', () => { + it('deletes legacy search rows and records the migration atomically', () => { + const deleteRun = jest.fn(); + const markerRun = jest.fn(); + const { sqlite, transaction } = createSqliteMock([ + ['SELECT value FROM app_state', { get: () => undefined }], + ['DELETE FROM tmdb_metadata', { run: deleteRun }], + ['INSERT INTO app_state', { run: markerRun }], + ]); + + cleanupLegacyTmdbSearchCache(sqlite); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(deleteRun).toHaveBeenCalledTimes(1); + expect(markerRun).toHaveBeenCalledWith( + 'migration:tmdb-search-lookup-v2-cache-cleanup:v1' + ); + const deleteSql = compactSql( + (sqlite.prepare as jest.Mock).mock.calls.find(([statement]) => + statement.includes('DELETE FROM tmdb_metadata') + )?.[0] + ); + expect(deleteSql).toContain( + "lookup_key LIKE 'title:%|year:%' AND lookup_key NOT LIKE 'title:%|year:%|v%'" + ); + }); + + it('does nothing after the migration has completed', () => { + const { sqlite, prepare, transaction } = createSqliteMock([ + completedMigrationStateRule, + ]); + + cleanupLegacyTmdbSearchCache(sqlite); + + expect(transaction).not.toHaveBeenCalled(); + expect(prepare).toHaveBeenCalledTimes(1); + }); +}); + describe('runMigrations Xtream cache deduplication', () => { it('re-points content to the canonical duplicate category before deleting the rest', () => { const candidatesAll = jest.fn(() => [ @@ -141,7 +178,11 @@ describe('runMigrations Xtream cache deduplication', () => { completedMigrationStateRule, [ 'FROM categories GROUP BY playlist_id, type, xtream_id', - { all: () => [{ playlistId: 'p1', type: 'live', xtreamId: 5 }] }, + { + all: () => [ + { playlistId: 'p1', type: 'live', xtreamId: 5 }, + ], + }, ], ['LEFT JOIN content', { all: candidatesAll }], [ @@ -149,7 +190,10 @@ describe('runMigrations Xtream cache deduplication', () => { { run: updateContentRun }, ], ['DELETE FROM categories WHERE id = ?', { run: deleteCategoryRun }], - ['FROM content GROUP BY category_id, type, xtream_id', { all: () => [] }], + [ + 'FROM content GROUP BY category_id, type, xtream_id', + { all: () => [] }, + ], ]); runMigrations(sqlite); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 7bc8ca437..ed0c854e7 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -30,6 +30,8 @@ const CONTENT_TITLE_FTS_MIGRATION_KEY = 'migration:content-title-fts-trigram:v1'; const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY = 'migration:epg-program-source-url-backfill:v1'; +const TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY = + 'migration:tmdb-search-lookup-v2-cache-cleanup:v1'; const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000; function readTraceFlag(name: string): boolean { @@ -373,6 +375,7 @@ export const __databaseConnectionTestHooks = { normalizeXtreamContentAddedEpochs, ensureContentTitleFts, backfillEpgProgramSourceUrls, + cleanupLegacyTmdbSearchCache, runMigrations, } as const; @@ -768,11 +771,61 @@ function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void { } } +/** + * Search-match cache keys gained a v2 suffix when title normalization changed. + * Remove the now-unreachable unversioned rows once rather than leaving negative + * resolutions and other legacy search matches in long-lived installations. + */ +function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void { + try { + const migrationState = sqliteDb + .prepare(`SELECT value FROM app_state WHERE key = ?`) + .get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as + | { value?: unknown } + | undefined; + + if (migrationState?.value === 'done') { + return; + } + + const executeCleanup = sqliteDb.transaction(() => { + sqliteDb + .prepare( + `DELETE FROM tmdb_metadata + WHERE lookup_key LIKE 'title:%|year:%' + AND lookup_key NOT LIKE 'title:%|year:%|v%'` + ) + .run(); + sqliteDb + .prepare( + `INSERT INTO app_state (key, value, updated_at) + VALUES (?, 'done', datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at` + ) + .run(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY); + }); + + executeCleanup(); + } catch (error) { + const message = + typeof error === 'object' && error !== null && 'message' in error + ? String((error as { message?: unknown }).message ?? error) + : String(error); + + console.warn( + `Legacy TMDB search cache cleanup failed (continuing): ${message}` + ); + } +} + /** * Run migrations that may fail if already applied */ function runMigrations(sqliteDb: Database.Database): void { widenTmdbMetadataMediaTypeCheck(sqliteDb); + cleanupLegacyTmdbSearchCache(sqliteDb); runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS); ensureContentTitleFts(sqliteDb); deduplicateXtreamCache(sqliteDb); diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 2a86df369..f9ff0f632 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -83,6 +83,12 @@ export interface Settings { * Missing values remain off for compatibility with older saved settings. */ webPlayerSharedControls?: boolean; + /** + * Fill the empty space around the inline VOD/series player with a blurred, + * dimmed copy of the poster (YouTube "Ambient mode" style) instead of plain + * black bars. Off by default; only affects the built-in web players. + */ + playerAmbientMode?: boolean; epgUrl: string[]; streamFormat: StreamFormat; openStreamOnDoubleClick: boolean; 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 8b9c597ed..868f571c9 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 @@ -50,6 +50,13 @@
+ @if (ambientEnabled()) { + + } app-web-player-view { + position: relative; + z-index: 1; display: block; - width: 100%; + flex: 0 0 auto; height: 100%; + width: auto; + max-width: 100%; + aspect-ratio: 16 / 9; +} + +// "Ambient mode": a blurred, dimmed copy of the poster fills the whole stage +// behind the player, turning the letterbox margins into atmosphere. +.player-shell__ambient { + position: absolute; + inset: 0; + z-index: 0; + background-image: var(--ambient-image); + background-position: center; + background-size: cover; + filter: blur(34px) brightness(0.5) saturate(1.15); + transform: scale(1.18); + pointer-events: none; } :host-context(.dark-theme) .player-shell { diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts index cd31802ac..7912f5613 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts @@ -202,4 +202,115 @@ describe('PortalInlinePlayerComponent', () => { expect(component.title()).toBe('US | Some Movie'); }); }); + + describe('ambient background fill', () => { + async function setup( + ambientEnabled: boolean, + player = 'videojs' + ): Promise { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ + PortalInlinePlayerComponent, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: SettingsStore, + useValue: { + player: signal(player), + playerAmbientMode: signal(ambientEnabled), + stripCountryPrefix: signal(false), + }, + }, + ], + }) + .overrideComponent(PortalInlinePlayerComponent, { + remove: { imports: [WebPlayerViewComponent] }, + add: { imports: [StubWebPlayerViewComponent] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(PortalInlinePlayerComponent); + component = fixture.componentInstance; + } + + const ambientEl = () => + fixture.nativeElement.querySelector('.player-shell__ambient'); + + it('renders a poster-backed ambient layer for VOD when enabled', async () => { + await setup(true); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mp4', + title: 'Some Movie', + thumbnail: 'https://cdn.example.com/poster.jpg', + }); + fixture.detectChanges(); + + const layer = ambientEl() as HTMLElement | null; + expect(component.ambientEnabled()).toBe(true); + expect(layer).toBeTruthy(); + expect(layer?.style.getPropertyValue('--ambient-image')).toBe( + 'url("https://cdn.example.com/poster.jpg")' + ); + }); + + it('does not render the ambient layer when the setting is off', async () => { + await setup(false); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mp4', + title: 'Some Movie', + thumbnail: 'https://cdn.example.com/poster.jpg', + }); + fixture.detectChanges(); + + expect(component.ambientEnabled()).toBe(false); + expect(ambientEl()).toBeNull(); + }); + + it('skips the ambient layer for live channels and without a poster', async () => { + await setup(true); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/live.m3u8', + title: 'CNN', + thumbnail: 'https://cdn.example.com/logo.png', + isLive: true, + }); + fixture.detectChanges(); + expect(ambientEl()).toBeNull(); + + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mp4', + title: 'No Poster Movie', + }); + fixture.detectChanges(); + expect(ambientEl()).toBeNull(); + }); + + it('keeps the ambient layer off for non-web engines like embedded MPV', async () => { + await setup(true, 'embedded-mpv'); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mp4', + title: 'Some Movie', + thumbnail: 'https://cdn.example.com/poster.jpg', + }); + fixture.detectChanges(); + + expect(component.ambientEnabled()).toBe(false); + expect(ambientEl()).toBeNull(); + }); + + it('rejects non-http(s) poster URLs to avoid CSS breakout', async () => { + await setup(true); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mp4', + title: 'Sneaky', + thumbnail: 'javascript:alert(1)', + }); + fixture.detectChanges(); + + expect(component.ambientImageStyle()).toBeNull(); + expect(ambientEl()).toBeNull(); + }); + }); }); 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 8a2c72158..5a1ba0c15 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 @@ -14,6 +14,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { PlayerContentInfo, ResolvedPortalPlayback, + VideoPlayer, } from '@iptvnator/shared/interfaces'; import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util'; import { SettingsStore } from '@iptvnator/services'; @@ -58,6 +59,44 @@ export class PortalInlinePlayerComponent { ); readonly streamUrl = computed(() => this.playback()?.streamUrl ?? ''); readonly startTime = computed(() => this.playback()?.startTime ?? 0); + /** + * Poster used for the "Ambient mode" fill behind the player. Live channels + * carry logos rather than posters, so they are excluded. + */ + private readonly ambientImageUrl = computed(() => { + const playback = this.playback(); + if (!playback || playback.isLive) { + return null; + } + + return playback.thumbnail ?? null; + }); + /** Safe `url(...)` value, or null when the poster URL is not a plain http/data URL. */ + readonly ambientImageStyle = computed(() => { + const url = this.ambientImageUrl(); + if (!url || !/^(https?:|data:)/i.test(url)) { + return null; + } + + const safe = url.replace(/"/g, '%22').replace(/\\/g, '%5C'); + return `url("${safe}")`; + }); + readonly ambientEnabled = computed(() => { + // Web players only — mirrors the settings UI, which offers the toggle + // for HTML5, Video.js, and ArtPlayer. Embedded MPV composites a native + // video layer, so an extra DOM layer behind it stays out of the mix. + const player = this.settingsStore.player?.(); + const isWebPlayer = + player === VideoPlayer.VideoJs || + player === VideoPlayer.Html5Player || + player === VideoPlayer.ArtPlayer; + + return ( + isWebPlayer && + this.settingsStore.playerAmbientMode?.() === true && + !!this.ambientImageStyle() + ); + }); readonly contentInfo = computed( () => this.playback()?.contentInfo ); From 59c15493a700dde93b987c1f0332ad295a2f9018 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 24 Jul 2026 08:13:43 +0200 Subject: [PATCH 21/26] docs: sync CLAUDE.md, AGENTS.md and architecture docs with actual code Full audit of CLAUDE.md, AGENTS.md, README.md and docs/architecture/ against the codebase; every fix is backed by current code: - remove documented-but-unimplemented IPTVNATOR_DISABLE_HARDWARE_ACCELERATION flag (no reads anywhere in apps/, libs/, tools/) - CLAUDE.md: add epg_channel_mappings to the schema table list - m3u-playlist-module: *-tab dirs -> *-view (+recent-view), selectActivePlaylist, real PlaylistState shape, ChannelEpgMetadata instead of removed EnrichedChannel, actual /workspace/playlists routes, per-view outputs, live-epg-panel-state key - workspace-dashboard: per-rail Settings.dashboardRails toggles, three missing rails in the diagram, split live-favorites/recent-live rails, welcome-dashboard empty-state type, RECENTLY_WATCHED_LIVE_TV title key - stalker-portal: CategoryContentViewComponent for vod/series, collection-route components for favorites/recent, corrected series-view/favorites-button paths, actor/:personId route, epg panel selectors - category-management: reloadCategories lives in with-content.feature.ts, workspace-context-panel owns the dialog, XtreamPendingRestoreService flow - stalker-mock-server (+app README): scenario-seeded faker, resetAll() clears content cache too, ordinal season episode ids, handlers/ dir location - sqlite-db-worker: cancellation shipped (drop from out-of-scope), full operations module list - portal-detail-navigation: replace three removed component paths - tmdb-metadata-enrichment: details cache keys are id:|v2 - electron-security: CSP frame-src youtube-nocookie exception, sandbox: !frameCopyExperiment nuance - download-manager: libs/portal/xtream instead of xtream-electron folder, data-driven downloads nav, drop removed app-search-result-item note - playlist-backup-restore: settings-backup facade owns the import handoff - workspace-shell: functional workspaceEntryRedirect, playlists route children - iptvnator-ui-guidelines: EPG card radius 11px, detail-view mixin is `base` - embedded-mpv-native, player-controls-contract: minor precision fixes Co-Authored-By: Claude Fable 5 --- AGENTS.md | 6 - CLAUDE.md | 7 +- README.md | 7 -- apps/stalker-mock-server/README.md | 27 ++--- docs/architecture/category-management.md | 32 +++--- docs/architecture/download-manager.md | 10 +- docs/architecture/electron-security.md | 16 ++- docs/architecture/embedded-mpv-native.md | 8 +- docs/architecture/iptvnator-ui-guidelines.md | 11 +- docs/architecture/m3u-playlist-module.md | 105 +++++++++--------- docs/architecture/player-controls-contract.md | 1 + docs/architecture/playlist-backup-restore.md | 8 +- docs/architecture/portal-detail-navigation.md | 5 +- docs/architecture/sqlite-db-worker.md | 24 +++- docs/architecture/stalker-mock-server.md | 18 ++- docs/architecture/stalker-portal.md | 23 ++-- docs/architecture/tmdb-metadata-enrichment.md | 9 +- docs/architecture/workspace-dashboard.md | 53 ++++++--- docs/architecture/workspace-shell.md | 9 +- 19 files changed, 217 insertions(+), 162 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 01f664e84..a910c9b14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,12 +77,6 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend `@iptvnator/shared/logging` or the redacting portal logger before reaching `console.*`; never log raw credentials while debugging. -- GPU/compositor debugging: - -```bash -IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend -``` - - If local Nx state gets weird before a rerun: ```bash diff --git a/CLAUDE.md b/CLAUDE.md index 659ab4a3a..b5d2fedc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,12 +134,6 @@ Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching `console.*`; never log raw credentials while debugging. -For GPU/compositor debugging: - -```bash -IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend -``` - If the Nx daemon gets into a bad state before rerunning Electron: ```bash @@ -578,6 +572,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - `favorites` - User favorites - `recentlyViewed` - Watch history - `epgChannels`, `epgPrograms` - Persisted EPG data + - `epgChannelMappings` (`epg_channel_mappings`) - Manual EPG channel mappings (defined in `epg-mapping.schema.ts`, re-exported by `schema.ts`) - `playbackPositions` - Resume positions - `downloads` - Download manager state - `appState` - Key-value app state (also tracks one-off data migrations) diff --git a/README.md b/README.md index d06f5ae1a..b4b810a7b 100644 --- a/README.md +++ b/README.md @@ -300,13 +300,6 @@ This redirects the SQLite database, Electron user data, and local config under the given directory. Delete that directory whenever you want a fresh empty state. -If you need to debug renderer freezes or GPU/compositor issues in Electron, you -can disable hardware acceleration for a run: - -``` -$ IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 pnpm run serve:backend -``` - If you need startup diagnostics for a white screen or a frozen route, you can also turn on opt-in Electron tracing. These logs are written to the Electron terminal output so they still help when the renderer DevTools never open: diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index fbcda2e2e..8a299a823 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -127,19 +127,20 @@ apps/stalker-mock-server/ │ ├── scenarios.ts # MAC → scenario config mapping │ ├── data-generator.ts # Seeded faker data generation │ ├── data-store.ts # Lazy per-MAC in-memory cache -│ └── routes/ -│ ├── portal.route.ts # /portal.php dispatcher -│ └── handlers/ -│ ├── handshake.handler.ts -│ ├── do-auth.handler.ts -│ ├── get-categories.handler.ts -│ ├── get-ordered-list.handler.ts -│ ├── get-seasons.handler.ts -│ ├── create-link.handler.ts -│ ├── favorites.handler.ts -│ ├── get-epg-info.handler.ts -│ ├── get-short-epg.handler.ts -│ └── get-genres.handler.ts +│ ├── routes/ +│ │ ├── portal.route.ts # /portal.php route +│ │ └── dispatch.ts # Shared Stalker action dispatcher +│ └── handlers/ +│ ├── handshake.handler.ts +│ ├── do-auth.handler.ts +│ ├── get-categories.handler.ts +│ ├── get-ordered-list.handler.ts +│ ├── get-seasons.handler.ts +│ ├── create-link.handler.ts +│ ├── favorites.handler.ts +│ ├── get-epg-info.handler.ts +│ ├── get-short-epg.handler.ts +│ └── get-genres.handler.ts ├── project.json ├── tsconfig.json └── README.md diff --git a/docs/architecture/category-management.md b/docs/architecture/category-management.md index c9aaeaddb..77e360629 100644 --- a/docs/architecture/category-management.md +++ b/docs/architecture/category-management.md @@ -75,9 +75,9 @@ ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0 ### Store -**File**: `libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts` +**File**: `libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts` -Added `reloadCategories()` method to refresh categories from database after visibility changes, ensuring the sidebar updates immediately. +`reloadCategories()` (exposed on the `XtreamStore` facade via feature composition) refreshes categories from the database after visibility changes, ensuring the sidebar updates immediately. ## Behavior Notes @@ -133,24 +133,28 @@ apps/electron-backend/src/app/ libs/services/src/lib/ └── database-electron.service.ts # Service methods (with hidden category support) -libs/ui/components/src/lib/recent-playlists/ -└── recent-playlists.component.ts # Stores hidden categories to localStorage on refresh +libs/playlist/shared/ui/src/lib/ +├── recent-playlists/ +│ └── recent-playlists.component.ts # Persists hidden categories (restore state) via XtreamPendingRestoreService on refresh +└── playlist-refresh-action.service.ts # Same restore-state persistence for the header refresh action + +libs/services/src/lib/ +└── xtream-pending-restore.service.ts # localStorage keyed `xtream-restore-{playlistId}` + +libs/workspace/shell/feature/src/lib/ +└── workspace-context-panel/ + └── workspace-context-panel.component.ts # Tune button; lazy-loads the dialog, calls reloadCategories() libs/portal/xtream/feature/src/lib/ -├── category-management-dialog/ # Dialog component -│ ├── category-management-dialog.component.ts -│ ├── category-management-dialog.component.html -│ └── category-management-dialog.component.scss -├── xtream-main-container.component.ts # Added button & dialog -├── xtream-main-container.component.html -├── live-stream-layout/ -│ ├── live-stream-layout.component.ts # Added button & dialog -│ └── live-stream-layout.component.html +└── category-management-dialog/ # Dialog component + ├── category-management-dialog.component.ts + ├── category-management-dialog.component.html + └── category-management-dialog.component.scss libs/portal/xtream/data-access/src/lib/ ├── data-sources/ │ └── electron-xtream-data-source.ts # Reads/passes hidden categories on save -└── stores/xtream.store.ts # Added reloadCategories method +└── stores/features/with-content.feature.ts # reloadCategories() (exposed on XtreamStore) apps/web/src/assets/i18n/ └── en.json # Added translation keys diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 1db7fca8c..0608f1fb1 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -1,11 +1,11 @@ # Download Manager Architecture -The download manager is a desktop-only feature that layers a curated queue, progress tracking, storage configuration, and playback controls on top of the existing Xtream (xtream-electron folder) + Stalker viewers. Backend work is handled in the Electron process while the Angular renderer surface exposes a dedicated `/downloads` route, contextual buttons, and theme-aware styling. +The download manager is a desktop-only feature that layers a curated queue, progress tracking, storage configuration, and playback controls on top of the existing Xtream (`libs/portal/xtream`) + Stalker (`libs/portal/stalker`) portal views. Backend work is handled in the Electron process while the Angular renderer surface exposes a dedicated `/downloads` route, contextual buttons, and theme-aware styling. ## Backend responsibilities - **Queue control (`apps/electron-backend/src/app/events/database/download-runtime.ts`)** - `DownloadTask` mirrors the shared `DownloadItem` table plus transient cancel/progress helpers. Request validation and row creation live in `download-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. + `DownloadTask` mirrors a row of the shared `downloads` table (type `Download` in `libs/shared/database/src/lib/schema.ts`) plus transient cancel/progress helpers. Request validation and row creation live in `download-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. - **electron-dl integration** `startDownload()` calls `electron-dl`'s `download()` helper. Headers (user agent, referer, origin) are attached, and the `onStarted`, `onProgress`, `onCompleted`, and `onCancel` callbacks translate the helper's payload into Drizzle updates. A cancellation requested before `onStarted` is remembered and applied as soon as Electron supplies the `DownloadItem`, so the request cannot be lost in the startup race. - **Destination collision policy** @@ -24,10 +24,8 @@ The download manager is a desktop-only feature that layers a curated queue, prog - **Downloads service** (`libs/services/src/lib/downloads.service.ts`) Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download the service asks the main process for the authorized folder and calls `downloadsStart`. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path. - **Downloads view** (`libs/portal/downloads/feature`) - A standalone page exposes the queue, desktop-only messaging, folder picker, and action buttons. `downloads.component.html` now wraps the list inside a scrollable panel (`downloads__list-wrapper`) so long queues stay reachable, and `downloads.component.scss` drives a bold two-tone aesthetic inspired by the frontend-design mandate—gradient cards, floating avatars, and theme-aware variables triggered via `body.dark-theme`. + A standalone page exposes the queue, desktop-only messaging, folder picker, and action buttons. `downloads.component.html` wraps the list inside a scrollable panel (`downloads__list-wrapper`) so long queues stay reachable, and `downloads.component.scss` drives gradient cards with theme-aware styling through Angular Material system CSS variables (`var(--mat-sys-*)`, `var(--app-*)`, `color-mix`) — theming tracks the active Material theme rather than a `body.dark-theme` hook. Failed/canceled cards now show retry/delete controls, queued/downloading cards show a cancel icon, and completed cards render inline play/open buttons with `mat-icon` cues. The header also shows the resolved download folder and a `CHANGE FOLDER` action. -- **Theme fixes** - To keep typography legible in both modes, `app-search-result-item` now inherits color from `:host-context(body.dark-theme)` and `:host-context(body:not(.dark-theme))`, ensuring dense light-theme grids no longer show white text on white backgrounds. ## Global API surface @@ -37,7 +35,7 @@ The download manager is a desktop-only feature that layers a curated queue, prog ## Routing and navigation - `/downloads` is available under both portal flavors: the Xtream routes already load `DownloadsComponent`, and the Stalker routes now import the same component so the sidebar link can target `/stalker/:id/downloads` without returning to the startup screen. -- The navigation component already points `routerLink="./downloads"` inside the shared nav pane, so both portals reuse the same download page. +- Downloads navigation is data-driven: `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts` emits a `downloads` section link (`path: [...root, 'downloads']`) for both portals, so they reuse the same download page. ## Queuing, persistence, and UX notes diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 0f33d310f..d5c2f16db 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -9,7 +9,9 @@ explicit hardened `webPreferences` object: - `contextIsolation: true` - `nodeIntegration: false` -- `sandbox: true` +- `sandbox: !frameCopyExperiment` — `true` by default; the opt-in Embedded MPV + frame-copy experiment is the one path that disables the renderer sandbox + (`contextIsolation`/`nodeIntegration` stay hardened regardless) - `webSecurity: true` - `preload: apps/electron-backend/src/app/api/main.preload.ts` @@ -98,11 +100,13 @@ produce both `dmg` and `zip` targets, and publish a single merged The Angular shell defines a baseline CSP in `apps/web/src/index.html`. -The policy keeps the application self-hosted for scripts, blocks object and -frame embedding, limits forms to the app origin, and allows IPTV playback -sources through `media-src` and `connect-src` for `http:`, `https:`, `blob:`, -and `data:`. The policy keeps `script-src` self-hosted and currently keeps -`unsafe-inline` for existing inline styles. +The policy keeps the application self-hosted for scripts, blocks object +embedding (`object-src 'none'`) while allowing frames only from +`https://www.youtube-nocookie.com` (`frame-src`, used for TMDB trailers), +limits forms to the app origin, and allows IPTV playback sources through +`media-src` and `connect-src` for `http:`, `https:`, `blob:`, and `data:`. The +policy keeps `script-src` self-hosted and currently keeps `unsafe-inline` for +existing inline styles. Angular production builds must not rely on inline event handlers for stylesheet activation. Keep `web:build:production` and `web:build:pwa` configured without diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 6adbbaabf..c143db405 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -209,8 +209,9 @@ the frame-copy canvas. embedded MPV experiment flag) switches macOS/arm64, Linux and Windows to a second rendering engine that replaces the native-view compositing entirely (gate: `isFrameCopyPlatformSupported()` in -`embedded-mpv-frame-copy-platform.util.ts`, shared by `main.ts`, the -service and the adapter): +`embedded-mpv-frame-copy-platform.util.ts`; the adapter imports it directly, +while `main.ts` and the service call it transitively through the same util +module's `isFrameCopyRuntimeUsable()` / `getFrameCopyRuntimeAvailability()`): - `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper`, a one-process-per-session libmpv host. It decodes (hwdec), renders @@ -259,7 +260,8 @@ service and the adapter): bounds sync. Enabling it: the `Settings > Playback > Embedded MPV: frame-copy engine` -checkbox (shown only when support reports `frameCopyAvailable`) persists to +checkbox (shown when support reports `frameCopyAvailable` or the option is +already enabled, so it stays visible for turning off) persists to the main-process config store (`electron-conf`), which `main.ts` reads before creating the window and translates into the env flag; an explicitly set env var (including `0`) wins over the stored preference, but cannot bypass the diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index d0d3f9f29..6fb85b0b2 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -88,10 +88,10 @@ Do not add extra badges, left rails, or second selection systems unless there is ## Detail Views -VOD and series detail screens share the `detail-view` Sass mixin from -`libs/ui/styles/_detail-view.scss`. Feature-local `styles/detail-view.scss` -files should only import that mixin and pass small typography overrides when a -provider needs them. +VOD and series detail screens share the detail-view Sass mixin (`@mixin base`) +from `libs/ui/styles/_detail-view.scss`. Feature-local `styles/detail-view.scss` +files should only `@use` that module and `@include detail-view.base(...)` with +small typography overrides when a provider needs them. Do not copy the full detail-view stylesheet into feature libraries. Add shared layout changes to the mixin, and keep provider-specific differences explicit in @@ -201,7 +201,8 @@ The shared row should be reused instead of rebuilding channel markup per view. ### EPG Card - Radius: - `14px` + `11px` (`.epg-timeline__block` in + `libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss`) - Neutral cards use low-contrast surface treatment - Current card uses selection surface and selection border - Description should clamp rather than overflow diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 85847412d..d4c8ba9ea 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -60,31 +60,23 @@ The behavioral contract is guarded by `apps/web/src/app/iptv-playlist-parser.con ### State Structure ```typescript +// libs/m3u-state/src/lib/state.ts interface PlaylistState { - // Active channel being played - active: Channel | undefined; + active: Channel | undefined; // Active channel being played + activePlaybackUrl: string | null; + activeEpgProgram: EpgProgram | undefined; + currentEpgProgram: EpgProgram | undefined; + epgAvailable: boolean; + channelsLoading: boolean; // Route still resolving channel data + channels: Channel[]; // All channels from current playlist + playlists: PlaylistMetaState; // Playlist metadata (entity adapter) +} - // Whether the current route is still resolving channel data - channelsLoading: boolean; - - // All channels from current playlist - channels: Channel[]; - - // EPG state - epg: { - epgAvailable: boolean; - activeEpgProgram: EpgProgram | undefined; - currentEpgProgram: EpgProgram | undefined; - }; - - // Playlist metadata (entity adapter) - playlistsMeta: { - ids: string[]; - entities: Record; - selectedId: string | undefined; - allPlaylistsLoaded: boolean; - selectedFilters: PlaylistSourceFilter[]; - }; +// libs/m3u-state/src/lib/playlists.state.ts +interface PlaylistMetaState extends EntityState { + selectedId: string; + allPlaylistsLoaded: boolean; + selectedFilters: string[]; // 'm3u' | 'xtream' | 'stalker' } ``` @@ -114,7 +106,7 @@ selectFavorites; // Favorite channel URLs // Playlist selectors selectAllPlaylistsMeta; // All playlists selectActivePlaylistId; // Selected playlist ID -selectCurrentPlaylist; // Active playlist object +selectActivePlaylist; // Active playlist object selectPlaylistTitle; // Title with "Global favorites" fallback // EPG selectors @@ -134,20 +126,25 @@ channel-list-container/ ├── channel-list-container.component.html ├── channel-list-container.component.scss │ -├── all-channels-tab/ # Virtual scroll + search -│ ├── all-channels-tab.component.ts -│ ├── all-channels-tab.component.html -│ └── all-channels-tab.component.scss +├── all-channels-view/ # Virtual scroll + debounced search +│ ├── all-channels-view.component.ts +│ ├── all-channels-view.component.html +│ └── all-channels-view.component.scss │ -├── groups-tab/ # Expansion panels + infinite scroll -│ ├── groups-tab.component.ts -│ ├── groups-tab.component.html -│ └── groups-tab.component.scss +├── groups-view/ # Expansion panels + infinite scroll +│ ├── groups-view.component.ts +│ ├── groups-view.component.html +│ └── groups-view.component.scss │ -├── favorites-tab/ # Drag-drop reordering -│ ├── favorites-tab.component.ts -│ ├── favorites-tab.component.html -│ └── favorites-tab.component.scss +├── favorites-view/ # Drag-drop reordering +│ ├── favorites-view.component.ts +│ ├── favorites-view.component.html +│ └── favorites-view.component.scss +│ +├── recent-view/ # Recently viewed channels +│ ├── recent-view.component.ts +│ ├── recent-view.component.html +│ └── recent-view.component.scss │ └── channel-list-item/ # Individual channel display ├── channel-list-item.component.ts @@ -235,14 +232,17 @@ channel-list-container/ rendering. Playlist order avoids cloning the full list when no search term is active. -### EnrichedChannel Pattern +### ChannelEpgMetadata Pattern -For performance optimization, channels are pre-enriched with EPG data: +For performance optimization, EPG data is kept in a side-car map instead of +being cloned onto every channel (the older `EnrichedChannel` pattern that +spread-cloned every channel on every ~30 s tick was removed — +`channel-list-container/epg-enrichment.util.ts`): ```typescript -interface EnrichedChannel extends Channel { +// libs/ui/components/src/lib/channel-list-container/epg-enrichment.util.ts +interface ChannelEpgMetadata { epgProgram: EpgProgram | null | undefined; - logo: string; // Playlist tvg-logo first, XMLTV icon fallback second progressPercentage: number; // Pre-computed by parent } ``` @@ -354,7 +354,7 @@ activation, and the details dialog behave identically to the timeline. `isLivePlayback`, `loading`, `emptyReason`, `selectedDate`, `collapsed`, `summary` and emits `programActivated`, `returnToLive`, `selectedDateChange`, `openEpgSettings`, `retry`, `collapsedChange`. The host layout owns playback, - persists the collapse state (`liveEpgPanelState` in localStorage), and (for + persists the collapse state (`live-epg-panel-state` in localStorage), and (for the M3U player) the `EpgActions.setCurrentEpgProgram` / `setEpgAvailableFlag` / `setActiveEpgProgram` dispatches. The timeline owns the **single** panel bar — collapse chevron + channel name on the left, return-to-live / jump / @@ -557,25 +557,25 @@ These URLs are playlist-scoped by default: #### AllChannelsViewComponent - **Inputs**: `channels`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `itemSize`, `activeChannelUrl`, `favoriteIds` -- **Outputs**: `channelSelected`, `favoriteToggled` +- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `favoriteToggled`, `sidebarToggleRequested` - **Features**: Workspace search, persisted channel sorting, virtual scrolling, no-results placeholder #### GroupsViewComponent -- **Inputs**: Same as AllChannelsTab + `groupedChannels` -- **Outputs**: `channelSelected`, `favoriteToggled` +- **Inputs**: Same as AllChannelsViewComponent + `groupedChannels` +- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `favoriteToggled`, `hiddenGroupTitlesChanged`, sidebar sizing outputs - **Features**: Resizable groups rail, local group search, group visibility management, persisted selected-group channel sorting #### FavoritesViewComponent - **Inputs**: `favorites`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl` -- **Outputs**: `channelSelected`, `favoriteToggled`, `favoritesReordered` +- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `favoriteToggled`, `favoritesReordered` - **Features**: Drag-and-drop reordering with CDK DragDrop, read-only channel details context menu #### RecentViewComponent - **Inputs**: recent channels, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl` -- **Outputs**: `channelSelected`, `favoriteToggled`, `recentItemRemoved` +- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `removeRecent` - **Features**: Read-only channel details context menu, row-level and context-menu removal ## EPG Integration @@ -736,16 +736,21 @@ interface EpgProgram { ## Routes +Routes live in `libs/playlist/m3u/feature-player/src/lib/m3u-workspace.routes.ts` +(`createM3uWorkspaceRoutes()`), nested under the workspace shell: + ``` -/playlists/:id # Video player with playlist -/iptv # Default IPTV route +/workspace/playlists/:id # M3U player (redirects to .../all) +/workspace/playlists/:id/favorites # Favorites collection view +/workspace/playlists/:id/recent # Recently viewed collection view +/workspace/playlists/:id/:view # Video player with channel list view ``` ## Adding New Features -### To add a new tab to channel list: +### To add a new view to channel list: -1. Create component in `channel-list-container/new-tab/` +1. Create component in `channel-list-container/new-view/` 2. Accept inputs: `channels`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl` 3. Emit `channelSelected` output 4. Add to parent template and imports diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index dd565c991..4ee51bd55 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -635,6 +635,7 @@ The guarded ArtPlayer integration lives in: ```text libs/ui/playback/src/lib/art-player/ +├── art-player-audio-tracks.ts ├── art-player-setup.ts ├── art-player-source-session.ts ├── art-player-video-session.ts diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index 3a4589bf8..1a88331e6 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -5,7 +5,9 @@ settings screen. ## Entry Points -- UI: `/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings.component.ts` +- UI: `/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup-section.component.ts` + (embedded in `settings.component.html`), with the file read/handoff in + `/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup.facade.ts` - Backup service: `/Users/4gray/Code/iptvnator/libs/services/src/lib/playlist-backup.service.ts` - Manifest types: `/Users/4gray/Code/iptvnator/libs/shared/interfaces/src/lib/playlist-backup.interface.ts` - Xtream pending restore storage: @@ -102,7 +104,9 @@ Only EPG source URLs are backed up at the app-settings level. ## Import Flow -The settings component hands file contents to `PlaylistBackupService`. +The settings backup facade (`settings-backup.facade.ts`, driven by +`settings-backup-section.component.ts`) reads the file (`file.text()`) and +hands its contents to `PlaylistBackupService.importBackup()`. The service: diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 6b684b99f..8b2d08717 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -50,7 +50,7 @@ Implication: Current code paths: -- `libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts` +- `libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts` (favorites + recent, with shared UI from `libs/portal/shared/ui/src/lib/components/favorites-layout/`) - `libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts` - `libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts` @@ -113,8 +113,7 @@ Implication: Current code paths: -- `libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts` -- `libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts` (favorites + recent via `mode` route data) -> `stalker-collection-detail.component.ts` - `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` - `libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts` (Stalker branch) diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 4aaa17c61..1083053f6 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -56,6 +56,15 @@ Keep SQL-heavy logic here so the worker entry remains a thin dispatcher: 2. `apps/electron-backend/src/app/database/operations/content.operations.ts` 3. `apps/electron-backend/src/app/database/operations/playlist.operations.ts` 4. `apps/electron-backend/src/app/database/operations/xtream.operations.ts` +5. `apps/electron-backend/src/app/database/operations/favorites.operations.ts` +6. `apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts` +7. `apps/electron-backend/src/app/database/operations/playback-position.operations.ts` +8. `apps/electron-backend/src/app/database/operations/content-backdrop.operations.ts` +9. `apps/electron-backend/src/app/database/operations/title-match.operations.ts` +10. `apps/electron-backend/src/app/database/operations/tmdb.operations.ts` +11. `apps/electron-backend/src/app/database/operations/epg-mapping.operations.ts` + +(plus the shared cancellation helper `operation-control.ts` in the same directory) ## Worker Architecture @@ -675,11 +684,16 @@ CI=1 NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false pnpm nx run electron-backend:build --s These are intentionally still out of scope for this first cut: -1. request cancellation -2. moving network-heavy Xtream fetches off the current path -3. migrating every remaining small SQLite IPC handler to the worker -4. richer delete progress reporting for bulk destructive operations -5. repo-wide Angular/Jest cleanup for the currently failing web test baseline +1. moving network-heavy Xtream fetches off the current path +2. migrating every remaining small SQLite IPC handler to the worker +3. richer delete progress reporting for bulk destructive operations +4. repo-wide Angular/Jest cleanup for the currently failing web test baseline + +(Request cancellation, originally listed here, has since shipped — see the +"Cancellation contract" section above: `DB_CANCEL_OPERATION` in +`apps/electron-backend/src/app/api/main.preload.ts`, `AbortError` production in +`database.worker.ts`, and `DatabaseService.cancelOperation` in +`libs/services/src/lib/database-electron.service.ts`.) ## Extending The Worker diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 60458d092..a15bb061a 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -22,7 +22,7 @@ The mock server enables: Per-request random data would break navigation: if category IDs change between calls, content fetched under a category ID won't match the category list. Instead: - Data is generated **once per MAC address** on first request, then cached in memory. -- `@faker-js/faker` is seeded with a numeric value derived from the MAC address before generation. +- `@faker-js/faker` is seeded with the scenario's `seed` value before generation: predefined scenario MACs use fixed seeds from `scenarios.ts`; unknown MACs derive the seed from the MAC via `macToSeed()`. - Same MAC → identical data on every server restart. - Restart the server to reshuffle all data. @@ -41,7 +41,7 @@ No files or databases are written. All state (generated content + favorites) liv ## Data Generation Pipeline ``` -faker.seed(macToNumber(mac)) +faker.seed(config.seed) // scenario seed; unknown MACs: macToSeed(mac) │ ├── generateCategories('itv', N) → itvCategories[] │ └── generateChannels() → channels Map @@ -123,7 +123,7 @@ marker and an `ffrt4://radio/...` command. "id": "30001-s1", "name": "Season 1", "cmd": "ffrt4://series/30001/season/1", - "series": ["30001-s1-e1", "30001-s1-e2", ...], + "series": ["1", "2", "3", ...], "screenshot_uri": "https://picsum.photos/seed/30001-s1/300/200", "director": "...", "actors": "...", @@ -217,9 +217,19 @@ interface ScenarioConfig { episodesPerSeason: number; isSeriesFraction: number; // 0–1: fraction of VOD with is_series=1 embeddedSeriesFraction: number; // 0–1: fraction of VOD with embedded series[] + supportsGetAllChannels?: boolean; // default true; false mimics legacy portals + // without the ITV get_all_channels action } ``` +The `legacy-pagination` scenario (`00:1A:79:00:00:06`) sets +`supportsGetAllChannels: false`: `get_all_channels` then answers with an error +payload so clients fall back to the paginated `get_ordered_list` crawl. For +supporting scenarios, `get_all_channels` (`get-all-channels.handler.ts`, +`type=itv` only) returns the complete ITV channel list in one +`{ js: { data, total_items } }` response, excluding channels from censored +(adult) genres. + ### Adding a New Scenario 1. Add an entry to the `SCENARIOS` map in `src/app/scenarios.ts`. @@ -257,7 +267,7 @@ Playwright waits for both servers to be healthy before starting tests. If either Each stalker e2e test calls `POST http://localhost:3210/reset` in `beforeEach` to clear in-memory state. This ensures tests don't bleed favorites or other mutable state into each other. -The generated content (categories, items) is **not** cleared on reset — it's deterministic and doesn't need to be. Only in-memory favorites are cleared. +`resetAll()` clears both the generated-content cache and in-memory favorites (`data-store.ts`). Because generation is seed-deterministic, the next request regenerates identical content, so the observable data does not change across resets. ### Recommended Test Structure diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index c37c44d59..c75d97f4f 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -32,14 +32,15 @@ Stalker support covers: Primary route tree lives in `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`. -- `/stalker/:id/vod` -- `/stalker/:id/series` +- `/stalker/:id/vod` (plus `vod/:categoryId` child) +- `/stalker/:id/series` (plus `series/:categoryId` child) - `/stalker/:id/itv` - `/stalker/:id/radio` - `/stalker/:id/favorites` - `/stalker/:id/recent` - `/stalker/:id/search` -- `/stalker/:id/downloads` (shared downloads module from Xtream UI) +- `/stalker/:id/actor/:personId` +- `/stalker/:id/downloads` (shared `DownloadsComponent` from `@iptvnator/portal/downloads/feature`) ## Runtime Architecture @@ -48,20 +49,20 @@ Primary route tree lives in 3. Requests go through `DataService.sendIpcEvent(STALKER_REQUEST, ...)` or `StalkerSessionService` (full portal auth). 4. Electron main process handles `STALKER_REQUEST` in `apps/electron-backend/src/app/events/stalker.events.ts`. -5. Axios calls Stalker `load.php` API with required headers/cookies and returns normalized payloads to renderer. +5. Axios calls Stalker `load.php` API with required headers/cookies and returns the raw `response.data` to the renderer; normalization happens in the store feature slices. ## Main UI Components -- `libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts` - - Category + content layout for `vod` and `series` +- `CategoryContentViewComponent` from `@iptvnator/portal/catalog/feature` (`libs/portal/catalog/feature`) + - Shared category + content layout used by the `vod` and `series` routes (wired in `stalker-feature.routes.ts` via `loadCategoryContentViewComponent`) - `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` - ITV live playback, radio playback, channel/station navigation, EPG panel integration - `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` - Shared inline audio player used by M3U radio channels and Stalker radio stations - `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` - Season/episode UI for all Stalker series modes -- `libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts` -- `libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts` + - Favorites and recently-viewed collection views (`mode = 'favorites' | 'recent'` route data), rendering `stalker-collection-detail.component.ts` - `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` ## Store and Data Flow @@ -342,9 +343,9 @@ Current implementation is shared via Stalker-specific helpers: Where this is used: -- `libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts` -- `libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts` (favorites + recently viewed) - `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` +- `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts` - `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts` Navigation rule to preserve: @@ -408,7 +409,7 @@ See full backend and web-remote flow in [Remote Control Architecture](./remote-c Stalker ITV now splits EPG usage: - active channel panel: bulk `get_epg_info` cached once per playlist and rendered - through shared `app-epg-list` + through the shared EPG panel (`app-epg-timeline`, or `app-epg-list-view` in list mode) - channel row preview: no pre-playback network requests; previews are derived from cached bulk EPG only after the first active-channel fetch succeeds - active panel fallback: `get_short_epg` when bulk EPG is missing or unsupported diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index b28de8208..dfe689ef7 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -215,7 +215,7 @@ Single table with two row kinds discriminated by `lookup_key` prefix: ``` tmdb_metadata ( media_type 'movie' | 'tv' | 'person', - lookup_key 'id:' -- details payload row + lookup_key 'id:|v2' -- details payload row 'title:|year:|v2' -- search resolution row 'person:' -- person payload row language TEXT, -- TMDB language code @@ -229,8 +229,11 @@ tmdb_metadata ( TTLs (enforced at read time in `TmdbCacheService.isFresh`): details and positive matches 30 days, negative matches 7 days. -Search keys carry a version suffix so normalization changes cannot reuse stale -positive or negative resolutions. Database startup deletes the obsolete +Search and details keys carry a `|v2` version suffix (`buildDetailsLookupKey` +in `tmdb-matcher.ts`): for search rows so normalization changes cannot reuse +stale positive or negative resolutions, for details rows because payloads now +include videos via `append_to_response` and pre-videos cache rows had to be +invalidated. Database startup deletes the obsolete unversioned search rows once and records `migration:tmdb-search-lookup-v2-cache-cleanup:v1` in `app_state`; details and person cache rows are unaffected. diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 7e0cb4ee7..79942c6e7 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -12,8 +12,12 @@ Related: - The dashboard is the default `/workspace` landing page. - It is a **rail-based** content surface (Netflix / Apple TV pattern), not a customizable widget grid. -- Layout is static and curated — there is no edit mode, drag-drop, size - stepper, show/hide toggle, or persisted layout. Rails auto-hide when empty. +- Layout order is static and curated — there is no edit mode, drag-drop, or + size stepper. Each rail has a persisted show/hide toggle + (`Settings.dashboardRails`, `DashboardRailsSettings` in + `libs/shared/interfaces/src/lib/settings.interface.ts`, surfaced under + Settings → Dashboard); every template rail is gated by + `dashboardRails().`. Rails additionally auto-hide when empty. - First-run users see the shared welcome empty-state with a single primary CTA to add their first playlist. @@ -38,14 +42,23 @@ Core implementation: │ Continue Watching · See all → │ │ [poster][poster][poster][poster] →→ │ ├─────────────────────────────────────────────────────────────────────┤ -│ Live now on your favorites / Continue with live TV · See all → │ +│ Live now on your favorites · See all → │ │ [channel][channel][channel][channel] →→ │ ├─────────────────────────────────────────────────────────────────────┤ +│ Recently watched live TV · See all → │ +│ [channel][channel][channel][channel] →→ │ +├─────────────────────────────────────────────────────────────────────┤ +│ Favorite movies & series · See all → │ +│ [poster][poster][poster][poster] →→ │ +├─────────────────────────────────────────────────────────────────────┤ │ Recently Used Sources · See all → │ │ [tile][tile][tile][tile] →→ │ ├─────────────────────────────────────────────────────────────────────┤ │ Recently Added on Xtream (aggregated across providers) │ │ [poster][poster][poster] →→ │ +├─────────────────────────────────────────────────────────────────────┤ +│ Trending this week (TMDB, opt-in, Electron-only) │ +│ [poster][poster][poster] →→ │ └─────────────────────────────────────────────────────────────────────┘ ``` @@ -55,7 +68,7 @@ Render rules: page no longer uses `dashboardReady()` as a page-wide skeleton gate. Initial hero/recent/favorites loading states render scoped skeletons so one slow rail does not hide already available content. -2. `hasPlaylists() === false` → render `` +2. `hasPlaylists() === false` → render `` full-bleed. All rails and the hero are skipped. 3. `hero()` = `globalRecentItems()[0]`. If present, render the hero panel. 4. Each rail is emitted via `@if (cards.length > 0)`. Empty rails are hidden @@ -63,9 +76,11 @@ Render rules: 5. The continue-watching hero prefers a stored Xtream `backdrop_url`; when it is missing the UI falls back to a blurred poster treatment instead of showing a flat panel. -6. The mixed global favorites rail is not rendered on the dashboard. Live - favorites are promoted into the live rail, while mixed favorites stay on - `/workspace/global-favorites`. +6. Live favorites are promoted into their own live rail; movie/series + favorites render in a separate `Favorite movies & series` rail + (`favoriteMoviesAndSeriesCards`, `data-test-id="dashboard-favorite-vod-rail"`, + mapped from `globalFavoriteItems()` filtered to movie/series). Full mixed + favorites management stays on `/workspace/global-favorites`. 7. The live favorites rail keeps its scoped skeleton until the initial global favorites load has completed for both Xtream-backed and playlist-backed favorites. This avoids first-paint partial counts such as a single Stalker @@ -106,9 +121,10 @@ Render rules: metadata and playback positions load, the detail player consumes the target once and resumes the saved episode. Opening the same item normally from the global recent grid remains a detail-only action. - 3. `liveOnFavoritesCardsEnriched` — maps favorited live channels first, - falling back to recently watched live channels when no live favorites - exist. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV + 3. `liveFavoriteCardsEnriched` and `recentLiveCardsEnriched` — two + independent rails (`dashboard-live-favorites-rail` and + `dashboard-recent-live-rail`); there is no fallback from one to the + other. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV fallback order (`tvg-id` -> `tvg-name` -> channel name); EPG enrichment must use that key before falling back to the card title. 4. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail @@ -130,7 +146,10 @@ Render rules: 3. `DashboardDataService` is passive on construction. The dashboard feature owns the initial reloads for recent items, favorites, and Xtream recently added rows on page entry. -4. No `Layout` state, no localStorage keys, no migrations. +4. No dashboard-local `Layout` state, no localStorage keys, no migrations. + Per-rail visibility is the one persisted preference, and it lives in the + global settings store (`Settings.dashboardRails`), not in a + dashboard-owned layout blob. 5. Navigation state + deep-link targets come from the existing `getRecentItemLink()` / `getGlobalFavoriteLink()` / `getPlaylistLink()` helpers on `DashboardDataService` and reuse the workspace navigation @@ -162,7 +181,7 @@ Render rules: ## Empty State The welcome state is rendered via the existing -`EmptyStateComponent` (`type="welcome"`) from +`EmptyStateComponent` (`type="welcome-dashboard"`) from `libs/playlist/shared/ui`: 1. Illustration + headline + description from the existing M3U welcome @@ -189,8 +208,9 @@ The welcome state is rendered via the existing 7. `Recently Used Sources` reflects recent source usage across all provider types, not just recent imports. 8. The live rail title key must match the rendered source: favorites use - `WORKSPACE.DASHBOARD.LIVE_FAVORITES`; recently watched fallback uses - `WORKSPACE.DASHBOARD.LIVE_RECENT`. + `WORKSPACE.DASHBOARD.LIVE_FAVORITES`; the recently-watched-live rail uses + `WORKSPACE.DASHBOARD.RECENTLY_WATCHED_LIVE_TV` + (`liveRailTitleKeyForSource` in `rails/dashboard-rail.utils.ts`). ## Adding Or Changing Rails @@ -210,8 +230,9 @@ Current workflow: Intentionally out of scope: -1. Customizable layout (drag/drop, resize, show/hide toggles, layout - persistence). Removed in favor of a curated, opinionated order. +1. Customizable layout (drag/drop, resize, freeform reordering). The rail + order stays curated and opinionated. (Per-rail show/hide toggles have + since shipped via `Settings.dashboardRails` — see Summary.) 2. Freeform widget grid with collision management. 3. External data rails such as RSS, sports, or news adapters. 4. Per-user A/B variants of rail ordering. diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index f5a7d14a8..5a0b6c9c0 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -38,10 +38,15 @@ Core implementation: Current workspace routes: 1. `/` -> `/workspace` -2. `/workspace` -> `/workspace/dashboard` +2. `/workspace` -> functional redirect `workspaceEntryRedirect` + (`WorkspaceStartupPreferencesService.resolveInitialWorkspacePath()`; + `/workspace/dashboard` by default, `/workspace/sources` when the dashboard + is disabled, or the last restorable route under + `StartupBehavior.RestoreLastView` — `dashboard` itself is guarded by + `dashboardAccessGuard`) 3. `/workspace/dashboard` 4. `/workspace/sources` -5. `/workspace/playlists/:id/:view` +5. `/workspace/playlists/:id/:view` (plus `favorites` and `recent` siblings) 6. `/workspace/global-favorites` 7. `/workspace/global-recent` 8. `/workspace/search` From 5aa44d19d4faa444e04a43fff12c814b902fecff Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:42:41 +0200 Subject: [PATCH 22/26] feat(tmdb): clickable director/creator chips and directing credits on person pages (#1227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tmdb): clickable director/creator chips and directing credits on person pages Directors were plain merged text — no photos, no navigation — while the data was already sitting in the cached TMDB payloads (credits.crew and created_by both carry id + profile_path; they just were not typed or parsed). - tmdb-merge: enrichedDirectors (crew, job === 'Director', deduped by person id) and enrichedCreators (created_by) produce the same chip shape as the cast (TmdbEnrichedCastMember) into a new tmdb_directors field on all three merges (Xtream VOD, Xtream series, Stalker); types widened (crew id/profile_path, created_by id/profile_path). - Detail views (shared VodDetailsComponent, Xtream vod/serial routes, Stalker series view) render the Director row as clickable avatar chips when tmdb_directors is present — same markup and openActor handler as the cast strip — falling back to the plain text otherwise. Stalker re-normalization allowlist preserves the new field. - Person pages: mapPersonFilmography now merges combined_credits.crew (jobs Director/Creator) into the filmography — acting wins the per-title dedup, directing-only titles show the job in the character slot. Everything else (library matching, All-portals scope, filters, search fallback, back button) works unchanged because the person page is role-agnostic. Existing caches work as-is: crew/created_by were always part of the stored payloads. Tests: merge spec (director/creator chips + crew-row dedup ×3 merges), person spec (crew credits, Producer excluded, acting-wins dedup), stalker-vod.utils passthrough. Docs updated (CLAUDE.md + architecture). Co-Authored-By: Claude Opus 4.8 * fix(tmdb): address director-pages review — split oversized spec, stable track keys, translated crew roles - tmdb-merge.spec.ts grew past the 400-line lint ceiling — the Stalker merge suite moved to tmdb-merge-stalker.spec.ts (fixes the CI Lint job). - All cast/director chip loops now track by TMDB person id with an index fallback ('p' / 'i') instead of member.name — distinct people can share a name and creator payloads carry no dedup (greptile). - Directing-only filmography credits carry the role in a new crewJob field ('Director' | 'Creator') instead of stuffing TMDB's raw English job into character; ActorViewComponent renders it through translated labels (XTREAM.CREW_JOB_DIRECTOR/CREATOR, added to all 18 locales via the i18n patch workflow, matching each locale's existing glossary — pt "Diretor", de "Regisseur") (Codex). Tests: person spec asserts character/crewJob separation; merge suites green after the split (15 + stalker file). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .gitignore | 4 + CLAUDE.md | 2 +- apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/tmdb-metadata-enrichment.md | 13 +- .../src/lib/stalker-vod.utils.spec.ts | 8 + .../data-access/src/lib/stalker-vod.utils.ts | 3 + .../stalker-series-view.component.html | 43 ++++- .../serial-details.component.html | 43 ++++- .../vod-details-route.component.html | 43 ++++- .../src/lib/tmdb/tmdb-merge-stalker.spec.ts | 113 +++++++++++++ libs/services/src/lib/tmdb/tmdb-merge.spec.ts | 157 +++++------------- libs/services/src/lib/tmdb/tmdb-merge.ts | 49 ++++++ .../services/src/lib/tmdb/tmdb-person.spec.ts | 60 ++++++- libs/services/src/lib/tmdb/tmdb-person.ts | 31 +++- libs/services/src/lib/tmdb/tmdb.types.ts | 19 ++- .../src/lib/stalker-vod-details.interface.ts | 2 + .../src/lib/vod-details-adapters.ts | 2 + .../src/lib/vod-details-item.interface.ts | 2 + .../src/lib/xtream-serie-details.interface.ts | 2 + .../src/lib/xtream-vod-details.interface.ts | 2 + .../vod-details/vod-details.component.html | 39 ++++- .../lib/actor-view/actor-view.component.html | 2 + .../lib/actor-view/actor-view.component.ts | 7 + 40 files changed, 528 insertions(+), 154 deletions(-) create mode 100644 libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts diff --git a/.gitignore b/.gitignore index c2263e287..eabdfdcd4 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,7 @@ vendor/embedded-mpv/*/include/ vendor/embedded-mpv/*/lib/ vendor/embedded-mpv/*/notices/ vendor/embedded-mpv/*/runtime-manifest.json + +# MemPalace per-project files (issue #185) +mempalace.yaml +entities.json diff --git a/CLAUDE.md b/CLAUDE.md index b5d2fedc3..04e4c99a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -805,7 +805,7 @@ engine` (restart required) or - "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation - Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`) - Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint -- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) +- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-merge.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) - Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts` - Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) - Match confidence: provider `tmdb_id` trusted fully; otherwise normalized-title + year (±1) search with a strict gate — no confident match means no enrichment diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 7c9cde711..ed10b1fad 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -785,6 +785,8 @@ "ACTORS": "الممثلون", "DIRECTOR": "المخرج", "SIMILAR": "مشابه", + "CREW_JOB_DIRECTOR": "المخرج", + "CREW_JOB_CREATOR": "المبتكر", "ACTOR_FILMOGRAPHY": "الأعمال الفنية", "ACTOR_FILTER_ALL": "الكل", "ACTOR_SCOPE_THIS_PORTAL": "هذه البوابة", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index f83fe2f02..e0d58c5b7 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -785,6 +785,8 @@ "ACTORS": "الممثلون", "DIRECTOR": "المخرج", "SIMILAR": "بحال هادو", + "CREW_JOB_DIRECTOR": "المخرج", + "CREW_JOB_CREATOR": "المبتكر", "ACTOR_FILMOGRAPHY": "الأفلام ديالو", "ACTOR_FILTER_ALL": "كلشي", "ACTOR_SCOPE_THIS_PORTAL": "هاد البوابة", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index bfc239cc5..485e0f796 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -785,6 +785,8 @@ "ACTORS": "Акцёры", "DIRECTOR": "Рэжысёр", "SIMILAR": "Падобнае", + "CREW_JOB_DIRECTOR": "Рэжысёр", + "CREW_JOB_CREATOR": "Стваральнік", "ACTOR_FILMOGRAPHY": "Фільмаграфія", "ACTOR_FILTER_ALL": "Усе", "ACTOR_SCOPE_THIS_PORTAL": "Гэты партал", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 8b3a4b2cc..ee8c4cbb6 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -785,6 +785,8 @@ "ACTORS": "Schauspieler", "DIRECTOR": "Regisseur", "SIMILAR": "Ähnliche Titel", + "CREW_JOB_DIRECTOR": "Regisseur", + "CREW_JOB_CREATOR": "Schöpfer", "ACTOR_FILMOGRAPHY": "Filmografie", "ACTOR_FILTER_ALL": "Alle", "ACTOR_SCOPE_THIS_PORTAL": "Dieses Portal", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 414ea0d4b..92828089d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -785,6 +785,8 @@ "ACTORS": "Ηθοποιοί", "DIRECTOR": "Σκηνοθέτης", "SIMILAR": "Παρόμοια", + "CREW_JOB_DIRECTOR": "Σκηνοθέτης", + "CREW_JOB_CREATOR": "Δημιουργός", "ACTOR_FILMOGRAPHY": "Φιλμογραφία", "ACTOR_FILTER_ALL": "Όλα", "ACTOR_SCOPE_THIS_PORTAL": "Αυτή η πύλη", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index aca191d45..c88c47858 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -785,6 +785,8 @@ "ACTORS": "Actors", "DIRECTOR": "Director", "SIMILAR": "Similar", + "CREW_JOB_DIRECTOR": "Director", + "CREW_JOB_CREATOR": "Creator", "ACTOR_FILMOGRAPHY": "Filmography", "ACTOR_FILTER_ALL": "All", "ACTOR_SCOPE_THIS_PORTAL": "This portal", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 81b329133..7cdbc4bde 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -785,6 +785,8 @@ "ACTORS": "Actores", "DIRECTOR": "Director", "SIMILAR": "Similares", + "CREW_JOB_DIRECTOR": "Director", + "CREW_JOB_CREATOR": "Creador", "ACTOR_FILMOGRAPHY": "Filmografía", "ACTOR_FILTER_ALL": "Todas", "ACTOR_SCOPE_THIS_PORTAL": "Este portal", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index eb5e74cb1..569cb0a9e 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -785,6 +785,8 @@ "ACTORS": "Acteurs", "DIRECTOR": "Réalisateur", "SIMILAR": "Similaires", + "CREW_JOB_DIRECTOR": "Réalisateur", + "CREW_JOB_CREATOR": "Créateur", "ACTOR_FILMOGRAPHY": "Filmographie", "ACTOR_FILTER_ALL": "Tout", "ACTOR_SCOPE_THIS_PORTAL": "Ce portail", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 5e790f94b..c16dcf61e 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -785,6 +785,8 @@ "ACTORS": "Attori", "DIRECTOR": "Regista", "SIMILAR": "Simili", + "CREW_JOB_DIRECTOR": "Regista", + "CREW_JOB_CREATOR": "Ideatore", "ACTOR_FILMOGRAPHY": "Filmografia", "ACTOR_FILTER_ALL": "Tutti", "ACTOR_SCOPE_THIS_PORTAL": "Questo portale", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 179db53b3..1fe40c681 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -785,6 +785,8 @@ "ACTORS": "出演者", "DIRECTOR": "監督", "SIMILAR": "類似作品", + "CREW_JOB_DIRECTOR": "監督", + "CREW_JOB_CREATOR": "クリエイター", "ACTOR_FILMOGRAPHY": "出演作品", "ACTOR_FILTER_ALL": "すべて", "ACTOR_SCOPE_THIS_PORTAL": "このポータル", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index f861aab78..d64b729cd 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -785,6 +785,8 @@ "ACTORS": "배우", "DIRECTOR": "감독", "SIMILAR": "비슷한 콘텐츠", + "CREW_JOB_DIRECTOR": "감독", + "CREW_JOB_CREATOR": "크리에이터", "ACTOR_FILMOGRAPHY": "출연 작품", "ACTOR_FILTER_ALL": "전체", "ACTOR_SCOPE_THIS_PORTAL": "현재 포털", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 15b75ce94..dd5af527d 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -785,6 +785,8 @@ "ACTORS": "Acteurs", "DIRECTOR": "Regisseur", "SIMILAR": "Vergelijkbaar", + "CREW_JOB_DIRECTOR": "Regisseur", + "CREW_JOB_CREATOR": "Bedenker", "ACTOR_FILMOGRAPHY": "Filmografie", "ACTOR_FILTER_ALL": "Alles", "ACTOR_SCOPE_THIS_PORTAL": "Dit portaal", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index e629135ed..bffa8dc82 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -785,6 +785,8 @@ "ACTORS": "Aktorzy", "DIRECTOR": "Reżyser", "SIMILAR": "Podobne", + "CREW_JOB_DIRECTOR": "Reżyser", + "CREW_JOB_CREATOR": "Twórca", "ACTOR_FILMOGRAPHY": "Filmografia", "ACTOR_FILTER_ALL": "Wszystkie", "ACTOR_SCOPE_THIS_PORTAL": "Ten portal", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 8572320aa..360be5be3 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -785,6 +785,8 @@ "ACTORS": "Atores", "DIRECTOR": "Diretor", "SIMILAR": "Semelhantes", + "CREW_JOB_DIRECTOR": "Diretor", + "CREW_JOB_CREATOR": "Criador", "ACTOR_FILMOGRAPHY": "Filmografia", "ACTOR_FILTER_ALL": "Todos", "ACTOR_SCOPE_THIS_PORTAL": "Este portal", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index dc3377518..02d4026a7 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -785,6 +785,8 @@ "ACTORS": "Актеры", "DIRECTOR": "Режиссёр", "SIMILAR": "Похожее", + "CREW_JOB_DIRECTOR": "Режиссёр", + "CREW_JOB_CREATOR": "Создатель", "ACTOR_FILMOGRAPHY": "Фильмография", "ACTOR_FILTER_ALL": "Все", "ACTOR_SCOPE_THIS_PORTAL": "Этот портал", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index ac4a86246..f699a9cf5 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -785,6 +785,8 @@ "ACTORS": "Oyuncular", "DIRECTOR": "Yönetmen", "SIMILAR": "Benzerler", + "CREW_JOB_DIRECTOR": "Yönetmen", + "CREW_JOB_CREATOR": "Yaratıcı", "ACTOR_FILMOGRAPHY": "Filmografi", "ACTOR_FILTER_ALL": "Tümü", "ACTOR_SCOPE_THIS_PORTAL": "Bu portal", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index d40d29f9a..4a6cf0e4a 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -785,6 +785,8 @@ "ACTORS": "演员", "DIRECTOR": "导演", "SIMILAR": "类似影片", + "CREW_JOB_DIRECTOR": "导演", + "CREW_JOB_CREATOR": "主创", "ACTOR_FILMOGRAPHY": "参演作品", "ACTOR_FILTER_ALL": "全部", "ACTOR_SCOPE_THIS_PORTAL": "当前门户", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 2e048b4dd..7bf0abebe 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -785,6 +785,8 @@ "ACTORS": "演員", "DIRECTOR": "導演", "SIMILAR": "類似作品", + "CREW_JOB_DIRECTOR": "導演", + "CREW_JOB_CREATOR": "主創", "ACTOR_FILMOGRAPHY": "參演作品", "ACTOR_FILTER_ALL": "全部", "ACTOR_SCOPE_THIS_PORTAL": "目前入口", diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index dfe689ef7..08fd79010 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -65,8 +65,11 @@ Integration glue per portal: Components read the selection through signals and re-render when the merged item lands. Enriched cast (`tmdb_cast` with profile photos) renders as -avatar chips in the detail views; a "check key" button in the settings -section validates the API key against `/configuration`. +avatar chips in the detail views, and so do directors/creators +(`tmdb_directors`: movie directors from `credits.crew` with +`job === 'Director'`, series creators from `created_by`) — both chip kinds +carry `tmdbPersonId` and open the same person page. A "check key" button +in the settings section validates the API key against `/configuration`. ## Match Confidence @@ -187,7 +190,11 @@ Cast chips carry the TMDB person id (`tmdbPersonId` on current portal. The page loads `/person/{id}?append_to_response= combined_credits` via `TmdbEnrichmentService.getPersonDetails` (cached under `person:{id}` with media_type `person`) and renders the shared -`ActorViewComponent` (`libs/ui/shared-portals`). +`ActorViewComponent` (`libs/ui/shared-portals`). The filmography merges +acting credits (`combined_credits.cast`) with directing/creating credits +(`combined_credits.crew`, jobs `Director`/`Creator`) into one list — +acting wins the per-title dedup, directing-only titles show the job in +the character slot — so the page serves actors and directors alike. Filmography has two scopes: diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index 9b2cc4c99..c37614014 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -245,6 +245,13 @@ describe('stalker-vod.utils regressions', () => { rating_imdb: '', rating_kinopoisk: '8.1', tmdb_cast: tmdbCast, + tmdb_directors: [ + { + name: 'Eric Kripke', + profileUrl: null, + tmdbPersonId: 1216630, + }, + ], tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/boys.jpg', tmdb_trailer: 'abc123def', tmdb_recommendations: tmdbRecommendations, @@ -252,6 +259,7 @@ describe('stalker-vod.utils regressions', () => { }); expect(info.tmdb_cast).toEqual(tmdbCast); + expect(info.tmdb_directors?.[0]?.name).toBe('Eric Kripke'); expect(info.tmdb_backdrop).toBe( 'https://image.tmdb.org/t/p/w1280/boys.jpg' ); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index 987cf755c..44c7c4d26 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -158,6 +158,9 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo { // views normalize the selected item on every render ...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}), ...(info.tmdb_cast ? { tmdb_cast: info.tmdb_cast } : {}), + ...(info.tmdb_directors + ? { tmdb_directors: info.tmdb_directors } + : {}), ...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}), ...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}), ...(info.tmdb_recommendations diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 861be1769..af79c3b2f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -37,7 +37,7 @@
@for ( member of serial.info.tmdb_cast; - track member.name + track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index ) { } - @if (serial.info.director) { + @if (serial.info.director || serial.info.tmdb_directors?.length) {
- {{ - 'XTREAM.DIRECTOR' | translate - }} - {{ serial.info.director }} + {{ 'XTREAM.DIRECTOR' | translate }} + @if (serial.info.tmdb_directors?.length) { +
+ @for (member of serial.info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ serial.info.director }} + }
} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index f10e52862..b7f715a8a 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -37,7 +37,7 @@ {{ 'XTREAM.CAST' | translate }} @if (info.tmdb_cast?.length) {
- @for (member of info.tmdb_cast; track member.name) { + @for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { - {{ - 'XTREAM.DIRECTOR' | translate - }} - {{ info.director }} + {{ 'XTREAM.DIRECTOR' | translate }} + @if (info.tmdb_directors?.length) { +
+ @for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ info.director }} + }
} 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 1a5494699..b8b3e79d6 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 @@ -37,7 +37,7 @@ {{ 'XTREAM.ACTORS' | translate }} @if (info.tmdb_cast?.length) {
- @for (member of info.tmdb_cast; track member.name) { + @for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { - {{ - 'XTREAM.DIRECTOR' | translate - }} - {{ info.director }} + {{ 'XTREAM.DIRECTOR' | translate }} + @if (info.tmdb_directors?.length) { +
+ @for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ info.director }} + }
} @if (info.rating_kinopoisk) { diff --git a/libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts new file mode 100644 index 000000000..61b6b3846 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts @@ -0,0 +1,113 @@ +import { StalkerVodInfo } from '@iptvnator/shared/interfaces'; +import { mergeStalkerInfoWithTmdb } from './tmdb-merge'; +import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types'; + +describe('mergeStalkerInfoWithTmdb', () => { + const providerStalkerInfo: StalkerVodInfo = { + movie_image: '', + description: '', + name: 'Ирония судьбы, или С лёгким паром!', + o_name: undefined, + actors: 'Провайдерский актёр', + director: '', + releasedate: '1976', + genre: '', + rating_imdb: '', + rating_kinopoisk: '8.1', + }; + + const tmdbMovieRu: TmdbMovieDetails = { + id: 20992, + title: 'Ирония судьбы, или С лёгким паром!', + overview: 'Описание из TMDB', + genres: [{ id: 35, name: 'комедия' }], + release_date: '1976-01-01', + vote_average: 7.9, + vote_count: 250, + poster_path: '/irony-poster.jpg', + backdrop_path: '/irony-backdrop.jpg', + credits: { + cast: [ + { + name: 'Андрей Мягков', + order: 0, + profile_path: '/myagkov.jpg', + }, + ], + crew: [{ id: 77, name: 'Эльдар Рязанов', job: 'Director' }], + }, + }; + + it('prefers TMDB editorial fields and attaches cast/backdrop', () => { + const merged = mergeStalkerInfoWithTmdb( + providerStalkerInfo, + tmdbMovieRu, + 'movie' + ); + + expect(merged.description).toBe('Описание из TMDB'); + expect(merged.actors).toBe('Андрей Мягков'); + expect(merged.director).toBe('Эльдар Рязанов'); + expect(merged.tmdb_directors?.[0]).toEqual({ + name: 'Эльдар Рязанов', + profileUrl: null, + tmdbPersonId: 77, + }); + expect(merged.genre).toBe('комедия'); + expect(merged.movie_image).toBe( + 'https://image.tmdb.org/t/p/w500/irony-poster.jpg' + ); + expect(merged.tmdb_backdrop).toBe( + 'https://image.tmdb.org/t/p/w1280/irony-backdrop.jpg' + ); + expect(merged.tmdb_cast).toEqual([ + { + name: 'Андрей Мягков', + profileUrl: 'https://image.tmdb.org/t/p/w185/myagkov.jpg', + }, + ]); + // Provider keeps its own fields where TMDB should not win + expect(merged.name).toBe('Ирония судьбы, или С лёгким паром!'); + expect(merged.rating_kinopoisk).toBe('8.1'); + expect(merged.releasedate).toBe('1976'); + }); + + it('only fills rating_imdb when the provider left it empty', () => { + const merged = mergeStalkerInfoWithTmdb( + providerStalkerInfo, + tmdbMovieRu, + 'movie' + ); + expect(merged.rating_imdb).toBe('7.9'); + + const withProviderRating = mergeStalkerInfoWithTmdb( + { ...providerStalkerInfo, rating_imdb: '8.4' }, + tmdbMovieRu, + 'movie' + ); + expect(withProviderRating.rating_imdb).toBe('8.4'); + }); + + it('uses series creators as director for tv items', () => { + const tv: TmdbTvDetails = { + id: 1, + overview: 'tv overview', + created_by: [{ name: 'Создатель Сериала' }], + }; + const merged = mergeStalkerInfoWithTmdb(providerStalkerInfo, tv, 'tv'); + expect(merged.director).toBe('Создатель Сериала'); + }); + + it('keeps provider values for missing TMDB fields', () => { + const merged = mergeStalkerInfoWithTmdb( + { ...providerStalkerInfo, description: 'Провайдерский сюжет' }, + { id: 20992 }, + 'movie' + ); + + expect(merged.description).toBe('Провайдерский сюжет'); + expect(merged.actors).toBe('Провайдерский актёр'); + expect(merged.tmdb_cast).toBeUndefined(); + expect(merged.tmdb_backdrop).toBeUndefined(); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts index 1c8efd1c4..050d13775 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts @@ -1,13 +1,5 @@ -import { - StalkerVodInfo, - XtreamSerieInfo, - XtreamVodInfo, -} from '@iptvnator/shared/interfaces'; -import { - mergeSerieInfoWithTmdb, - mergeStalkerInfoWithTmdb, - mergeVodInfoWithTmdb, -} from './tmdb-merge'; +import { XtreamSerieInfo, XtreamVodInfo } from '@iptvnator/shared/interfaces'; +import { mergeSerieInfoWithTmdb, mergeVodInfoWithTmdb } from './tmdb-merge'; import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types'; function providerVodInfo(overrides: Partial = {}): XtreamVodInfo { @@ -68,8 +60,15 @@ const tmdbMovie: TmdbMovieDetails = { { name: 'Laurence Fishburne', order: 1, profile_path: null }, ], crew: [ - { name: 'Lana Wachowski', job: 'Director' }, - { name: 'Lilly Wachowski', job: 'Director' }, + { + id: 9339, + name: 'Lana Wachowski', + job: 'Director', + profile_path: '/lana.jpg', + }, + { id: 9340, name: 'Lilly Wachowski', job: 'Director' }, + // Duplicate crew row TMDB sometimes returns — must be deduped + { id: 9339, name: 'Lana Wachowski', job: 'Director' }, { name: 'Someone Else', job: 'Producer' }, ], }, @@ -106,7 +105,21 @@ describe('mergeVodInfoWithTmdb', () => { expect(merged.description).toBe('TMDB overview'); expect(merged.cast).toBe('Keanu Reeves, Laurence Fishburne'); expect(merged.actors).toBe('Keanu Reeves, Laurence Fishburne'); - expect(merged.director).toBe('Lana Wachowski, Lilly Wachowski'); + expect(merged.director).toBe( + 'Lana Wachowski, Lilly Wachowski, Lana Wachowski' + ); + expect(merged.tmdb_directors).toEqual([ + { + name: 'Lana Wachowski', + profileUrl: 'https://image.tmdb.org/t/p/w185/lana.jpg', + tmdbPersonId: 9339, + }, + { + name: 'Lilly Wachowski', + profileUrl: null, + tmdbPersonId: 9340, + }, + ]); expect(merged.genre).toBe('Action, Science Fiction'); expect(merged.rating).toBe(8.2); expect(merged.tmdb_id).toBe(603); @@ -242,7 +255,10 @@ describe('mergeSerieInfoWithTmdb', () => { vote_count: 3200, poster_path: '/dark-poster.jpg', backdrop_path: '/dark-backdrop.jpg', - created_by: [{ name: 'Baran bo Odar' }, { name: 'Jantje Friese' }], + created_by: [ + { id: 91, name: 'Baran bo Odar', profile_path: '/odar.jpg' }, + { name: 'Jantje Friese' }, + ], credits: { cast: [{ name: 'Louis Hofmann', order: 0 }], }, @@ -254,6 +270,14 @@ describe('mergeSerieInfoWithTmdb', () => { expect(merged.plot).toBe('TMDB tv overview'); expect(merged.cast).toBe('Louis Hofmann'); expect(merged.director).toBe('Baran bo Odar, Jantje Friese'); + expect(merged.tmdb_directors).toEqual([ + { + name: 'Baran bo Odar', + profileUrl: 'https://image.tmdb.org/t/p/w185/odar.jpg', + tmdbPersonId: 91, + }, + { name: 'Jantje Friese', profileUrl: null }, + ]); expect(merged.genre).toBe('Mystery'); expect(merged.rating).toBe('8.4'); expect(merged.rating_5based).toBe(4.2); @@ -279,108 +303,3 @@ describe('mergeSerieInfoWithTmdb', () => { expect(merged.cover).toBe('http://provider/cover.jpg'); }); }); - -describe('mergeStalkerInfoWithTmdb', () => { - const providerStalkerInfo: StalkerVodInfo = { - movie_image: '', - description: '', - name: 'Ирония судьбы, или С лёгким паром!', - o_name: undefined, - actors: 'Провайдерский актёр', - director: '', - releasedate: '1976', - genre: '', - rating_imdb: '', - rating_kinopoisk: '8.1', - }; - - const tmdbMovieRu: TmdbMovieDetails = { - id: 20992, - title: 'Ирония судьбы, или С лёгким паром!', - overview: 'Описание из TMDB', - genres: [{ id: 35, name: 'комедия' }], - release_date: '1976-01-01', - vote_average: 7.9, - vote_count: 250, - poster_path: '/irony-poster.jpg', - backdrop_path: '/irony-backdrop.jpg', - credits: { - cast: [ - { - name: 'Андрей Мягков', - order: 0, - profile_path: '/myagkov.jpg', - }, - ], - crew: [{ name: 'Эльдар Рязанов', job: 'Director' }], - }, - }; - - it('prefers TMDB editorial fields and attaches cast/backdrop', () => { - const merged = mergeStalkerInfoWithTmdb( - providerStalkerInfo, - tmdbMovieRu, - 'movie' - ); - - expect(merged.description).toBe('Описание из TMDB'); - expect(merged.actors).toBe('Андрей Мягков'); - expect(merged.director).toBe('Эльдар Рязанов'); - expect(merged.genre).toBe('комедия'); - expect(merged.movie_image).toBe( - 'https://image.tmdb.org/t/p/w500/irony-poster.jpg' - ); - expect(merged.tmdb_backdrop).toBe( - 'https://image.tmdb.org/t/p/w1280/irony-backdrop.jpg' - ); - expect(merged.tmdb_cast).toEqual([ - { - name: 'Андрей Мягков', - profileUrl: 'https://image.tmdb.org/t/p/w185/myagkov.jpg', - }, - ]); - // Provider keeps its own fields where TMDB should not win - expect(merged.name).toBe('Ирония судьбы, или С лёгким паром!'); - expect(merged.rating_kinopoisk).toBe('8.1'); - expect(merged.releasedate).toBe('1976'); - }); - - it('only fills rating_imdb when the provider left it empty', () => { - const merged = mergeStalkerInfoWithTmdb( - providerStalkerInfo, - tmdbMovieRu, - 'movie' - ); - expect(merged.rating_imdb).toBe('7.9'); - - const withProviderRating = mergeStalkerInfoWithTmdb( - { ...providerStalkerInfo, rating_imdb: '8.4' }, - tmdbMovieRu, - 'movie' - ); - expect(withProviderRating.rating_imdb).toBe('8.4'); - }); - - it('uses series creators as director for tv items', () => { - const tv: TmdbTvDetails = { - id: 1, - overview: 'tv overview', - created_by: [{ name: 'Создатель Сериала' }], - }; - const merged = mergeStalkerInfoWithTmdb(providerStalkerInfo, tv, 'tv'); - expect(merged.director).toBe('Создатель Сериала'); - }); - - it('keeps provider values for missing TMDB fields', () => { - const merged = mergeStalkerInfoWithTmdb( - { ...providerStalkerInfo, description: 'Провайдерский сюжет' }, - { id: 20992 }, - 'movie' - ); - - expect(merged.description).toBe('Провайдерский сюжет'); - expect(merged.actors).toBe('Провайдерский актёр'); - expect(merged.tmdb_cast).toBeUndefined(); - expect(merged.tmdb_backdrop).toBeUndefined(); - }); -}); diff --git a/libs/services/src/lib/tmdb/tmdb-merge.ts b/libs/services/src/lib/tmdb/tmdb-merge.ts index f583d6482..c4e0a2cca 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.ts @@ -64,6 +64,46 @@ function creatorNames(details: TmdbTvDetails): string { .join(', '); } +/** + * Directors (movies) as clickable person chips — same shape as the cast + * chips, so a director opens the same person page as an actor. Deduped by + * TMDB id to collapse the duplicate crew rows TMDB sometimes returns. + */ +function enrichedDirectors( + credits: TmdbCredits | undefined +): TmdbEnrichedCastMember[] { + const seen = new Set(); + const directors: TmdbEnrichedCastMember[] = []; + for (const member of credits?.crew ?? []) { + if (member.job !== 'Director' || !member.name) { + continue; + } + if (member.id !== undefined) { + if (seen.has(member.id)) { + continue; + } + seen.add(member.id); + } + directors.push({ + name: member.name, + profileUrl: tmdbProfileUrl(member.profile_path), + ...(member.id ? { tmdbPersonId: member.id } : {}), + }); + } + return directors; +} + +/** Series creators as clickable person chips (TV shows have no director) */ +function enrichedCreators(details: TmdbTvDetails): TmdbEnrichedCastMember[] { + return (details.created_by ?? []) + .filter((creator) => Boolean(creator.name)) + .map((creator) => ({ + name: creator.name, + profileUrl: tmdbProfileUrl(creator.profile_path), + ...(creator.id ? { tmdbPersonId: creator.id } : {}), + })); +} + const MAX_RECOMMENDATIONS = 12; /** Best YouTube trailer key: official trailer > any trailer > teaser */ @@ -130,6 +170,7 @@ export function mergeVodInfoWithTmdb( details: TmdbMovieDetails ): XtreamVodInfo { const tmdbCast = enrichedCast(details.credits); + const tmdbDirectors = enrichedDirectors(details.credits); const trailer = pickTrailerKey(details); const recommendations = recommendationList(details); const cast = castNames(details.credits); @@ -163,6 +204,7 @@ export function mergeVodInfoWithTmdb( backdrop_path: mergedBackdrops(details, info.backdrop_path), episode_run_time: info.episode_run_time || (details.runtime ?? 0), youtube_trailer: prefer(trailer, info.youtube_trailer), + ...(tmdbDirectors.length > 0 ? { tmdb_directors: tmdbDirectors } : {}), ...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}), ...(recommendations.length > 0 ? { tmdb_recommendations: recommendations } @@ -175,6 +217,7 @@ export function mergeSerieInfoWithTmdb( details: TmdbTvDetails ): XtreamSerieInfo { const tmdbCast = enrichedCast(details.credits); + const tmdbDirectors = enrichedCreators(details); const trailer = pickTrailerKey(details); const recommendations = recommendationList(details); const cast = castNames(details.credits); @@ -197,6 +240,7 @@ export function mergeSerieInfoWithTmdb( backdrop_path: mergedBackdrops(details, info.backdrop_path), youtube_trailer: prefer(trailer, info.youtube_trailer), tmdb_id: details.id, + ...(tmdbDirectors.length > 0 ? { tmdb_directors: tmdbDirectors } : {}), ...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}), ...(recommendations.length > 0 ? { tmdb_recommendations: recommendations } @@ -216,6 +260,10 @@ export function mergeStalkerInfoWithTmdb( mediaType: TmdbMediaType ): StalkerVodInfo { const tmdbCast = enrichedCast(details.credits); + const tmdbDirectors = + mediaType === 'movie' + ? enrichedDirectors(details.credits) + : enrichedCreators(details as TmdbTvDetails); const trailer = pickTrailerKey(details); const recommendations = recommendationList(details); const cast = castNames(details.credits); @@ -245,6 +293,7 @@ export function mergeStalkerInfoWithTmdb( tmdb_id: details.id, ...(backdrop ? { tmdb_backdrop: backdrop } : {}), ...(trailer ? { tmdb_trailer: trailer } : {}), + ...(tmdbDirectors.length > 0 ? { tmdb_directors: tmdbDirectors } : {}), ...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}), ...(recommendations.length > 0 ? { tmdb_recommendations: recommendations } diff --git a/libs/services/src/lib/tmdb/tmdb-person.spec.ts b/libs/services/src/lib/tmdb/tmdb-person.spec.ts index 117b19d52..94e9c0404 100644 --- a/libs/services/src/lib/tmdb/tmdb-person.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-person.spec.ts @@ -35,6 +35,30 @@ const person: TmdbPersonDetails = { { id: 42, title: 'No media type' }, { id: 43, media_type: 'movie', title: ' ' }, ], + crew: [ + { + id: 16869, + media_type: 'movie', + title: 'Legends of the Fall', + release_date: '1994-12-16', + job: 'Producer', + }, + { + id: 1422, + media_type: 'movie', + title: 'The Departed', + release_date: '2006-10-05', + job: 'Director', + }, + { + // Directed a title he also starred in — acting credit wins + id: 550, + media_type: 'movie', + title: 'Fight Club', + release_date: '1999-10-15', + job: 'Director', + }, + ], }, }; @@ -57,19 +81,47 @@ describe('mapPersonFilmography', () => { const credits = mapPersonFilmography(person); expect(credits.map((credit) => credit.tmdbId)).toEqual([ - 550, 1104, 999, + 1422, 550, 1104, 999, ]); - expect(credits[0]).toEqual({ + expect(credits[1]).toEqual({ tmdbId: 550, mediaType: 'movie', title: 'Fight Club', year: 1999, posterUrl: 'https://image.tmdb.org/t/p/w500/fc.jpg', character: 'Tyler Durden', + crewJob: null, }); - expect(credits[1].mediaType).toBe('tv'); + expect(credits[2].mediaType).toBe('tv'); // Undated entries sort last - expect(credits[2].year).toBeNull(); + expect(credits[3].year).toBeNull(); + }); + + it('includes directing credits from the crew, acting wins the dedup', () => { + const credits = mapPersonFilmography(person); + + // Directed-only title carries the role separately from character, + // so the UI can render it through a translated label + const departed = credits.find((credit) => credit.tmdbId === 1422); + expect(departed).toEqual({ + tmdbId: 1422, + mediaType: 'movie', + title: 'The Departed', + year: 2006, + posterUrl: null, + character: null, + crewJob: 'Director', + }); + + // Non-director crew jobs (Producer) are not part of the filmography + expect( + credits.find((credit) => credit.tmdbId === 16869) + ).toBeUndefined(); + + // Fight Club was acted AND directed — the acting credit wins + const fightClub = credits.find((credit) => credit.tmdbId === 550); + expect(fightClub?.character).toBe('Tyler Durden'); + expect(fightClub?.crewJob).toBeNull(); }); it('handles a person without credits', () => { diff --git a/libs/services/src/lib/tmdb/tmdb-person.ts b/libs/services/src/lib/tmdb/tmdb-person.ts index e3b64855b..13da913ce 100644 --- a/libs/services/src/lib/tmdb/tmdb-person.ts +++ b/libs/services/src/lib/tmdb/tmdb-person.ts @@ -17,16 +17,30 @@ export interface ActorProfile { photoUrl: string | null; } +export type ActorCrewJob = 'Director' | 'Creator'; + export interface ActorFilmographyCredit { tmdbId: number; mediaType: 'movie' | 'tv'; title: string; year: number | null; posterUrl: string | null; + /** Character name for acting credits, null for crew-only credits */ character: string | null; + /** + * Crew role for directing-only credits — kept separate from + * `character` so the UI can render it through translated labels + * instead of TMDB's raw English job string. + */ + crewJob: ActorCrewJob | null; } const MAX_FILMOGRAPHY_CREDITS = 80; +/** Crew jobs worth showing on the person page (directors, TV creators) */ +const FILMOGRAPHY_CREW_JOBS = new Set([ + 'Director', + 'Creator', +] satisfies ActorCrewJob[]); export function mapPersonProfile(person: TmdbPersonDetails): ActorProfile { return { @@ -41,8 +55,11 @@ export function mapPersonProfile(person: TmdbPersonDetails): ActorProfile { } /** - * Deduplicated acting credits, newest first (undated entries last), - * capped at {@link MAX_FILMOGRAPHY_CREDITS}. + * Deduplicated acting + directing credits in one merged list, newest + * first (undated entries last), capped at {@link MAX_FILMOGRAPHY_CREDITS}. + * Acting credits win the dedup so "Actor — Character" survives when a + * person both starred in and directed the same title; directing-only + * titles carry the crew role in `crewJob` for translated rendering. */ export function mapPersonFilmography( person: TmdbPersonDetails @@ -50,7 +67,14 @@ export function mapPersonFilmography( const seen = new Set(); const credits: ActorFilmographyCredit[] = []; - for (const credit of person.combined_credits?.cast ?? []) { + const castCredits = (person.combined_credits?.cast ?? []).map( + (credit) => ({ credit, crewJob: null as ActorCrewJob | null }) + ); + const crewCredits = (person.combined_credits?.crew ?? []) + .filter((credit) => credit.job && FILMOGRAPHY_CREW_JOBS.has(credit.job)) + .map((credit) => ({ credit, crewJob: credit.job as ActorCrewJob })); + + for (const { credit, crewJob } of [...castCredits, ...crewCredits]) { const mediaType = credit.media_type === 'movie' || credit.media_type === 'tv' ? credit.media_type @@ -69,6 +93,7 @@ export function mapPersonFilmography( year: extractYear(credit.release_date ?? credit.first_air_date), posterUrl: tmdbPosterUrl(credit.poster_path), character: credit.character?.trim() || null, + crewJob, }); } diff --git a/libs/services/src/lib/tmdb/tmdb.types.ts b/libs/services/src/lib/tmdb/tmdb.types.ts index 87565fa44..f36ee6c06 100644 --- a/libs/services/src/lib/tmdb/tmdb.types.ts +++ b/libs/services/src/lib/tmdb/tmdb.types.ts @@ -45,9 +45,11 @@ export interface TmdbCastMember { } export interface TmdbCrewMember { + id?: number; name: string; job?: string; department?: string; + profile_path?: string | null; } export interface TmdbCredits { @@ -97,7 +99,11 @@ export interface TmdbTvDetails extends TmdbDetailsBase { original_name?: string; first_air_date?: string; episode_run_time?: number[]; - created_by?: { name: string }[]; + created_by?: { + id?: number; + name: string; + profile_path?: string | null; + }[]; } export interface TmdbEpisode { @@ -118,7 +124,7 @@ export interface TmdbSeasonDetails { episodes?: TmdbEpisode[]; } -/** One acting credit from /person/{id} combined_credits */ +/** One credit from /person/{id} combined_credits (cast or crew) */ export interface TmdbPersonCredit { id: number; media_type?: string; @@ -128,7 +134,11 @@ export interface TmdbPersonCredit { /** TV credits */ name?: string; first_air_date?: string; + /** Acting credits (cast array) */ character?: string; + /** Crew credits (crew array) — e.g. "Director" / "Directing" */ + job?: string; + department?: string; poster_path?: string | null; vote_count?: number; popularity?: number; @@ -142,7 +152,10 @@ export interface TmdbPersonDetails { deathday?: string | null; place_of_birth?: string | null; profile_path?: string | null; - combined_credits?: { cast?: TmdbPersonCredit[] }; + combined_credits?: { + cast?: TmdbPersonCredit[]; + crew?: TmdbPersonCredit[]; + }; } export type TmdbDetails = TmdbMovieDetails | TmdbTvDetails; diff --git a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts index 35f65278a..98e2d1458 100644 --- a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts @@ -39,6 +39,8 @@ export interface StalkerVodInfo { rating_kinopoisk: string; /** Populated by TMDB enrichment; absent in raw portal responses */ tmdb_cast?: TmdbEnrichedCastMember[]; + /** Directors (movies) / creators (series) as clickable person chips */ + tmdb_directors?: TmdbEnrichedCastMember[]; /** TMDB backdrop URL — Stalker portals never provide one themselves */ tmdb_backdrop?: string; /** YouTube trailer key from TMDB — Stalker portals provide no trailers */ diff --git a/libs/shared/interfaces/src/lib/vod-details-adapters.ts b/libs/shared/interfaces/src/lib/vod-details-adapters.ts index ff1520a72..608b3b772 100644 --- a/libs/shared/interfaces/src/lib/vod-details-adapters.ts +++ b/libs/shared/interfaces/src/lib/vod-details-adapters.ts @@ -30,6 +30,7 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta { ratingKinopoisk: info?.rating_kinopoisk, youtubeTrailer: info?.youtube_trailer, tmdbCast: info?.tmdb_cast, + tmdbDirectors: info?.tmdb_directors, tmdbRecommendations: info?.tmdb_recommendations, }; } @@ -59,6 +60,7 @@ export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta // Stalker portals provide no trailers; TMDB enrichment can youtubeTrailer: info?.tmdb_trailer, tmdbCast: info?.tmdb_cast, + tmdbDirectors: info?.tmdb_directors, tmdbRecommendations: info?.tmdb_recommendations, }; } diff --git a/libs/shared/interfaces/src/lib/vod-details-item.interface.ts b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts index d66bfab8f..1bb8b607a 100644 --- a/libs/shared/interfaces/src/lib/vod-details-item.interface.ts +++ b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts @@ -84,6 +84,8 @@ export interface NormalizedVodMeta { youtubeTrailer?: string; /** Cast with profile photos, populated by TMDB enrichment */ tmdbCast?: TmdbEnrichedCastMember[]; + /** Directors (movies) / creators (series) as clickable person chips */ + tmdbDirectors?: TmdbEnrichedCastMember[]; /** TMDB recommendations (drives the cross-portal "Similar" rail) */ tmdbRecommendations?: TmdbRecommendation[]; } diff --git a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts index c82a7f138..b5a047c7c 100644 --- a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts @@ -26,6 +26,8 @@ export interface XtreamSerieInfo { category_id: string; /** Populated by TMDB enrichment; absent in raw provider responses */ tmdb_cast?: TmdbEnrichedCastMember[]; + /** Directors (movies) / creators (series) as clickable person chips */ + tmdb_directors?: TmdbEnrichedCastMember[]; /** Populated by TMDB enrichment; matched against the catalog in views */ tmdb_recommendations?: TmdbRecommendation[]; /** Matched TMDB show id — enables lazy season/episode enrichment */ diff --git a/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts b/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts index d9b47f51f..10fbf2e1f 100644 --- a/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts @@ -36,6 +36,8 @@ export interface XtreamVodInfo { rating_imdb?: string; /** Populated by TMDB enrichment; absent in raw provider responses */ tmdb_cast?: TmdbEnrichedCastMember[]; + /** Directors (movies) / creators (series) as clickable person chips */ + tmdb_directors?: TmdbEnrichedCastMember[]; /** Populated by TMDB enrichment; matched against the catalog in views */ tmdb_recommendations?: TmdbRecommendation[]; } diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index 59730c10b..7e578926c 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -37,7 +37,7 @@ {{ 'XTREAM.ACTORS' | translate }} @if (meta.tmdbCast?.length) {
- @for (member of meta.tmdbCast; track member.name) { + @for (member of meta.tmdbCast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { {{ 'XTREAM.DIRECTOR' | translate }} - {{ meta.director }} + @if (meta.tmdbDirectors?.length) { +
+ @for (member of meta.tmdbDirectors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ meta.director }} + }
} @if (meta.ratingKinopoisk) { diff --git a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html index 69bfefd90..e9aeecc34 100644 --- a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html +++ b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html @@ -165,6 +165,8 @@ } @if (item.character) { · {{ item.character }} + } @else if (item.crewJob) { + · {{ crewJobKey(item.crewJob) | translate }} }
diff --git a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts index 0d55cb647..0c6b993b7 100644 --- a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts +++ b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts @@ -52,6 +52,13 @@ export class ActorViewComponent { readonly filterMode = signal<'all' | 'available'>('all'); + /** Translated label key for a crew-only credit ("Director"/"Creator") */ + crewJobKey(job: 'Director' | 'Creator'): string { + return job === 'Creator' + ? 'XTREAM.CREW_JOB_CREATOR' + : 'XTREAM.CREW_JOB_DIRECTOR'; + } + readonly visibleItems = computed(() => this.showAvailabilityFilter() && this.filterMode() === 'available' ? this.items().filter((item) => item.available) From 13b27a51babd614117b48d913fa8e5e7f6ceb354 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:12:13 +0200 Subject: [PATCH 23/26] fix(search): find punctuation-joined words like "A&E" anywhere in titles (#1172) Search normalization splits "A&E" into the 1-letter tokens "a" + "e", and short first tokens are prefix-anchored for performance (GLOB 'a*' plus a startsWith gate in the ranking), so provider-prefixed channels such as "US: A&E" could never match. Words that keep internal punctuation after edge-trimming ("a&e", "x-men", "l'equipe") are now preserved as compound words and matched as an intact substring in addition to the existing token logic: - global Xtream search supplements the unchanged prefix/GLOB arm with a trigram FTS substring lookup (MATCH '"a&e"'), deduped by content id, falling back to the content scan if FTS is unavailable - the ranking gate lets multi-token phrases match as a space-bounded whole- word sequence anywhere in the title ("US: A&E", score 40) without crossing word boundaries ("Casa e Villa" stays excluded) - per-playlist search and the M3U payload prefilter OR-in intact-word contains patterns SQL conditions compose per word so a compound word's substring arm stays AND-constrained by the other words of the query ("A&E HD" cannot flood the bounded candidate window with plain "A&E" titles); the FTS supplement AND-s the non-compound tokens as LIKE conditions. Pure search-text helpers moved into content-search.util.ts. Fixes #1161 Co-Authored-By: Claude Fable 5 --- .../operations/content-search.util.spec.ts | 151 +++++++ .../operations/content-search.util.ts | 370 ++++++++++++++++ .../operations/content.operations.spec.ts | 301 ++++++++++++- .../database/operations/content.operations.ts | 410 ++++++++---------- docs/architecture/sqlite-db-worker.md | 15 +- 5 files changed, 1014 insertions(+), 233 deletions(-) create mode 100644 apps/electron-backend/src/app/database/operations/content-search.util.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/content-search.util.ts diff --git a/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts b/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts new file mode 100644 index 000000000..c668afcf6 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-search.util.spec.ts @@ -0,0 +1,151 @@ +import { + buildCompoundFtsMatchQuery, + buildCompoundLikePatterns, + buildM3uPayloadCompoundPatterns, + getCompoundResidualTokenGroups, + getCompoundSearchWords, + getSearchWordPlans, + scoreSearchTextMatch, + shouldUseContentTitlePrefixIndex, +} from './content-search.util'; + +describe('content-search.util', () => { + describe('getCompoundSearchWords', () => { + it('extracts punctuation-joined words from the raw search term', () => { + expect(getCompoundSearchWords('A&E')).toEqual(['A&E']); + expect(getCompoundSearchWords('US A&E HD')).toEqual(['A&E']); + expect(getCompoundSearchWords('Spider-Man')).toEqual([ + 'Spider-Man', + ]); + expect(getCompoundSearchWords("L'Équipe")).toEqual(["L'Équipe"]); + }); + + it('trims edge punctuation before deciding whether a word is compound', () => { + expect(getCompoundSearchWords('(A&E)')).toEqual(['A&E']); + expect(getCompoundSearchWords('"A&E:"')).toEqual(['A&E']); + }); + + it('ignores plain words, standalone punctuation and too-short fragments', () => { + expect(getCompoundSearchWords('History')).toEqual([]); + expect(getCompoundSearchWords('Tom & Jerry')).toEqual([]); + expect(getCompoundSearchWords('a&')).toEqual([]); + expect(getCompoundSearchWords('')).toEqual([]); + expect(getCompoundSearchWords(undefined)).toEqual([]); + }); + + }); + + describe('getSearchWordPlans', () => { + it('keeps each word with its own token groups and compound flag', () => { + expect(getSearchWordPlans('A&E HD')).toEqual([ + { compound: 'A&E', tokenGroups: [['a'], ['e']] }, + { compound: null, tokenGroups: [['hd']] }, + ]); + }); + + it('skips punctuation-only words', () => { + expect(getSearchWordPlans('Tom & Jerry')).toEqual([ + { compound: null, tokenGroups: [['tom']] }, + { compound: null, tokenGroups: [['jerry']] }, + ]); + }); + }); + + describe('getCompoundResidualTokenGroups', () => { + it('returns the token groups of the non-compound words only', () => { + expect(getCompoundResidualTokenGroups('A&E HD')).toEqual([['hd']]); + expect(getCompoundResidualTokenGroups('A&E')).toEqual([]); + expect(getCompoundResidualTokenGroups('History')).toEqual([ + ['history'], + ]); + }); + }); + + describe('buildCompoundFtsMatchQuery', () => { + it('quotes each compound word as a trigram substring phrase', () => { + expect(buildCompoundFtsMatchQuery('A&E')).toBe('"a&e"'); + expect(buildCompoundFtsMatchQuery('X-Men')).toBe('"x-men"'); + }); + + it('keeps accented and diacritic-stripped variants', () => { + expect(buildCompoundFtsMatchQuery("L'Équipe")).toBe( + '("l\'équipe" OR "l\'equipe")' + ); + }); + + it('joins multiple compound words with AND', () => { + expect(buildCompoundFtsMatchQuery('A&E X-Men')).toBe( + '"a&e" AND "x-men"' + ); + }); + + it('returns an empty query for terms without compound words', () => { + expect(buildCompoundFtsMatchQuery('History Channel')).toBe(''); + expect(buildCompoundFtsMatchQuery('tv')).toBe(''); + }); + }); + + describe('buildCompoundLikePatterns', () => { + it('builds case-variant contains patterns around the intact word', () => { + const patterns = buildCompoundLikePatterns('A&E'); + + expect(patterns).toContain('%a&e%'); + expect(patterns).toContain('%A&E%'); + expect( + patterns.every( + (pattern) => + pattern.startsWith('%') && pattern.endsWith('%') + ) + ).toBe(true); + }); + + it('escapes LIKE wildcards inside the compound word', () => { + expect(buildCompoundLikePatterns('a_b')).toContain('%a\\_b%'); + }); + }); + + describe('buildM3uPayloadCompoundPatterns', () => { + it('scopes compound contains patterns to payload name/title fields', () => { + const patterns = buildM3uPayloadCompoundPatterns('A&E'); + + expect(patterns).toContain('%"name":"%a&e%"%'); + expect(patterns).toContain('%"title":"%a&e%"%'); + }); + }); + + describe('shouldUseContentTitlePrefixIndex', () => { + it('still routes compound short-token terms through the prefix index', () => { + // The compound FTS lookup supplements the prefix arm instead of + // replacing it, so titles starting with "A & E" keep matching. + expect(shouldUseContentTitlePrefixIndex('A&E')).toBe(true); + expect(shouldUseContentTitlePrefixIndex('Spider-Man')).toBe(false); + }); + }); + + describe('scoreSearchTextMatch', () => { + it('matches compound words anywhere in the title (issue #1161)', () => { + expect(scoreSearchTextMatch('US: A&E', 'A&E')).toBe(40); + expect(scoreSearchTextMatch('US | A&E HD', 'A&E HD')).toBe(40); + expect(scoreSearchTextMatch('(US) A&E', 'A&E')).toBe(40); + }); + + it('keeps exact and prefix compound matches ranked above mid-title ones', () => { + expect(scoreSearchTextMatch('A&E', 'A&E')).toBe(0); + expect(scoreSearchTextMatch('A&E HD', 'A&E')).toBe(10); + }); + + it('does not match the phrase across word boundaries', () => { + expect(scoreSearchTextMatch('Casa e Villa', 'A&E')).toBeNull(); + expect(scoreSearchTextMatch('Bravo Espana', 'A&E')).toBeNull(); + }); + + it('requires every extra token, not just the compound word', () => { + expect(scoreSearchTextMatch('US: A&E', 'A&E HD')).toBeNull(); + }); + + it('keeps single short tokens anchored to the title start', () => { + expect(scoreSearchTextMatch('TV Sport News', 'tv')).toBe(10); + expect(scoreSearchTextMatch('Test TV', 'tv')).toBeNull(); + }); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content-search.util.ts b/apps/electron-backend/src/app/database/operations/content-search.util.ts new file mode 100644 index 000000000..dc0d9488b --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-search.util.ts @@ -0,0 +1,370 @@ +/** + * Pure text helpers for the content search pipeline (global search, + * per-playlist search and M3U payload search). Everything here is + * side-effect free and independent of drizzle so it can be unit tested + * without database mocks; the SQL condition builders that consume these + * helpers live in `content.operations.ts`. + */ + +/** Minimum length the trigram FTS index can match (trigram = 3 chars). */ +const MIN_COMPOUND_WORD_LENGTH = 3; + +const WORD_EDGE_PUNCTUATION_REGEXP = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu; +const INNER_PUNCTUATION_REGEXP = /[^\p{L}\p{N}]/u; + +export function escapeLikePattern(term: string): string { + return term.replace(/[%_\\]/g, '\\$&'); +} + +export function normalizeSearchMatchText(value: unknown): string { + return typeof value === 'string' + ? value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLocaleLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .replace(/\s+/g, ' ') + : ''; +} + +function normalizeSqlSearchText(value: unknown): string { + return typeof value === 'string' + ? value + .toLocaleLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .replace(/\s+/g, ' ') + : ''; +} + +function getSearchTokens(value: unknown): string[] { + const normalized = normalizeSearchMatchText(value); + return normalized ? normalized.split(' ') : []; +} + +export function getSqlSearchTokenGroups(value: unknown): string[][] { + const rawTokens = normalizeSqlSearchText(value).split(' ').filter(Boolean); + const normalizedTokens = getSearchTokens(value); + const tokenCount = Math.max(rawTokens.length, normalizedTokens.length); + + return Array.from({ length: tokenCount }, (_, index) => + [...new Set([rawTokens[index], normalizedTokens[index]])].filter( + Boolean + ) + ).filter((group) => group.length > 0); +} + +export function isShortSearchTokenGroup(tokens: readonly string[]): boolean { + return tokens.some((token) => token.length <= 2); +} + +function getSqlSearchTokenVariants(value: unknown): string[] { + return [...new Set(getSqlSearchTokenGroups(value).flat())]; +} + +export function buildLikePatterns( + term: string, + mode: 'contains' | 'prefix' = 'contains' +): string[] { + const variants = new Set(); + + for (const value of [term, ...getSqlSearchTokenVariants(term)]) { + const trimmedValue = value.trim(); + if (!trimmedValue) { + continue; + } + + const titleCase = + trimmedValue.length > 0 + ? trimmedValue.charAt(0).toLocaleUpperCase() + + trimmedValue.slice(1).toLocaleLowerCase() + : trimmedValue; + + variants.add(trimmedValue); + variants.add(trimmedValue.toLocaleLowerCase()); + variants.add(trimmedValue.toLocaleUpperCase()); + variants.add(titleCase); + } + + return [...variants].map((value) => { + const escapedValue = escapeLikePattern(value); + return mode === 'prefix' ? `${escapedValue}%` : `%${escapedValue}%`; + }); +} + +export function buildGlobPrefixPatterns(token: string): string[] { + const variants = new Set(); + + for (const value of [token, ...getSqlSearchTokenVariants(token)]) { + variants.add(value); + variants.add(value.toLocaleLowerCase()); + variants.add(value.toLocaleUpperCase()); + variants.add( + value.charAt(0).toLocaleUpperCase() + + value.slice(1).toLocaleLowerCase() + ); + } + + return [...variants].map((value) => `${value}*`); +} + +export function shouldUseContentTitlePrefixIndex(searchTerm: string): boolean { + const [firstTokenGroup] = getSqlSearchTokenGroups(searchTerm); + + return !!firstTokenGroup && isShortSearchTokenGroup(firstTokenGroup); +} + +export function buildContentTitleFtsMatchQuery(searchTerm: string): string { + return getSqlSearchTokenGroups(searchTerm) + .map((tokens) => { + const quotedTokens = tokens + .filter((token) => token.length >= 3) + .map((token) => `"${token.replace(/"/g, '""')}"`); + + if (quotedTokens.length <= 1) { + return quotedTokens[0] ?? ''; + } + + return `(${quotedTokens.join(' OR ')})`; + }) + .filter(Boolean) + .join(' AND '); +} + +export function shouldUseContentTitleFts(searchTerm: string): boolean { + return ( + !shouldUseContentTitlePrefixIndex(searchTerm) && + buildContentTitleFtsMatchQuery(searchTerm).length > 0 + ); +} + +/** + * Whitespace-delimited words from the raw search term that still contain + * internal punctuation after trimming their edges — e.g. `a&e`, `x-men`, + * `l'équipe`. Tokenization splits these into (often 1-letter) fragments + * whose short-token handling anchors the search to the title start, so the + * intact word is preserved as an additional exact-substring search unit + * (issue #1161: "A&E" not finding "US: A&E"). + */ +export function getCompoundSearchWords(value: unknown): string[] { + if (typeof value !== 'string') { + return []; + } + + const words = new Set(); + for (const rawWord of value.split(/\s+/)) { + const word = rawWord.replace(WORD_EDGE_PUNCTUATION_REGEXP, ''); + if ( + word.length < MIN_COMPOUND_WORD_LENGTH || + !INNER_PUNCTUATION_REGEXP.test(word) + ) { + continue; + } + words.add(word); + } + + return [...words]; +} + +/** + * One whitespace-delimited search word with its token groups. `compound` is + * the intact word when it contains internal punctuation (see + * `getCompoundSearchWords`), else null. SQL condition builders compose + * per-word conditions from this so a compound word's substring arm stays + * AND-constrained by the other words of the query — `A&E HD` must not let + * plain `A&E` titles flood the SQL candidate window before scoring runs. + */ +export interface SearchWordPlan { + compound: string | null; + tokenGroups: string[][]; +} + +export function getSearchWordPlans(value: unknown): SearchWordPlan[] { + if (typeof value !== 'string') { + return []; + } + + const plans: SearchWordPlan[] = []; + for (const rawWord of value.split(/\s+/)) { + const word = rawWord.replace(WORD_EDGE_PUNCTUATION_REGEXP, ''); + if (!word) { + continue; + } + + const tokenGroups = getSqlSearchTokenGroups(word); + if (tokenGroups.length === 0) { + continue; + } + + const isCompound = + word.length >= MIN_COMPOUND_WORD_LENGTH && + INNER_PUNCTUATION_REGEXP.test(word); + plans.push({ compound: isCompound ? word : null, tokenGroups }); + } + + return plans; +} + +/** + * Token groups of the non-compound words of the term. When the compound FTS + * arm looks up `"a&e"` for `A&E HD`, these groups (`hd`) are re-applied as + * LIKE conditions so the supplement cannot fill the candidate limit with + * titles that match the compound word alone. + */ +export function getCompoundResidualTokenGroups(value: unknown): string[][] { + return getSearchWordPlans(value) + .filter((plan) => plan.compound === null) + .flatMap((plan) => plan.tokenGroups); +} + +/** As-typed plus diacritic-stripped forms, both long enough for trigram FTS. */ +function getCompoundWordVariants(word: string): string[] { + const stripped = word.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); + + return [...new Set([word, stripped])].filter( + (variant) => variant.length >= MIN_COMPOUND_WORD_LENGTH + ); +} + +/** + * Case/diacritic LIKE variants of a compound word as contains patterns + * (`%a&e%`). SQLite LIKE is only case-insensitive for ASCII, so the same + * lower/upper/title-case expansion as `buildLikePatterns` is applied. + */ +export function buildCompoundLikePatterns(word: string): string[] { + const variants = new Set(); + + for (const value of getCompoundWordVariants(word)) { + variants.add(value); + variants.add(value.toLocaleLowerCase()); + variants.add(value.toLocaleUpperCase()); + variants.add( + value.charAt(0).toLocaleUpperCase() + + value.slice(1).toLocaleLowerCase() + ); + } + + return [...variants].map((value) => `%${escapeLikePattern(value)}%`); +} + +/** + * Trigram FTS match query treating each compound word as a quoted substring + * phrase: `"a&e"`, `("café" OR "cafe")`. The trigram tokenizer matches + * case-insensitive substrings of >= 3 chars, so this finds the compound word + * anywhere in the title without a table scan. Returns '' when the term has + * no compound words. + */ +export function buildCompoundFtsMatchQuery(searchTerm: string): string { + return getCompoundSearchWords(searchTerm) + .map((word) => { + const quotedVariants = [ + ...new Set( + getCompoundWordVariants(word).map((variant) => + variant.toLocaleLowerCase() + ) + ), + ].map((variant) => `"${variant.replace(/"/g, '""')}"`); + + if (quotedVariants.length <= 1) { + return quotedVariants[0] ?? ''; + } + + return `(${quotedVariants.join(' OR ')})`; + }) + .filter(Boolean) + .join(' AND '); +} + +export function buildM3uPayloadTextFieldPatterns( + token: string, + mode: 'contains' | 'prefix' +): string[] { + return buildLikePatterns(token, mode).flatMap((pattern) => + wrapM3uPayloadTextFieldPattern(pattern) + ); +} + +/** Compound-word contains patterns scoped to M3U payload name/title fields. */ +export function buildM3uPayloadCompoundPatterns(word: string): string[] { + return buildCompoundLikePatterns(word).flatMap((pattern) => + wrapM3uPayloadTextFieldPattern(pattern) + ); +} + +function wrapM3uPayloadTextFieldPattern(pattern: string): string[] { + return [ + `%"name":"${pattern}"%`, + `%"name": "${pattern}"%`, + `%"title":"${pattern}"%`, + `%"title": "${pattern}"%`, + ]; +} + +export function scoreSearchTextMatch( + value: string, + searchTerm: string +): number | null { + const candidateText = normalizeSearchMatchText(value); + const searchText = normalizeSearchMatchText(searchTerm); + if (!candidateText || !searchText) { + return null; + } + + const searchTokens = searchText.split(' '); + const candidateTokens = candidateText.split(' '); + const firstSearchToken = searchTokens[0]; + + if ( + firstSearchToken.length <= 2 && + !candidateText.startsWith(firstSearchToken) + ) { + // Short first tokens stay prefix-anchored to keep one-letter noise + // out, but a multi-token phrase ("a e" from "A&E") may still match as + // a whole word sequence anywhere in the title ("US: A&E"). + if ( + searchTokens.length > 1 && + ` ${candidateText} `.includes(` ${searchText} `) + ) { + return 40; + } + return null; + } + + if (candidateText === searchText) { + return 0; + } + + if ( + candidateText.startsWith(searchText) && + (searchTokens.length > 1 || firstSearchToken.length <= 2) + ) { + return 10; + } + + if (candidateTokens.some((token) => token.startsWith(searchText))) { + return 20; + } + + if ( + searchTokens.every((searchToken) => + candidateTokens.some((candidateToken) => + candidateToken.startsWith(searchToken) + ) + ) + ) { + return 30; + } + + if (candidateText.includes(searchText)) { + return 40; + } + + if ( + searchTokens.every((searchToken) => candidateText.includes(searchToken)) + ) { + return 50; + } + + return null; +} diff --git a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts index d3dd2db06..586dfd065 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts @@ -12,6 +12,10 @@ const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({ left, values, })); +const orMock = jest.fn((...conditions: unknown[]) => ({ + kind: 'or', + conditions, +})); const sqlJoinMock = jest.fn((chunks: unknown[], separator?: unknown) => ({ kind: 'sql.join', chunks, @@ -34,7 +38,7 @@ jest.mock('drizzle-orm', () => ({ desc: jest.fn(), eq: (left: unknown, right: unknown) => eqMock(left, right), inArray: (left: unknown, values: unknown[]) => inArrayMock(left, values), - or: jest.fn(), + or: (...conditions: unknown[]) => orMock(...conditions), sql: sqlMock, })); @@ -47,6 +51,7 @@ import { getGlobalRecentlyAdded, saveContent, scoreSearchTextMatch, + searchContent, } from './content.operations'; function createDbMock(result: unknown[] = []) { @@ -111,6 +116,7 @@ describe('content.operations', () => { andMock.mockClear(); eqMock.mockClear(); inArrayMock.mockClear(); + orMock.mockClear(); sqlJoinMock.mockClear(); sqlMock.mockClear(); }); @@ -752,6 +758,299 @@ describe('content.operations', () => { ).toBe(true); }); + it('finds compound-word channels anywhere in M3U channel names (issue #1161)', () => { + const makeChannel = (id: string, name: string) => ({ + id, + url: `https://stream.test/${id}.m3u8`, + name, + group: { title: 'Entertainment' }, + tvg: { + id, + name: '', + url: '', + logo: '', + rec: '', + }, + http: { + referrer: '', + 'user-agent': '', + origin: '', + }, + radio: '', + }); + + const results = buildM3uGlobalSearchResults( + [ + { + id: 'm3u-1', + name: 'M3U One', + payload: JSON.stringify({ + playlist: { + items: [ + makeChannel('prefixed', 'US: A&E'), + makeChannel('exact', 'A&E'), + makeChannel('noise', 'Casa e Villa'), + makeChannel('other', 'Bravo Espana'), + ], + }, + }), + }, + ], + 'A&E', + false + ); + + expect(results.map((item) => item.title)).toEqual(['A&E', 'US: A&E']); + }); + + it('finds compound-word titles anywhere via per-playlist search (issue #1161)', async () => { + const makeRow = (id: number, title: string) => ({ + id, + category_id: 10, + title, + rating: '', + added: '', + poster_url: '', + epg_channel_id: '', + tv_archive: 0, + tv_archive_duration: 0, + direct_source: '', + xtream_id: id, + type: 'live', + }); + const limit = jest + .fn() + .mockResolvedValue([ + makeRow(1, 'US: A&E'), + makeRow(2, 'Casa e Villa'), + ]); + const where = jest.fn().mockReturnValue({ limit }); + const innerJoin = jest.fn().mockReturnValue({ where }); + const from = jest.fn().mockReturnValue({ innerJoin }); + const select = jest.fn().mockReturnValue({ from }); + const db = { + select, + } as unknown as AppDatabase; + + const results = await searchContent(db, 'playlist-1', 'A&E', ['live']); + + expect(results.map((item) => item.title)).toEqual(['US: A&E']); + + // The intact compound word must reach SQL as a contains pattern and + // be OR-combined with the prefix-anchored token conditions. + const patterns = sqlMock.mock.calls + .flatMap(([, ...values]) => values) + .filter((value): value is string => typeof value === 'string'); + expect(patterns).toContain('%a&e%'); + expect(where.mock.calls[0][0].conditions).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: 'or' })]) + ); + }); + + it('supplements short compound global searches with a trigram FTS substring lookup', async () => { + const makeRow = (id: number, title: string) => ({ + id, + category_id: 10, + title, + rating: '', + added: '', + poster_url: '', + epg_channel_id: '', + tv_archive: 0, + tv_archive_duration: 0, + direct_source: '', + xtream_id: id, + type: 'live', + playlist_id: 'playlist-1', + playlist_name: 'Playlist One', + }); + const all = jest + .fn() + // Prefix-index arm: titles starting with the short first token. + .mockResolvedValueOnce([makeRow(1, 'A&E')]) + // FTS arm: compound substring matches, overlapping with the + // prefix arm to prove candidates are deduplicated. + .mockResolvedValueOnce([ + makeRow(2, 'US: A&E'), + makeRow(1, 'A&E'), + ]); + const select = jest.fn(); + const db = { + all, + select, + } as unknown as AppDatabase; + + const results = await globalSearch( + db, + 'A&E', + ['live'], + false, + ['xtream'], + { limit: 10 } + ); + + expect(all).toHaveBeenCalledTimes(2); + expect(select).not.toHaveBeenCalled(); + + const matchSqlCall = sqlMock.mock.calls.find(([strings]) => + Array.from(strings as TemplateStringsArray) + .join(' ') + .includes('content_title_fts MATCH') + ); + expect(matchSqlCall?.[1]).toBe('"a&e"'); + + expect(results.map((item) => item.title)).toEqual(['A&E', 'US: A&E']); + }); + + it('keeps non-compound tokens as conditions on the compound FTS supplement', async () => { + const all = jest.fn().mockResolvedValue([]); + const db = { + all, + select: jest.fn(), + } as unknown as AppDatabase; + + await globalSearch(db, 'A&E HD', ['live'], false, ['xtream'], { + limit: 10, + }); + + expect(all).toHaveBeenCalledTimes(2); + + const matchSqlCall = sqlMock.mock.calls.find(([strings]) => + Array.from(strings as TemplateStringsArray) + .join(' ') + .includes('content_title_fts MATCH') + ); + expect(matchSqlCall?.[1]).toBe('"a&e"'); + + // The FTS arm must AND-in the residual "hd" token so plain "A&E" + // titles cannot exhaust the candidate limit before scoring runs. + const residualPatterns = sqlMock.mock.calls + .filter(([strings]) => + Array.from(strings as TemplateStringsArray) + .join(' ') + .includes('c.title LIKE') + ) + .flatMap(([, ...values]) => values) + .filter((value): value is string => typeof value === 'string'); + expect(residualPatterns).toContain('%hd%'); + }); + + it('requires every word of a multi-token compound term in per-playlist SQL conditions', async () => { + const limit = jest.fn().mockResolvedValue([]); + const where = jest.fn().mockReturnValue({ limit }); + const innerJoin = jest.fn().mockReturnValue({ where }); + const from = jest.fn().mockReturnValue({ innerJoin }); + const select = jest.fn().mockReturnValue({ from }); + const db = { + select, + } as unknown as AppDatabase; + + await searchContent(db, 'playlist-1', 'A&E HD', ['live']); + + // One condition per word: the compound "A&E" word (an OR with the + // intact-substring arm) plus the plain "hd" word — both AND-ed, so + // the compound arm cannot bypass the "hd" requirement. + const conditions = where.mock.calls[0][0].conditions as Array<{ + kind: string; + }>; + expect(conditions).toHaveLength(4); + expect(conditions[2]).toEqual(expect.objectContaining({ kind: 'or' })); + expect(conditions[3]).toEqual(expect.objectContaining({ kind: 'and' })); + + const patterns = sqlMock.mock.calls + .flatMap(([, ...values]) => values) + .filter((value): value is string => typeof value === 'string'); + expect(patterns).toContain('%a&e%'); + expect(patterns).toContain('%hd%'); + }); + + it('falls back to a content scan when the compound FTS lookup fails', async () => { + const globRow = { + id: 1, + category_id: 10, + title: 'A&E', + rating: '', + added: '', + poster_url: '', + epg_channel_id: '', + tv_archive: 0, + tv_archive_duration: 0, + direct_source: '', + xtream_id: 1, + type: 'live', + playlist_id: 'playlist-1', + playlist_name: 'Playlist One', + }; + const scanRow = { + ...globRow, + id: 2, + xtream_id: 2, + title: 'US: A&E', + }; + const all = jest + .fn() + .mockResolvedValueOnce([globRow]) + .mockRejectedValueOnce(new Error('no such table: content_title_fts')); + const limit = jest.fn().mockResolvedValue([globRow, scanRow]); + const orderBy = jest.fn().mockReturnValue({ limit }); + const where = jest.fn().mockReturnValue({ orderBy }); + const innerJoin2 = jest.fn().mockReturnValue({ where }); + const innerJoin1 = jest.fn().mockReturnValue({ innerJoin: innerJoin2 }); + const from = jest.fn().mockReturnValue({ innerJoin: innerJoin1 }); + const select = jest.fn().mockReturnValue({ from }); + const db = { + all, + select, + } as unknown as AppDatabase; + + const results = await globalSearch( + db, + 'A&E', + ['live'], + false, + ['xtream'], + { limit: 10 } + ); + + expect(all).toHaveBeenCalledTimes(2); + expect(select).toHaveBeenCalledTimes(1); + expect(results.map((item) => item.title)).toEqual(['A&E', 'US: A&E']); + }); + + it('adds compound contains patterns to the M3U payload prefilter', async () => { + const limit = jest.fn().mockResolvedValue([]); + const orderedQuery = { + limit, + then: (resolve: (value: unknown[]) => void) => resolve([]), + }; + const orderBy = jest.fn().mockReturnValue(orderedQuery); + const where = jest.fn().mockReturnValue({ orderBy }); + const from = jest.fn().mockReturnValue({ where }); + const select = jest.fn().mockReturnValue({ from }); + const db = { + select, + } as unknown as AppDatabase; + + await globalSearch(db, 'A&E', ['live'], false, ['m3u'], { + limit: 10, + }); + + const searchPatterns = sqlMock.mock.calls + .flatMap(([, ...values]) => values) + .filter( + (value): value is string => + typeof value === 'string' && + value.toLocaleLowerCase().includes('a&e') + ); + + expect(searchPatterns).toEqual( + expect.arrayContaining([ + expect.stringContaining('"name":"%a&e%"'), + expect.stringContaining('"title":"%a&e%"'), + ]) + ); + }); + it('keeps accented M3U payload text field variants in SQL prefilters', async () => { const limit = jest.fn().mockResolvedValue([]); const orderedQuery = { diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index 7319d420b..b2eeaf8c8 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -14,6 +14,23 @@ import { XtreamGlobalSearchResult, } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; +import { + buildCompoundFtsMatchQuery, + buildCompoundLikePatterns, + buildContentTitleFtsMatchQuery, + buildGlobPrefixPatterns, + buildLikePatterns, + buildM3uPayloadCompoundPatterns, + buildM3uPayloadTextFieldPatterns, + getCompoundResidualTokenGroups, + getSearchWordPlans, + getSqlSearchTokenGroups, + isShortSearchTokenGroup, + normalizeSearchMatchText, + scoreSearchTextMatch, + shouldUseContentTitleFts, + shouldUseContentTitlePrefixIndex, +} from './content-search.util'; import { checkpointOperation, chunkValues, @@ -21,86 +38,7 @@ import { reportOperationProgress, } from './operation-control'; -function escapeLikePattern(term: string): string { - return term.replace(/[%_\\]/g, '\\$&'); -} - -function normalizeSearchMatchText(value: unknown): string { - return typeof value === 'string' - ? value - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLocaleLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, ' ') - .trim() - .replace(/\s+/g, ' ') - : ''; -} - -function normalizeSqlSearchText(value: unknown): string { - return typeof value === 'string' - ? value - .toLocaleLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, ' ') - .trim() - .replace(/\s+/g, ' ') - : ''; -} - -function getSearchTokens(value: unknown): string[] { - const normalized = normalizeSearchMatchText(value); - return normalized ? normalized.split(' ') : []; -} - -function getSqlSearchTokenGroups(value: unknown): string[][] { - const rawTokens = normalizeSqlSearchText(value).split(' ').filter(Boolean); - const normalizedTokens = getSearchTokens(value); - const tokenCount = Math.max(rawTokens.length, normalizedTokens.length); - - return Array.from({ length: tokenCount }, (_, index) => - [...new Set([rawTokens[index], normalizedTokens[index]])].filter( - Boolean - ) - ).filter((group) => group.length > 0); -} - -function isShortSearchTokenGroup(tokens: readonly string[]): boolean { - return tokens.some((token) => token.length <= 2); -} - -function getSqlSearchTokenVariants(value: unknown): string[] { - return [...new Set(getSqlSearchTokenGroups(value).flat())]; -} - -function buildLikePatterns( - term: string, - mode: 'contains' | 'prefix' = 'contains' -): string[] { - const variants = new Set(); - - for (const value of [term, ...getSqlSearchTokenVariants(term)]) { - const trimmedValue = value.trim(); - if (!trimmedValue) { - continue; - } - - const titleCase = - trimmedValue.length > 0 - ? trimmedValue.charAt(0).toLocaleUpperCase() + - trimmedValue.slice(1).toLocaleLowerCase() - : trimmedValue; - - variants.add(trimmedValue); - variants.add(trimmedValue.toLocaleLowerCase()); - variants.add(trimmedValue.toLocaleUpperCase()); - variants.add(titleCase); - } - - return [...variants].map((value) => { - const escapedValue = escapeLikePattern(value); - return mode === 'prefix' ? `${escapedValue}%` : `%${escapedValue}%`; - }); -} +export { scoreSearchTextMatch } from './content-search.util'; const DEFAULT_GLOBAL_SEARCH_LIMIT = 50; const MAX_GLOBAL_SEARCH_LIMIT = 500; @@ -174,65 +112,6 @@ function normalizeGlobalSearchPagination( return { limit, offset }; } -export function scoreSearchTextMatch( - value: string, - searchTerm: string -): number | null { - const candidateText = normalizeSearchMatchText(value); - const searchText = normalizeSearchMatchText(searchTerm); - if (!candidateText || !searchText) { - return null; - } - - const searchTokens = searchText.split(' '); - const candidateTokens = candidateText.split(' '); - const firstSearchToken = searchTokens[0]; - - if ( - firstSearchToken.length <= 2 && - !candidateText.startsWith(firstSearchToken) - ) { - return null; - } - - if (candidateText === searchText) { - return 0; - } - - if ( - candidateText.startsWith(searchText) && - (searchTokens.length > 1 || firstSearchToken.length <= 2) - ) { - return 10; - } - - if (candidateTokens.some((token) => token.startsWith(searchText))) { - return 20; - } - - if ( - searchTokens.every((searchToken) => - candidateTokens.some((candidateToken) => - candidateToken.startsWith(searchToken) - ) - ) - ) { - return 30; - } - - if (candidateText.includes(searchText)) { - return 40; - } - - if ( - searchTokens.every((searchToken) => candidateText.includes(searchToken)) - ) { - return 50; - } - - return null; -} - function compareScoredGlobalSearchResults( first: ScoredGlobalSearchResult, second: ScoredGlobalSearchResult @@ -278,73 +157,55 @@ function getGlobalSearchCandidateLimit(): number { return MAX_GLOBAL_SEARCH_CANDIDATE_LIMIT; } +/** + * Per-word title conditions, AND-ed by the caller. A word's condition is the + * AND of its token-group LIKE conditions; a punctuation-joined word ("A&E") + * additionally matches as an intact contains pattern anywhere in the title + * (issue #1161). Composing per word keeps the other words' constraints on + * the compound arm, so "A&E HD" cannot fill the SQL candidate limit with + * titles that only contain "A&E". + */ function buildContentTitleSearchConditions(searchTerm: string) { - const tokenGroups = getSqlSearchTokenGroups(searchTerm); - if (tokenGroups.length === 0) { - return []; - } + let groupIndex = 0; - return tokenGroups - .map((tokens, index) => { - const mode = - index === 0 && isShortSearchTokenGroup(tokens) - ? 'prefix' - : 'contains'; - const likeConditions = tokens.flatMap((token) => - buildLikePatterns(token, mode).map( - (pattern) => - sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'` - ) - ); - return or(...likeConditions); - }) - .filter((condition) => condition !== undefined); -} + return getSearchWordPlans(searchTerm) + .map((plan) => { + const tokenConditions = plan.tokenGroups + .map((tokens) => { + const mode = + groupIndex === 0 && isShortSearchTokenGroup(tokens) + ? 'prefix' + : 'contains'; + groupIndex += 1; + const likeConditions = tokens.flatMap((token) => + buildLikePatterns(token, mode).map( + (pattern) => + sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'` + ) + ); + return or(...likeConditions); + }) + .filter((condition) => condition !== undefined); -function shouldUseContentTitlePrefixIndex(searchTerm: string): boolean { - const [firstTokenGroup] = getSqlSearchTokenGroups(searchTerm); - - return !!firstTokenGroup && isShortSearchTokenGroup(firstTokenGroup); -} - -function buildContentTitleFtsMatchQuery(searchTerm: string): string { - return getSqlSearchTokenGroups(searchTerm) - .map((tokens) => { - const quotedTokens = tokens - .filter((token) => token.length >= 3) - .map((token) => `"${token.replace(/"/g, '""')}"`); - - if (quotedTokens.length <= 1) { - return quotedTokens[0] ?? ''; + const tokenArm = + tokenConditions.length > 0 + ? and(...tokenConditions) + : undefined; + if (plan.compound === null) { + return tokenArm; } - return `(${quotedTokens.join(' OR ')})`; + const compoundConditions = buildCompoundLikePatterns( + plan.compound + ).map( + (pattern) => + sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'` + ); + return tokenArm === undefined + ? or(...compoundConditions) + : or(tokenArm, ...compoundConditions); }) - .filter(Boolean) - .join(' AND '); -} - -function shouldUseContentTitleFts(searchTerm: string): boolean { - return ( - !shouldUseContentTitlePrefixIndex(searchTerm) && - buildContentTitleFtsMatchQuery(searchTerm).length > 0 - ); -} - -function buildGlobPrefixPatterns(token: string): string[] { - const variants = new Set(); - - for (const value of [token, ...getSqlSearchTokenVariants(token)]) { - variants.add(value); - variants.add(value.toLocaleLowerCase()); - variants.add(value.toLocaleUpperCase()); - variants.add( - value.charAt(0).toLocaleUpperCase() + - value.slice(1).toLocaleLowerCase() - ); - } - - return [...variants].map((value) => `${value}*`); + .filter((condition) => condition !== undefined); } function buildRawContentTitleSearchSql(searchTerm: string): SQL[] { @@ -371,6 +232,40 @@ function buildRawContentTitleSearchSql(searchTerm: string): SQL[] { }); } +/** + * Contains-mode LIKE conditions for the non-compound words of the term, + * applied on top of the compound FTS lookup so `A&E HD` only surfaces + * candidates that also contain `hd` (the FTS phrase can only express the + * compound word — trigram tokens need >= 3 chars). + */ +function buildCompoundResidualTitleSql(searchTerm: string): SQL[] { + return getCompoundResidualTokenGroups(searchTerm).map( + (tokens) => + sql`(${sql.join( + tokens.flatMap((token) => + buildLikePatterns(token).map( + (pattern) => sql`c.title LIKE ${pattern} ESCAPE '\\'` + ) + ), + sql` OR ` + )})` + ); +} + +function dedupeXtreamCandidatesById( + candidates: XtreamGlobalSearchCandidate[] +): XtreamGlobalSearchCandidate[] { + const seenIds = new Set(); + + return candidates.filter((candidate) => { + if (seenIds.has(candidate.id)) { + return false; + } + seenIds.add(candidate.id); + return true; + }); +} + async function selectXtreamGlobalSearchCandidatesWithTitleIndex( db: AppDatabase, searchTerm: string, @@ -415,12 +310,12 @@ async function selectXtreamGlobalSearchCandidatesWithTitleIndex( async function selectXtreamGlobalSearchCandidatesWithFts( db: AppDatabase, - searchTerm: string, + matchQuery: string, types: string[], excludeHidden: boolean, - candidateLimit: number + candidateLimit: number, + residualTitleConditions: SQL[] = [] ): Promise { - const matchQuery = buildContentTitleFtsMatchQuery(searchTerm); if (!matchQuery || types.length === 0) { return []; } @@ -450,6 +345,11 @@ async function selectXtreamGlobalSearchCandidatesWithFts( types.map((type) => sql`${type}`), sql`, ` )}) + ${ + residualTitleConditions.length > 0 + ? sql`AND ${sql.join(residualTitleConditions, sql` AND `)}` + : sql`` + } ${excludeHidden ? sql`AND cat.hidden = 0` : sql``} ORDER BY rank, c.title LIMIT ${candidateLimit} @@ -495,38 +395,52 @@ async function selectXtreamGlobalSearchCandidatesWithContentScan( .limit(candidateLimit); } -function buildM3uPayloadTextFieldPatterns( - token: string, - mode: 'contains' | 'prefix' -): string[] { - return buildLikePatterns(token, mode).flatMap((pattern) => [ - `%"name":"${pattern}"%`, - `%"name": "${pattern}"%`, - `%"title":"${pattern}"%`, - `%"title": "${pattern}"%`, - ]); -} - +/** + * Per-word M3U payload prefilter conditions, AND-ed by the caller — the same + * compound-word composition as `buildContentTitleSearchConditions`: "A&E" + * must reach channel names like "US: A&E" (issue #1161), while the other + * words of the query keep constraining the candidate playlists. + */ function buildM3uPayloadSearchConditions(searchTerm: string) { - const tokenGroups = getSqlSearchTokenGroups(searchTerm); - if (tokenGroups.length === 0) { - return []; - } + let groupIndex = 0; - return tokenGroups - .map((tokens, index) => { - const mode = - index === 0 && isShortSearchTokenGroup(tokens) - ? 'prefix' - : 'contains'; - const patterns = tokens.flatMap((token) => - buildM3uPayloadTextFieldPatterns(token, mode) - ); - const likeConditions = patterns.map( + return getSearchWordPlans(searchTerm) + .map((plan) => { + const tokenConditions = plan.tokenGroups + .map((tokens) => { + const mode = + groupIndex === 0 && isShortSearchTokenGroup(tokens) + ? 'prefix' + : 'contains'; + groupIndex += 1; + const patterns = tokens.flatMap((token) => + buildM3uPayloadTextFieldPatterns(token, mode) + ); + const likeConditions = patterns.map( + (pattern) => + sql`${schema.playlists.payload} LIKE ${pattern} ESCAPE '\\'` + ); + return or(...likeConditions); + }) + .filter((condition) => condition !== undefined); + + const tokenArm = + tokenConditions.length > 0 + ? and(...tokenConditions) + : undefined; + if (plan.compound === null) { + return tokenArm; + } + + const compoundConditions = buildM3uPayloadCompoundPatterns( + plan.compound + ).map( (pattern) => sql`${schema.playlists.payload} LIKE ${pattern} ESCAPE '\\'` ); - return or(...likeConditions); + return tokenArm === undefined + ? or(...compoundConditions) + : or(tokenArm, ...compoundConditions); }) .filter((condition) => condition !== undefined); } @@ -1207,11 +1121,45 @@ export async function globalSearch( excludeHidden, candidateLimit ); + + // The prefix arm only sees titles that start with the short first + // token ("A&E" -> GLOB 'a*'), so a compound word like "a&e" is + // additionally looked up as a trigram FTS substring to reach + // titles such as "US: A&E" (issue #1161). The non-compound words + // of the query stay applied as LIKE conditions so this arm cannot + // fill the candidate limit with compound-only matches. + const compoundMatchQuery = buildCompoundFtsMatchQuery(searchTerm); + if (compoundMatchQuery) { + try { + const compoundCandidates = + await selectXtreamGlobalSearchCandidatesWithFts( + db, + compoundMatchQuery, + types, + excludeHidden, + candidateLimit, + buildCompoundResidualTitleSql(searchTerm) + ); + candidates = dedupeXtreamCandidatesById([ + ...candidates, + ...compoundCandidates, + ]); + } catch { + candidates = + await selectXtreamGlobalSearchCandidatesWithContentScan( + db, + searchTerm, + types, + excludeHidden, + candidateLimit + ); + } + } } else if (shouldUseContentTitleFts(searchTerm)) { try { candidates = await selectXtreamGlobalSearchCandidatesWithFts( db, - searchTerm, + buildContentTitleFtsMatchQuery(searchTerm), types, excludeHidden, candidateLimit diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 1083053f6..0e2a3705b 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -250,7 +250,20 @@ Xtream-only row shape: short first tokens such as `tv` stay anchored to the start of the title, so `TV Sport` matches but `Test TV` does not. These short first-token queries bypass trigram FTS and use the `idx_content_title` prefix index path because - trigram tokenization cannot match 1-2 character terms. + trigram tokenization cannot match 1-2 character terms. Punctuation-joined + words such as `A&E` or `X-Men` are an exception: tokenization splits them + into short fragments, so the intact word is preserved as a "compound word" + (`content-search.util.ts`) that additionally matches as an exact substring — + a supplemental trigram FTS `MATCH '"a&e"'` query for the Xtream arm (merged + and deduped with the prefix-index candidates), intact-word `LIKE` contains + patterns for the per-playlist and M3U payload prefilters, and a + space-bounded whole-phrase check in the ranking step. All compound arms + keep the remaining words of the query as SQL constraints — the FTS + supplement AND-s the non-compound tokens as `LIKE` conditions and the + `LIKE` prefilters compose per word — so `A&E HD` cannot fill the bounded + candidate window with titles that only contain `A&E`. This lets `A&E` + find `US: A&E` anywhere in the title while single short tokens stay + prefix-anchored (issue #1161). 6. `excludeHidden` still filters hidden Xtream categories and also filters M3U channels whose `group.title` is listed in the playlist payload's `hiddenGroupTitles`. From 0273ded8e2605dc480dd81423be9d21df70777f8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:46:48 +0200 Subject: [PATCH 24/26] fix(tmdb): resolve season number from title markers for per-season series slices (#1229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tmdb): resolve season number from title markers for per-season series slices Providers often slice a show into per-season catalog items ("The Mandalorian (2 season)", "Пацаны 2 сезон", "The Boys S05") and renumber the single contained season to 1, so season enrichment fetched the wrong TMDB season (season 1 metadata for a season 2 item). - new season-marker.util.ts in shared/interfaces: extractSeasonFromTitle (word-first, number-first and S-form markers, bracketed or trailing) and resolveEnrichmentSeasonNumber (title marker wins only for single-season items whose provider number disagrees) - wired into Xtream enrichSerialSeasonWithTmdb and the Stalker series-view season service (cache/overlay still keyed by provider season key) - SEASON_SUFFIX_PATTERN now also strips number-first season suffixes ("2 season", "2 сезон", "2-й сезон" incl. NFD-decomposed ordinals) so such titles match the show at all Co-Authored-By: Claude Fable 5 * fix(stalker): wait for the season map before TMDB season fetch The TMDB match can arrive before the async season resource; fetching then passed seasonCount 0, suppressed the title-marker override and cached the wrong season forever (fetchSeason is idempotent). The effect now reads the season map tracked and skips while it is empty — overlay-driven re-runs are safe because fetchSeason early-returns per (tmdbId, seasonKey). Addresses Greptile review on #1229. Co-Authored-By: Claude Fable 5 * fix(stalker): reset season selection on detail-to-detail navigation The router reuses the series view for detail-to-detail navigation, and a retained season selection let the NEW item's tmdb_id pair with the PREVIOUS series' season context in the TMDB fetch effect, poisoning its idempotent per-season cache before the new season resource loaded. The selection is now a linkedSignal keyed on the displayed item's identity — compared inside the computation, since displayItem produces a fresh object on every recomputation. Addresses Greptile review round 2 on #1229. Co-Authored-By: Claude Fable 5 * fix(stalker): include the resolved season in the TMDB season cache identity Per-season slices of one show share (tmdbId, provider season key "1") but resolve to different TMDB seasons — plain key idempotency served the first slice's episodes to every later slice. Each cache entry now records the resolved season it was fetched for: a call resolving a different season refetches and overwrites (also self-healing a fetch made with stale navigation context), an in-flight marker dedups concurrent runs, and a superseded fetch may not store its result. Failed fetches stay uncached so later triggers retry. Addresses Codex review (P1) and Greptile review round 3 on #1229. Co-Authored-By: Claude Fable 5 * fix(stalker): drop a mismatched season cache entry before its replacement fetch If a replacement fetch (same key, different resolved season) failed, the previous slice's entry stayed visible indefinitely through overlay() and descriptions(). The mismatched entry is now removed up front, so a failed replacement falls back to provider data until a retry succeeds. Addresses Codex review (P2) on #1229. Co-Authored-By: Claude Fable 5 * fix(stalker): title-based season reset identity and o_name marker support - The season-selection reset identity now combines provider id and title: distinct items can share or lack provider ids, and an id-only identity retained the previous item's selection across such navigation - The season marker is read from whichever title field carries it via pickSeasonMarkedTitle: providers put the descriptive title in o_name while name stays generic, and the show-level match already used o_name Addresses Greptile review round 4 (P1) and Codex review (P2) on #1229. Co-Authored-By: Claude Fable 5 * fix(stalker): gate TMDB season fetch on resource coherence, not selection resets Resetting the parent season selection on item identity change (previous round) silently disabled enrichment after detail-to-detail navigation between items sharing one season-key set: the season container keeps its own selection and deduplicates seasonSelected emissions, so the parent key stayed null forever. The reset is gone; instead the fetch effect gates on coherence — it waits while the season resource reloads (the window in which a reused component pairs the new item's tmdb_id with the previous item's map) and requires the selected key to exist in the map with episodes. Stale-snapshot fetches remain self-healing through the resolution-aware cache. Addresses Codex review (P2) and Greptile review round 5 on #1229. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CLAUDE.md | 2 +- docs/architecture/tmdb-metadata-enrichment.md | 25 ++- ...talker-series-tmdb-seasons.service.spec.ts | 158 ++++++++++++++++++ .../stalker-series-tmdb-seasons.service.ts | 120 ++++++++++--- .../stalker-series-view.component.spec.ts | 145 +++++++++++++++- .../stalker-series-view.component.ts | 46 ++++- .../lib/stores/xtream-tmdb-enrichment.spec.ts | 78 +++++++++ .../src/lib/stores/xtream-tmdb-enrichment.ts | 13 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/season-marker.util.spec.ts | 116 +++++++++++++ .../interfaces/src/lib/season-marker.util.ts | 122 ++++++++++++++ .../src/lib/title-normalization.util.spec.ts | 15 ++ .../src/lib/title-normalization.util.ts | 22 ++- 13 files changed, 822 insertions(+), 41 deletions(-) create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts create mode 100644 libs/shared/interfaces/src/lib/season-marker.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/season-marker.util.ts diff --git a/CLAUDE.md b/CLAUDE.md index 04e4c99a9..536b830aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -803,7 +803,7 @@ engine` (restart required) or - Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works - "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation -- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`) +- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`) - Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint - Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-merge.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) - Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts` diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 08fd79010..5c8b322fb 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -176,10 +176,33 @@ detail views lazily fetch `/tv/{tmdbId}/season/{n}` via - episodes without a TMDB counterpart (by episode number) pass through untouched +The season number `{n}` is the provider's episode season number, with one +correction (`resolveEnrichmentSeasonNumber` in +`libs/shared/interfaces/src/lib/season-marker.util.ts`): providers often +slice a show into per-season catalog items ("The Mandalorian (2 season)", +"Пацаны 2 сезон", "The Boys S05") and renumber the single contained season +to 1. When the item contains exactly ONE season and the raw title carries +an explicit season marker (`extractSeasonFromTitle`: `s02`, `season 2`, +`2 season`, `2nd season`, `сезон 2`, `2-й сезон`, `staffel`/`temporada`/ +`saison` forms, bracketed or not) that differs from the provider's number, +the marker wins and that TMDB season is fetched. Multi-season items always +keep provider numbering. `normalizeTitleKeys` strips the same markers +(including number-first forms like "2 сезон") from search titles, so the +show-level match is unaffected by them. + Wiring: Xtream — `XtreamStore.enrichSelectedSerialSeason(seasonKey)` fired from the serial detail's `(seasonSelected)`; Stalker — the series view keeps a `${tmdbId}|${seasonKey}`-keyed map and overlays it inside its -`mappedSeasons` computed. Without a show-level match or with enrichment +`mappedSeasons` computed. Each Stalker entry records the RESOLVED season +it was fetched for: per-season slices of one show share +(tmdbId, provider key "1") but resolve to different seasons, and a fetch +made with stale detail-to-detail navigation context is overwritten once +the real context re-resolves. The fetch effect gates on coherence rather +than timing: it waits while the season resource reloads and requires the +selected key to exist in the map with episodes. The retained season +selection deliberately survives navigation — the season container +deduplicates `seasonSelected` emissions, so items sharing one season-key +set would otherwise never re-trigger enrichment. Without a show-level match or with enrichment disabled everything is a no-op — the `SeasonContainer` UI already renders every episode field conditionally. diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts new file mode 100644 index 000000000..046beade4 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts @@ -0,0 +1,158 @@ +import { TestBed } from '@angular/core/testing'; +import { TmdbEnrichmentService } from '@iptvnator/services'; +import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service'; + +describe('StalkerSeriesTmdbSeasonsService', () => { + let service: StalkerSeriesTmdbSeasonsService; + let getSeason: jest.Mock; + + const episodesOfSeason = (season: number): XtreamSerieEpisode[] => [ + { + id: `${season}-1`, + episode_num: 1, + season, + title: 'Episode 1', + } as unknown as XtreamSerieEpisode, + ]; + + beforeEach(() => { + getSeason = jest.fn().mockResolvedValue({ + overview: 'Season overview', + episodes: [{ episode_number: 1, name: 'The Marshal' }], + }); + TestBed.configureTestingModule({ + providers: [ + StalkerSeriesTmdbSeasonsService, + { + provide: TmdbEnrichmentService, + useValue: { getSeason }, + }, + ], + }); + service = TestBed.inject(StalkerSeriesTmdbSeasonsService); + }); + + it('fetches the title-marked season for a renumbered single-season slice', async () => { + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + + expect(getSeason).toHaveBeenCalledWith(82856, 2); + + // Overlay still keys by the provider's season key + const overlaid = service.overlay( + { '1': episodesOfSeason(1) }, + 82856 + ); + expect(overlaid['1'][0].title).toBe('The Marshal'); + }); + + it('keeps provider numbering for multi-season items despite a marker', async () => { + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 3, + }); + + expect(getSeason).toHaveBeenCalledWith(82856, 1); + }); + + it('keeps provider numbering without fetch context', async () => { + await service.fetchSeason(82856, '1', episodesOfSeason(1)); + + expect(getSeason).toHaveBeenCalledWith(82856, 1); + }); + + it('skips a repeat fetch for the same resolved season', async () => { + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + + expect(getSeason).toHaveBeenCalledTimes(1); + }); + + it('refetches when the same provider key resolves to another season', async () => { + getSeason.mockResolvedValueOnce({ + overview: 'Season 2 overview', + episodes: [{ episode_number: 1, name: 'Season 2 Episode' }], + }); + // Per-season slices of ONE show share (tmdbId, provider key "1") + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + expect(getSeason).toHaveBeenCalledWith(82856, 2); + + getSeason.mockResolvedValueOnce({ + overview: 'Season 3 overview', + episodes: [{ episode_number: 1, name: 'Season 3 Episode' }], + }); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (3 сезон)', + seasonCount: 1, + }); + expect(getSeason).toHaveBeenCalledWith(82856, 3); + + // The newer resolution overwrote the entry under the shared key + const overlaid = service.overlay({ '1': episodesOfSeason(1) }, 82856); + expect(overlaid['1'][0].title).toBe('Season 3 Episode'); + expect(service.descriptions(82856)['1']).toBe('Season 3 overview'); + }); + + it('drops the stale entry when a replacement fetch fails', async () => { + getSeason.mockResolvedValueOnce({ + overview: 'Season 2 overview', + episodes: [{ episode_number: 1, name: 'Season 2 Episode' }], + }); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + + // Replacement resolution (another slice of the show) fails — + // the season-2 entry must not stay on screen for the new slice + getSeason.mockResolvedValueOnce(null); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (3 сезон)', + seasonCount: 1, + }); + + const overlaid = service.overlay({ '1': episodesOfSeason(1) }, 82856); + expect(overlaid['1'][0].title).toBe('Episode 1'); + expect(service.descriptions(82856)).toEqual({}); + + // A later trigger retries and fills the correct season + getSeason.mockResolvedValueOnce({ + overview: 'Season 3 overview', + episodes: [{ episode_number: 1, name: 'Season 3 Episode' }], + }); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (3 сезон)', + seasonCount: 1, + }); + const healed = service.overlay({ '1': episodesOfSeason(1) }, 82856); + expect(healed['1'][0].title).toBe('Season 3 Episode'); + }); + + it('does not cache a failed fetch, so a later trigger retries', async () => { + getSeason.mockResolvedValueOnce(null); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + + expect(getSeason).toHaveBeenCalledTimes(2); + const overlaid = service.overlay({ '1': episodesOfSeason(1) }, 82856); + expect(overlaid['1'][0].title).toBe('The Marshal'); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts index 0f53e518d..0d5539bf8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts @@ -4,7 +4,16 @@ import { mergeEpisodesWithTmdb, type TmdbEpisode, } from '@iptvnator/services'; -import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { + XtreamSerieEpisode, + resolveEnrichmentSeasonNumber, +} from '@iptvnator/shared/interfaces'; + +interface FetchedTmdbSeason { + /** Resolved TMDB season number this entry was fetched for */ + seasonNumber: number; + episodes: TmdbEpisode[]; +} /** * Component-scoped holder for lazily fetched TMDB season data (episode @@ -12,18 +21,24 @@ import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; * the component's `providers`). * * Entries are keyed by `${tmdbId}|${seasonKey}` so data from a previously - * shown series can never leak into the current one. + * shown series can never leak into the current one, and each entry records + * the RESOLVED TMDB season it holds: per-season slices of one show share + * (tmdbId, provider key "1") but resolve to different seasons, and a fetch + * made with stale navigation context must be overwritten once the real + * context re-resolves — plain key idempotency would block both. */ @Injectable() export class StalkerSeriesTmdbSeasonsService { private readonly tmdbEnrichment = inject(TmdbEnrichmentService); - private readonly episodesByKey = signal< - ReadonlyMap + private readonly seasonsByKey = signal< + ReadonlyMap >(new Map()); private readonly overviewsByKey = signal>( new Map() ); + /** mapKey → season number currently being fetched (in-flight dedup) */ + private readonly pending = new Map(); /** * Overlays fetched TMDB episode data (real names, overviews, stills) @@ -34,14 +49,14 @@ export class StalkerSeriesTmdbSeasonsService { seasons: Record, tmdbId: number | null | undefined ): Record { - const fetched = this.episodesByKey(); + const fetched = this.seasonsByKey(); if (!tmdbId || fetched.size === 0) { return seasons; } const merged: Record = {}; for (const [seasonKey, episodes] of Object.entries(seasons)) { - const forSeason = fetched.get(`${tmdbId}|${seasonKey}`); + const forSeason = fetched.get(`${tmdbId}|${seasonKey}`)?.episodes; merged[seasonKey] = forSeason?.length ? mergeEpisodesWithTmdb(episodes, forSeason) : episodes; @@ -72,47 +87,98 @@ export class StalkerSeriesTmdbSeasonsService { /** * Lazily pulls the TMDB season (episode list + overview) for an opened * season; a no-op without a show-level TMDB match, with enrichment - * disabled, or when the season was already fetched. + * disabled, or when the entry already holds the resolved season. + * `context` carries the raw provider title and total season count so a + * per-season slice ("The Mandalorian (2 season)" with its single season + * renumbered to 1) fetches the season the title names instead of the + * provider's number. A later call resolving a DIFFERENT season for the + * same key (another slice of the show, or corrected navigation context) + * refetches and overwrites the entry. */ async fetchSeason( tmdbId: number | null | undefined, seasonKey: string, - episodes: XtreamSerieEpisode[] | undefined + episodes: XtreamSerieEpisode[] | undefined, + context?: { rawTitle?: string | null; seasonCount?: number } ): Promise { if (!tmdbId) { return; } + const providerSeasonNumber = Number(episodes?.[0]?.season ?? seasonKey); + if (!Number.isFinite(providerSeasonNumber)) { + return; + } + + const seasonNumber = resolveEnrichmentSeasonNumber({ + rawTitle: context?.rawTitle, + providerSeasonNumber, + providerSeasonCount: context?.seasonCount ?? 0, + }); + const mapKey = `${tmdbId}|${seasonKey}`; - if (this.episodesByKey().has(mapKey)) { + const cached = this.seasonsByKey().get(mapKey); + if ( + cached?.seasonNumber === seasonNumber || + this.pending.get(mapKey) === seasonNumber + ) { return; } - const seasonNumber = Number(episodes?.[0]?.season ?? seasonKey); - if (!Number.isFinite(seasonNumber)) { - return; - } + this.pending.set(mapKey, seasonNumber); - const season = await this.tmdbEnrichment.getSeason( - tmdbId, - seasonNumber - ); - if (!season) { - return; + // A mismatched entry belongs to another slice/context of this + // show — drop it BEFORE fetching so a failed replacement fetch + // can never leave the wrong season's metadata on screen (the + // overlay then falls back to provider data until a retry). + if (cached) { + this.deleteEntry(mapKey); } + try { + const season = await this.tmdbEnrichment.getSeason( + tmdbId, + seasonNumber + ); + + // A newer resolution for this key superseded us mid-flight — + // only the latest requested fetch may store its result. + if (this.pending.get(mapKey) !== seasonNumber) { + return; + } + if (!season) { + // Transient failure — stays uncached so a later trigger + // can retry. + return; + } - if (season.overview) { const overviews = new Map(this.overviewsByKey()); - overviews.set(mapKey, season.overview); + if (season.overview) { + overviews.set(mapKey, season.overview); + } else { + overviews.delete(mapKey); + } this.overviewsByKey.set(overviews); - } - if (!season.episodes?.length) { - return; + const next = new Map(this.seasonsByKey()); + next.set(mapKey, { + seasonNumber, + episodes: season.episodes ?? [], + }); + this.seasonsByKey.set(next); + } finally { + if (this.pending.get(mapKey) === seasonNumber) { + this.pending.delete(mapKey); + } } + } - const next = new Map(this.episodesByKey()); - next.set(mapKey, season.episodes); - this.episodesByKey.set(next); + private deleteEntry(mapKey: string): void { + const next = new Map(this.seasonsByKey()); + next.delete(mapKey); + this.seasonsByKey.set(next); + + const overviews = new Map(this.overviewsByKey()); + overviews.delete(mapKey); + this.overviewsByKey.set(overviews); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 3aaa2899b..693fc5824 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -79,6 +79,7 @@ describe('StalkerSeriesViewComponent', () => { const selectedItem = signal(null); const serialSeasonsResource = signal([]); const vodSeriesSeasonsResource = signal([]); + const isSerialSeasonsLoading = signal(false); const fetchVodSeriesEpisodes = jest.fn(); const resolveVodPlayback = jest.fn(); const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]); @@ -106,6 +107,7 @@ describe('StalkerSeriesViewComponent', () => { }, ]); vodSeriesSeasonsResource.set([]); + isSerialSeasonsLoading.set(false); fetchVodSeriesEpisodes.mockReset(); resolveVodPlayback.mockReset(); resolveVodPlayback.mockImplementation( @@ -156,7 +158,7 @@ describe('StalkerSeriesViewComponent', () => { getVodSeriesSeasonsResource: () => vodSeriesSeasonsResource(), isVodSeriesSeasonsLoading: signal(false), - isSerialSeasonsLoading: signal(false), + isSerialSeasonsLoading, fetchVodSeriesEpisodes, resolveVodPlayback, fetchLinkToPlay: jest.fn(), @@ -816,4 +818,145 @@ describe('StalkerSeriesViewComponent', () => { expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1); }); + + it('waits for the season map before fetching so a per-season slice gets the title-marked season', async () => { + serialSeasonsResource.set([]); + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series (2 season)', + description: 'Series description', + movie_image: 'poster.jpg', + tmdb_id: 777, + }, + } as never); + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.onSeasonSelected('1'); + fixture.detectChanges(); + await fixture.whenStable(); + + // Season resource still loading — fetching now would pass a zero + // season count, suppress the title-marker override and cache the + // wrong season forever (fetchSeason is idempotent). + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + serialSeasonsResource.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_30001.mpg', + series: [1, 2], + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Single-season slice whose provider season is renumbered to 1: + // the title marker names the real TMDB season. + expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2); + }); + + it('gates the fetch on the reloading season resource during detail-to-detail navigation', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.onSeasonSelected('1'); + fixture.detectChanges(); + await fixture.whenStable(); + // No show-level TMDB match yet — nothing fetched for the first item + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + // Detail-to-detail navigation reuses the component; the new item's + // TMDB match can arrive while the season resource reloads and the + // map still shows the previous series' seasons. + isSerialSeasonsLoading.set(true); + selectedItem.set({ + id: '30002', + cmd: '/media/file_30002.mpg', + info: { + name: 'Other Series (2 season)', + description: 'Other description', + movie_image: 'poster2.jpg', + tmdb_id: 888, + }, + } as never); + fixture.detectChanges(); + await fixture.whenStable(); + + // The new tmdb_id must NOT pair with the previous series' season + // context while the resource reloads. + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + // Once the new item's own seasons land, the fetch runs WITHOUT a + // new seasonSelected emission — the season container deduplicates + // emissions when both items share the same season-key set, so the + // retained key must stay usable. + serialSeasonsResource.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_30002.mpg', + series: [1, 2], + }, + ]); + isSerialSeasonsLoading.set(false); + fixture.detectChanges(); + await fixture.whenStable(); + expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2); + }); + + it('enriches after equal-id navigation once the season resource settles', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentInstance.onSeasonSelected('1'); + fixture.detectChanges(); + await fixture.whenStable(); + + // Distinct items can reuse a provider id; the loading gate (not an + // id comparison) keeps the stale map from being used. + isSerialSeasonsLoading.set(true); + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Different Series (3 season)', + description: 'Different description', + movie_image: 'poster3.jpg', + tmdb_id: 999, + }, + } as never); + fixture.detectChanges(); + await fixture.whenStable(); + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + isSerialSeasonsLoading.set(false); + fixture.detectChanges(); + await fixture.whenStable(); + expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3); + }); + + it('reads the season marker from o_name when name is generic', async () => { + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series', + o_name: 'Regular Series (2 season)', + description: 'Series description', + movie_image: 'poster.jpg', + tmdb_id: 777, + }, + } as never); + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.onSeasonSelected('1'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 55316a158..0c0a0a02f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -27,6 +27,7 @@ import { ResolvedPortalPlayback, TmdbEnrichedCastMember, XtreamSerieEpisode, + pickSeasonMarkedTitle, youtubeEmbedUrl, } from '@iptvnator/shared/interfaces'; import { SafePipe } from '@iptvnator/pipes'; @@ -162,7 +163,15 @@ export class StalkerSeriesViewComponent implements OnDestroy { }); }); - /** Season currently selected in the season container. */ + /** + * Season currently selected in the season container. Deliberately NOT + * reset on detail-to-detail navigation: the season container keeps its + * own selection and deduplicates `seasonSelected` emissions, so when + * two items share the same season-key set (commonly just "1") it never + * re-emits — a parent-side reset would leave the new item permanently + * unenriched. Stale-context safety lives in the fetch effect's + * coherence gates instead (see the constructor). + */ private readonly selectedSeasonKey = signal(null); /** Season descriptions for the season tabs (TMDB overview per season). */ @@ -204,14 +213,43 @@ export class StalkerSeriesViewComponent implements OnDestroy { // tmdb_id — so the fetch must re-run when the match arrives, not only // on selection. fetchSeason is idempotent per (tmdbId, season). effect(() => { - const tmdbId = this.displayItem()?.info?.tmdb_id; + const item = this.displayItem(); + const tmdbId = item?.info?.tmdb_id; const seasonKey = this.selectedSeasonKey(); - if (tmdbId && seasonKey) { + // Coherence gates instead of timing assumptions. All inputs are + // read TRACKED so the effect re-runs as each one settles: + // - the season resource must not be mid-reload — during + // detail-to-detail navigation a reused component briefly + // pairs the NEW item's tmdb_id with the PREVIOUS item's map + // - the selected key must exist in the map with episodes — an + // empty map would pass seasonCount 0 (suppressing the + // title-marker override), and a key retained from the + // previous item is only usable when the new item has that + // season too (otherwise the container's auto-select re-emits) + // Re-running on overlay updates cannot loop (fetchSeason skips + // when its entry already holds the resolved season), and a + // fetch made with a stale snapshot is overwritten once the + // real context re-resolves to a different season. + const seasonsLoading = this.isVodSeries() + ? this.isVodSeriesSeasonsLoading() + : this.isSerialSeasonsLoading(); + const seasons = this.mappedSeasons(); + const episodes = seasonKey ? seasons[seasonKey] : undefined; + if (tmdbId && seasonKey && !seasonsLoading && episodes?.length) { untracked(() => void this.tmdbSeasons.fetchSeason( tmdbId, seasonKey, - this.mappedSeasons()[seasonKey] + episodes, + { + // The season marker can live in either title + // field (generic name + descriptive o_name) + rawTitle: pickSeasonMarkedTitle( + item?.info?.name, + item?.info?.o_name + ), + seasonCount: Object.keys(seasons).length, + } ) ); } diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts index 9c4690bbb..5df070cc8 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts @@ -1,5 +1,6 @@ import type { TmdbEnrichmentService } from '@iptvnator/services'; import { + enrichSerialSeasonWithTmdb, enrichSerialSelectionWithTmdb, enrichVodSelectionWithTmdb, } from './xtream-tmdb-enrichment'; @@ -25,6 +26,7 @@ function createEnrichment(overrides: Partial = {}) { isEnabled: jest.fn(() => true), enrichMovie: jest.fn().mockResolvedValue(null), enrichTv: jest.fn().mockResolvedValue(null), + getSeasonEpisodes: jest.fn().mockResolvedValue(null), ...overrides, } as unknown as TmdbEnrichmentService; } @@ -173,3 +175,79 @@ describe('enrichSerialSelectionWithTmdb', () => { expect(enrichment.enrichTv).not.toHaveBeenCalled(); }); }); + +describe('enrichSerialSeasonWithTmdb', () => { + function seasonSliceItem( + name: string, + episodes: Record + ) { + return { + series_id: '7', + info: { ...serialItem.info, name, tmdb_id: 82856 }, + episodes: Object.fromEntries( + Object.entries(episodes).map(([key, list]) => [ + key, + list.map((episode) => ({ + id: `${key}-${episode.episode_num}`, + title: `Episode ${episode.episode_num}`, + ...episode, + })), + ]) + ), + }; + } + + it('fetches the title-marked season for a renumbered single-season slice', async () => { + const store = createStore( + seasonSliceItem('The Mandalorian (2 season)', { + '1': [{ episode_num: 1, season: 1 }], + }) + ); + const enrichment = createEnrichment({ + getSeasonEpisodes: jest + .fn() + .mockResolvedValue([ + { episode_number: 1, name: 'The Marshal' }, + ]), + } as Partial); + + await enrichSerialSeasonWithTmdb(store, enrichment, '1'); + + expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 2); + const updated = store.setSelectedItem.mock.calls[0][0] as { + episodes: Record; + }; + expect(updated.episodes['1'][0].title).toBe('The Marshal'); + }); + + it('keeps provider numbering for multi-season items despite a marker', async () => { + const store = createStore( + seasonSliceItem('The Mandalorian (2 season)', { + '1': [{ episode_num: 1, season: 1 }], + '2': [{ episode_num: 1, season: 2 }], + }) + ); + const enrichment = createEnrichment({ + getSeasonEpisodes: jest.fn().mockResolvedValue([]), + } as Partial); + + await enrichSerialSeasonWithTmdb(store, enrichment, '1'); + + expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1); + }); + + it('keeps provider numbering when the title has no marker', async () => { + const store = createStore( + seasonSliceItem('The Mandalorian', { + '1': [{ episode_num: 1, season: 1 }], + }) + ); + const enrichment = createEnrichment({ + getSeasonEpisodes: jest.fn().mockResolvedValue([]), + } as Partial); + + await enrichSerialSeasonWithTmdb(store, enrichment, '1'); + + expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts index 79bc4de43..8d32d389d 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts @@ -9,6 +9,7 @@ import { XtreamSerieDetails, XtreamVodDetails, getXtreamVodInfo, + resolveEnrichmentSeasonNumber, } from '@iptvnator/shared/interfaces'; /** @@ -162,11 +163,19 @@ export async function enrichSerialSeasonWithTmdb< return; } - const seasonNumber = Number(episodes[0]?.season ?? seasonKey); - if (!Number.isFinite(seasonNumber)) { + const providerSeasonNumber = Number(episodes[0]?.season ?? seasonKey); + if (!Number.isFinite(providerSeasonNumber)) { return; } + // Per-season provider slices ("The Mandalorian (2 season)") renumber + // their single season to 1 — the title marker names the real TMDB season + const seasonNumber = resolveEnrichmentSeasonNumber({ + rawTitle: info.name, + providerSeasonNumber, + providerSeasonCount: Object.keys(selected?.episodes ?? {}).length, + }); + const tmdbEpisodes = await enrichment.getSeasonEpisodes( info.tmdb_id, seasonNumber diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 2f25d78e0..2f99efe03 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -35,6 +35,7 @@ export * from './lib/store-keys.enum'; export * from './lib/stream-format.enum'; export * from './lib/catalog-title-match.interface'; export * from './lib/theme.enum'; +export * from './lib/season-marker.util'; export * from './lib/title-normalization.util'; export * from './lib/tmdb.interface'; export * from './lib/xtream-account-info-dialog-data.interface'; diff --git a/libs/shared/interfaces/src/lib/season-marker.util.spec.ts b/libs/shared/interfaces/src/lib/season-marker.util.spec.ts new file mode 100644 index 000000000..f03132810 --- /dev/null +++ b/libs/shared/interfaces/src/lib/season-marker.util.spec.ts @@ -0,0 +1,116 @@ +import { + extractSeasonFromTitle, + pickSeasonMarkedTitle, + resolveEnrichmentSeasonNumber, +} from './season-marker.util'; + +describe('extractSeasonFromTitle', () => { + it('reads bracketed number-first markers', () => { + expect(extractSeasonFromTitle('The Mandalorian (2 season)')).toBe(2); + expect(extractSeasonFromTitle('Кухня (6 сезон)')).toBe(6); + }); + + it('reads word-first markers with any joiner', () => { + expect(extractSeasonFromTitle('Breaking Bad Season 2')).toBe(2); + expect(extractSeasonFromTitle('Breaking Bad season_02')).toBe(2); + expect(extractSeasonFromTitle('Breaking Bad season2')).toBe(2); + expect(extractSeasonFromTitle('Мандалорец сезон 2')).toBe(2); + expect(extractSeasonFromTitle('Dark Staffel 3')).toBe(3); + expect(extractSeasonFromTitle('La Casa Temporada 4')).toBe(4); + }); + + it('reads number-first markers including ordinals', () => { + expect(extractSeasonFromTitle('The Boys 2 Season')).toBe(2); + expect(extractSeasonFromTitle('The Boys 2nd Season')).toBe(2); + expect(extractSeasonFromTitle('Пацаны 2 сезон')).toBe(2); + expect(extractSeasonFromTitle('Пацаны 2-й сезон')).toBe(2); + }); + + it('reads S-form markers, including S..E.. episode tags', () => { + expect(extractSeasonFromTitle('The Boys S05')).toBe(5); + expect(extractSeasonFromTitle('Gintama s2')).toBe(2); + expect(extractSeasonFromTitle('[S03] Dark')).toBe(3); + expect(extractSeasonFromTitle('Dark S02E05')).toBe(2); + }); + + it('never fires on titles without an explicit marker', () => { + expect(extractSeasonFromTitle('The Mandalorian')).toBeNull(); + expect(extractSeasonFromTitle("Ocean's 11")).toBeNull(); + expect(extractSeasonFromTitle('Cars 2')).toBeNull(); + expect(extractSeasonFromTitle('The 4400')).toBeNull(); + expect(extractSeasonFromTitle('The Four Seasons')).toBeNull(); + expect(extractSeasonFromTitle('2 Fast 2 Furious')).toBeNull(); + expect(extractSeasonFromTitle('2001: A Space Odyssey')).toBeNull(); + expect(extractSeasonFromTitle('')).toBeNull(); + expect(extractSeasonFromTitle(null)).toBeNull(); + }); + + it('rejects plurals, word-internal hits and season zero', () => { + expect(extractSeasonFromTitle('Best of 2 Seasons')).toBeNull(); + expect(extractSeasonFromTitle('Postseason 3')).toBeNull(); + expect(extractSeasonFromTitle('Dark S00')).toBeNull(); + }); +}); + +describe('pickSeasonMarkedTitle', () => { + it('prefers the title that carries a season marker', () => { + expect( + pickSeasonMarkedTitle( + 'Regular Series', + 'The Mandalorian (2 season)' + ) + ).toBe('The Mandalorian (2 season)'); + expect( + pickSeasonMarkedTitle('The Boys S05', 'The Boys') + ).toBe('The Boys S05'); + }); + + it('falls back to the first non-empty title without markers', () => { + expect(pickSeasonMarkedTitle(undefined, 'The Mandalorian')).toBe( + 'The Mandalorian' + ); + expect(pickSeasonMarkedTitle('', null)).toBeNull(); + }); +}); + +describe('resolveEnrichmentSeasonNumber', () => { + it('overrides a renumbered single-season slice with the title season', () => { + expect( + resolveEnrichmentSeasonNumber({ + rawTitle: 'The Mandalorian (2 season)', + providerSeasonNumber: 1, + providerSeasonCount: 1, + }) + ).toBe(2); + }); + + it('keeps provider numbering for multi-season items', () => { + expect( + resolveEnrichmentSeasonNumber({ + rawTitle: 'The Mandalorian (2 season)', + providerSeasonNumber: 1, + providerSeasonCount: 3, + }) + ).toBe(1); + }); + + it('keeps provider numbering when the marker agrees with it', () => { + expect( + resolveEnrichmentSeasonNumber({ + rawTitle: 'The Mandalorian (2 season)', + providerSeasonNumber: 2, + providerSeasonCount: 1, + }) + ).toBe(2); + }); + + it('keeps provider numbering without a title marker', () => { + expect( + resolveEnrichmentSeasonNumber({ + rawTitle: 'The Mandalorian', + providerSeasonNumber: 1, + providerSeasonCount: 1, + }) + ).toBe(1); + }); +}); diff --git a/libs/shared/interfaces/src/lib/season-marker.util.ts b/libs/shared/interfaces/src/lib/season-marker.util.ts new file mode 100644 index 000000000..996044630 --- /dev/null +++ b/libs/shared/interfaces/src/lib/season-marker.util.ts @@ -0,0 +1,122 @@ +/** + * Season markers in provider titles. Providers often slice a show into + * per-season catalog items ("The Mandalorian (2 season)", "Пацаны 2 сезон", + * "The Boys S05") and renumber the single contained season to 1 — the + * marker in the title is then the only source of the real season number. + * Pure functions shared by the renderer and tests — no Angular/Node deps. + */ + +/** + * Season vocabulary shared with the trailing-suffix stripper in + * `title-normalization.util.ts`. Words only — digit placement (before or + * after the word) is handled by the patterns built from it. + */ +export const SEASON_WORD_ALTERNATIVES = 'season|сезон|staffel|temporada|saison'; + +const SEASON_WORD = `(?:${SEASON_WORD_ALTERNATIVES})`; + +/** + * "Season 2", "season_02", "season2", "сезон 2". The leading guard blocks + * word-internal matches ("postseason 3"); the digit lookahead rejects + * 3+-digit numbers, which are never season markers. + */ +const WORD_FIRST_MARKER = new RegExp( + `(?:^|[^\\p{L}])${SEASON_WORD}[\\s_.-]*(\\d{1,2})(?!\\d)`, + 'iu' +); + +/** + * "2 season", "2nd Season", "2 сезон", "2-й сезон". The digit guard keeps + * long numbers out ("2001 season" never yields season 20/01), the trailing + * lookahead rejects plurals ("The Four Seasons", "2 Seasons"). + */ +const NUMBER_FIRST_MARKER = new RegExp( + `(?:^|[^\\d])(\\d{1,2})\\s*(?:st|nd|rd|th|-?й|-?я|-?ой)?[\\s_.-]+${SEASON_WORD}(?!\\p{L})`, + 'iu' +); + +/** + * "S02", "s2" — and the season half of "S02E05". Tightly bounded on both + * sides so words ending in "s" followed by a number ("Ocean's 11", + * "Cars 2") can never match. + */ +const S_FORM_MARKER = /(?:^|[\s([{_.-])s(\d{1,2})(?=$|[\s)\]}_.-]|e\d)/i; + +/** + * Extract an explicit season number from a RAW provider title (before any + * normalization — bracket groups like "(2 season)" are removed by + * `normalizeTitleKeys`, so this must see the original string). Returns + * `null` when there is no unambiguous marker; season 0 ("specials") is + * deliberately rejected. + */ +export function extractSeasonFromTitle( + raw: string | null | undefined +): number | null { + if (!raw) { + return null; + } + + for (const pattern of [ + WORD_FIRST_MARKER, + NUMBER_FIRST_MARKER, + S_FORM_MARKER, + ]) { + const match = raw.match(pattern); + if (match) { + const season = Number(match[1]); + if (season >= 1) { + return season; + } + } + } + + return null; +} + +/** + * The first candidate title carrying an explicit season marker, else the + * first non-empty one. Providers spread the descriptive title across + * fields (a generic `name` with the marker only in `o_name`), and the + * marker must be read from whichever field carries it. + */ +export function pickSeasonMarkedTitle( + ...titles: readonly (string | null | undefined)[] +): string | null { + for (const title of titles) { + if (extractSeasonFromTitle(title) !== null) { + return title ?? null; + } + } + return titles.find((title) => !!title) ?? null; +} + +export interface SeasonNumberResolution { + /** RAW provider title of the series item (not normalized) */ + rawTitle: string | null | undefined; + /** Season number reported by the provider's episode data */ + providerSeasonNumber: number; + /** How many seasons the provider item contains in total */ + providerSeasonCount: number; +} + +/** + * The TMDB season number to fetch for a provider season. Provider data + * stays authoritative except for the one case it is known to lie about: + * a SINGLE-season item whose title carries a different explicit season + * marker is a per-season slice with renumbered seasons — the marker names + * the real TMDB season. Multi-season items always keep provider numbering + * (overriding all their seasons from one title marker would be wrong). + */ +export function resolveEnrichmentSeasonNumber( + resolution: SeasonNumberResolution +): number { + const fromTitle = extractSeasonFromTitle(resolution.rawTitle); + if ( + fromTitle !== null && + resolution.providerSeasonCount === 1 && + fromTitle !== resolution.providerSeasonNumber + ) { + return fromTitle; + } + return resolution.providerSeasonNumber; +} diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts b/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts index 6566ae95c..8f8df8318 100644 --- a/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts +++ b/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts @@ -54,6 +54,21 @@ describe('normalizeTitleKeys', () => { expect(normalizeTitleKeys('The Boys s05').exact).toBe('the boys'); expect(normalizeTitleKeys('Пацаны сезон 2').base).toBe('пацаны'); }); + + it('strips number-first season suffixes ("2 season", "2 сезон")', () => { + expect(normalizeTitle('The Mandalorian 2 Season')).toBe( + 'the mandalorian' + ); + expect(normalizeTitle('The Mandalorian 2nd Season')).toBe( + 'the mandalorian' + ); + expect(normalizeTitle('Мандалорец 2 сезон')).toBe('мандалорец'); + expect(normalizeTitle('Пацаны 2-й сезон')).toBe('пацаны'); + }); + + it('keeps plural "seasons" endings — only singular markers are tags', () => { + expect(normalizeTitle('Best of 2 Seasons')).toBe('best of 2 seasons'); + }); }); describe('provider tag stripping', () => { diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.ts b/libs/shared/interfaces/src/lib/title-normalization.util.ts index b06c73694..e2963c83b 100644 --- a/libs/shared/interfaces/src/lib/title-normalization.util.ts +++ b/libs/shared/interfaces/src/lib/title-normalization.util.ts @@ -4,6 +4,8 @@ * matching). Pure functions — no Angular/Node dependencies. */ +import { SEASON_WORD_ALTERNATIVES } from './season-marker.util'; + const QUALITY_TAGS = new Set([ '4k', 'uhd', @@ -175,12 +177,22 @@ const YEAR_PATTERN = /\b(19\d{2}|20\d{2})\b/; const TRAILING_YEAR_PATTERN = /(?:^|\s)(19\d{2}|20\d{2})$/; /** - * Trailing season markers on series titles: "The Boys s05", "сезон 2". - * Uses (?:^|\s) instead of \b — JS word boundaries are ASCII-only and - * never fire next to Cyrillic letters. + * Trailing season markers on series titles: "The Boys s05", "сезон 2", + * plus number-first forms ("2 season", "Пацаны 2 сезон", "2nd Season" — + * "2-й" normalizes to "2 й", hence the optional ordinal token). The + * ordinal list carries NFD-decomposed forms too: this pattern runs after + * diacritics stripping, which turns "й" into "и" ("2-й сезон" → "2 и + * сезон"). Uses (?:^|\s) instead of \b — JS word boundaries are + * ASCII-only and never fire next to Cyrillic letters. */ -const SEASON_SUFFIX_PATTERN = - /(?:^|\s)(?:s\d{1,2}|season\s*\d{1,2}|сезон\s*\d{1,2}|staffel\s*\d{1,2}|temporada\s*\d{1,2})$/i; +const SEASON_SUFFIX_PATTERN = new RegExp( + '(?:^|\\s)(?:' + + 's\\d{1,2}' + + `|(?:${SEASON_WORD_ALTERNATIVES})\\s*\\d{1,2}` + + `|\\d{1,2}\\s*(?:st|nd|rd|th|й|и|я|ой|ои)?\\s+(?:${SEASON_WORD_ALTERNATIVES})` + + ')$', + 'iu' +); /** * A provider title normalized on two tiers. Trailing years on provider From 188f5c4b5612b7eb31a83fbbebfbc72e8e0ac832 Mon Sep 17 00:00:00 2001 From: genrichh93-ui Date: Fri, 24 Jul 2026 18:40:30 +0200 Subject: [PATCH 25/26] feat(downloads): pause and resume support for the download manager (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a paused state to the Electron download manager with a full partial-file lifecycle: - Pause keeps the .part and byte progress; cancel discards them; every lifecycle stage (queued, active, mid-transfer) is pausable. - Resume continues via HTTP Range with If-Range entity validation (strong ETag / Last-Modified persisted in the new resume_validator column, idempotent migration incl. legacy-table rebuild). Non-206 answers restart from zero over the same .part; the 206 Content-Range offset is verified; responses that end before the advertised size are retained for a Range retry instead of being committed as completed. - Crash recovery converts interrupted transfers to paused, keeps queued-with-partial rows resumable, and commits finalizations that crashed before the DB update. - Destination collisions are non-destructive (retained partials finalize to the next numbered name); locked .part files never lose their DB owner across cancel/remove/restart; resume claims rows atomically and the queue dedupes ids. - Stored request headers are re-filtered through the User-Agent/Origin/Referer allowlist on read, URL-derived extensions are sanitized, resume appends never follow symlinks, and transfer errors are logged by message only. - UI: pause/resume/cancel/retry/remove surface failures in a snackbar; paused items show an active Resume button in VOD/episode detail views; translations for all 18 locales. - Runtime split into download-runtime/transfer/finalize/broadcast modules; +30 unit tests and an Electron E2E covering pause -> retained .part -> Range/If-Range resume -> byte-exact assembly. Co-authored-by: genrichh93-ui 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../electron-backend-e2e/src/downloads.e2e.ts | 188 ++++++- .../src/app/api/main.preload.ts | 4 + .../app/events/database/download-broadcast.ts | 13 + .../database/download-file-path.spec.ts | 116 ++++ .../app/events/database/download-file-path.ts | 127 ++++- .../events/database/download-finalize.spec.ts | 276 ++++++++++ .../app/events/database/download-finalize.ts | 349 ++++++++++++ .../events/database/download-recovery.spec.ts | 417 +++++++++++++-- .../app/events/database/download-recovery.ts | 216 +++++++- .../events/database/download-requests.spec.ts | 351 ++++++++++++ .../app/events/database/download-requests.ts | 231 +++++++- .../events/database/download-reserve.spec.ts | 151 ++++++ .../events/database/download-resume.spec.ts | 337 ++++++++++++ .../download-retained-partial.spec.ts | 154 ++++++ .../events/database/download-runtime.spec.ts | 498 ++++++++++-------- .../app/events/database/download-runtime.ts | 402 ++++++++------ .../src/app/events/database/download-task.ts | 38 +- .../app/events/database/download-transfer.ts | 262 +++++++++ .../events/database/download.test-helpers.ts | 58 ++ .../events/database/downloads.events.spec.ts | 331 ++++++++++++ .../app/events/database/downloads.events.ts | 117 +++- .../events/database/stale-download-files.ts | 29 - apps/web/src/assets/i18n/ar.json | 6 +- apps/web/src/assets/i18n/ary.json | 6 +- apps/web/src/assets/i18n/by.json | 6 +- apps/web/src/assets/i18n/de.json | 6 +- apps/web/src/assets/i18n/el.json | 6 +- apps/web/src/assets/i18n/en.json | 6 +- apps/web/src/assets/i18n/es.json | 6 +- apps/web/src/assets/i18n/fr.json | 6 +- apps/web/src/assets/i18n/it.json | 6 +- apps/web/src/assets/i18n/ja.json | 6 +- apps/web/src/assets/i18n/ko.json | 6 +- apps/web/src/assets/i18n/nl.json | 6 +- apps/web/src/assets/i18n/pl.json | 6 +- apps/web/src/assets/i18n/pt.json | 6 +- apps/web/src/assets/i18n/ru.json | 6 +- apps/web/src/assets/i18n/tr.json | 6 +- apps/web/src/assets/i18n/zh.json | 6 +- apps/web/src/assets/i18n/zhtw.json | 6 +- docs/architecture/download-manager.md | 45 +- .../feature/src/lib/downloads.component.html | 76 +++ .../feature/src/lib/downloads.component.scss | 5 + .../feature/src/lib/downloads.component.ts | 44 +- .../vod-details-route.component.html | 8 + .../vod-details-route.component.ts | 20 + .../src/lib/downloads.service.spec.ts | 87 +++ libs/services/src/lib/downloads.service.ts | 83 ++- .../lib/runtime-capabilities.service.spec.ts | 4 + .../src/lib/runtime-capabilities.service.ts | 2 + .../database/src/lib/connection.spec.ts | 110 ++++ libs/shared/database/src/lib/connection.ts | 160 +++++- libs/shared/database/src/lib/schema.ts | 11 +- .../src/lib/electron-api.interface.ts | 6 + .../season-container.component.html | 35 ++ .../season-container.component.scss | 8 + .../season-container.component.ts | 29 + .../vod-details/vod-details.component.html | 8 + .../lib/vod-details/vod-details.component.ts | 35 +- .../vod-details/vod-download-state.util.ts | 34 ++ libs/ui/styles/_detail-view-actions.scss | 10 + package.json | 1 - pnpm-lock.yaml | 75 --- 63 files changed, 4986 insertions(+), 683 deletions(-) create mode 100644 apps/electron-backend/src/app/events/database/download-broadcast.ts create mode 100644 apps/electron-backend/src/app/events/database/download-finalize.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-finalize.ts create mode 100644 apps/electron-backend/src/app/events/database/download-requests.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-reserve.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-resume.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-retained-partial.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-transfer.ts create mode 100644 apps/electron-backend/src/app/events/database/download.test-helpers.ts create mode 100644 apps/electron-backend/src/app/events/database/downloads.events.spec.ts delete mode 100644 apps/electron-backend/src/app/events/database/stale-download-files.ts create mode 100644 libs/ui/playback/src/lib/vod-details/vod-download-state.util.ts diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index 6810cc3c1..fe92a4208 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -1,4 +1,6 @@ -import { mkdirSync, readdirSync } from 'fs'; +import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs'; +import { createServer } from 'http'; +import type { AddressInfo } from 'net'; import { join } from 'path'; import type { Page } from '@playwright/test'; import { @@ -17,6 +19,90 @@ async function openDownloadsPage(page: Page): Promise { await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); } +interface RangeServerRequest { + ifRange?: string; + range?: string; +} + +interface ThrottledRangeServer { + close: () => Promise; + payload: Buffer; + requests: RangeServerRequest[]; + url: string; +} + +const RANGE_SERVER_ETAG = '"e2e-range-etag"'; + +/** + * Serves a payload slowly on the first (full) request so the UI has a wide + * window to pause mid-transfer, and answers Range requests with an immediate + * 206 so the resumed transfer finishes fast. Records Range/If-Range headers. + */ +async function createThrottledRangeServer(): Promise { + const payload = Buffer.from( + Array.from({ length: 96 * 1024 }, (_, index) => + String(index % 10) + ).join('') + ); + const requests: RangeServerRequest[] = []; + + const server = createServer((req, res) => { + const range = req.headers.range; + const ifRange = req.headers['if-range']; + requests.push({ + ifRange: typeof ifRange === 'string' ? ifRange : undefined, + range: typeof range === 'string' ? range : undefined, + }); + + const offset = range + ? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN) + : 0; + if (range && Number.isFinite(offset)) { + res.writeHead(206, { + 'Content-Length': payload.length - offset, + 'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`, + 'Content-Type': 'video/mp4', + ETag: RANGE_SERVER_ETAG, + }); + res.end(payload.subarray(offset)); + return; + } + + res.writeHead(200, { + 'Content-Length': payload.length, + 'Content-Type': 'video/mp4', + ETag: RANGE_SERVER_ETAG, + }); + // First 16 KiB immediately, then a trickle: the transfer stays alive + // for tens of seconds unless it is paused or resumed via Range. + let sent = 16 * 1024; + res.write(payload.subarray(0, sent)); + const timer = setInterval(() => { + if (sent >= payload.length) { + clearInterval(timer); + res.end(); + return; + } + res.write(payload.subarray(sent, sent + 2 * 1024)); + sent += 2 * 1024; + }, 150); + res.on('close', () => clearInterval(timer)); + }); + + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + + return { + close: () => + new Promise((resolve) => server.close(() => resolve())), + payload, + requests, + url: `http://127.0.0.1:${port}/media/e2e-pause-movie.mp4`, + }; +} + /** * On a cold profile the renderer can query SQLite while the DB worker is * still creating tables ("database is locked" / "no such table" on slow CI @@ -163,4 +249,104 @@ test.describe('Electron Downloads', () => { await fileServer.close(); } }); + + test('@downloads @electron pauses a download, retains the partial, and resumes it with an HTTP Range request', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const rangeServer = await createThrottledRangeServer(); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Pause Portal', + username: 'user1', + password: 'pass1', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openDownloadsPage(app.mainWindow); + + const downloadsDir = join(dataDir, 'e2e-pause-downloads'); + mkdirSync(downloadsDir, { recursive: true }); + await app.electronApp.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = async () => + ({ + canceled: false, + filePaths: [folder], + }) as Awaited>; + }, downloadsDir); + await app.mainWindow + .getByRole('button', { name: 'Change Folder' }) + .click(); + await expect( + app.mainWindow.locator('.downloads__folder-inline-path') + ).toContainText('e2e-pause-downloads'); + + const startResult = await app.mainWindow.evaluate( + async ({ url, folder }) => + window.electron?.downloadsStart?.({ + playlistId: 'e2e-playlist', + xtreamId: 7373, + contentType: 'vod', + title: 'E2E Pause Movie', + url, + downloadFolder: folder, + }), + { url: rangeServer.url, folder: downloadsDir } + ); + expect(startResult?.error ?? null).toBeNull(); + + const item = app.mainWindow.locator('.downloads__item'); + await expect(item).toHaveCount(1, { timeout: 20000 }); + await expect(item.locator('.downloads__item-status')).toContainText( + 'Downloading', + { timeout: 20000 } + ); + + // Pause mid-transfer: the row flips to Paused and only a .part + // file exists on disk (final file absent, progress retained). + await item + .getByRole('button') + .filter({ hasText: 'pause' }) + .click(); + await expect(item.locator('.downloads__item-status')).toContainText( + 'Paused', + { timeout: 20000 } + ); + const pausedFiles = readdirSync(downloadsDir); + expect(pausedFiles).toEqual(['E2E Pause Movie.mp4.part']); + const pausedBytes = statSync( + join(downloadsDir, 'E2E Pause Movie.mp4.part') + ).size; + expect(pausedBytes).toBeGreaterThan(0); + expect(pausedBytes).toBeLessThan(rangeServer.payload.length); + + // Resume: the runtime must continue via Range/If-Range instead of + // restarting, and the assembled file must match the payload. + await item + .getByRole('button') + .filter({ hasText: 'play_arrow' }) + .click(); + await expect(item.locator('.downloads__item-status')).toContainText( + 'Completed', + { timeout: 30000 } + ); + + const resumeRequest = rangeServer.requests.find( + (entry) => entry.range + ); + expect(resumeRequest?.range).toMatch(/^bytes=\d+-$/); + expect(resumeRequest?.ifRange).toBe(RANGE_SERVER_ETAG); + + expect(readdirSync(downloadsDir)).toEqual(['E2E Pause Movie.mp4']); + const finalFile = readFileSync( + join(downloadsDir, 'E2E Pause Movie.mp4') + ); + expect(finalFile.equals(rangeServer.payload)).toBe(true); + } finally { + await closeElectronApp(app); + await rangeServer.close(); + } + }); }); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index f232d88d4..771a48359 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -842,6 +842,10 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DOWNLOADS_START', data), downloadsCancel: (downloadId: number) => ipcRenderer.invoke('DOWNLOADS_CANCEL', downloadId), + downloadsPause: (downloadId: number) => + ipcRenderer.invoke('DOWNLOADS_PAUSE', downloadId), + downloadsResume: (downloadId: number, downloadFolder: string) => + ipcRenderer.invoke('DOWNLOADS_RESUME', downloadId, downloadFolder), downloadsRetry: (downloadId: number, downloadFolder: string) => ipcRenderer.invoke('DOWNLOADS_RETRY', downloadId, downloadFolder), downloadsRemove: (downloadId: number) => diff --git a/apps/electron-backend/src/app/events/database/download-broadcast.ts b/apps/electron-backend/src/app/events/database/download-broadcast.ts new file mode 100644 index 000000000..fbd3f1666 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-broadcast.ts @@ -0,0 +1,13 @@ +import type { BrowserWindow } from 'electron'; + +let mainWindow: BrowserWindow | null = null; + +export function setMainWindow(win: BrowserWindow): void { + mainWindow = win; +} + +export function broadcastDownloadUpdate(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT'); + } +} diff --git a/apps/electron-backend/src/app/events/database/download-file-path.spec.ts b/apps/electron-backend/src/app/events/database/download-file-path.spec.ts index 56ef927b2..df7c58ebc 100644 --- a/apps/electron-backend/src/app/events/database/download-file-path.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-file-path.spec.ts @@ -1,6 +1,13 @@ +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { + findAvailableFinalPath, + getPartialDownloadPath, + getPartialDownloadSize, removePartialDownload, + removePartialDownloadFile, + reserveAvailablePartialDownloadFile, reserveAvailableDownloadFile, } from './download-file-path'; @@ -51,6 +58,45 @@ describe('reserveAvailableDownloadFile', () => { }); }); +describe('reserveAvailablePartialDownloadFile', () => { + it('reserves a .part path while keeping the final path free', () => { + const reserveFile = jest.fn(); + + expect( + reserveAvailablePartialDownloadFile( + '/downloads', + 'movie.mp4', + reserveFile, + () => false + ) + ).toEqual({ + filename: 'movie.mp4', + partialPath: join('/downloads', 'movie.mp4.part'), + path: join('/downloads', 'movie.mp4'), + }); + expect(reserveFile).toHaveBeenCalledWith( + join('/downloads', 'movie.mp4.part') + ); + }); + + it('skips candidates when the final file already exists', () => { + const reserveFile = jest.fn(); + + expect( + reserveAvailablePartialDownloadFile( + '/downloads', + 'movie.mp4', + reserveFile, + (filePath) => filePath.endsWith('movie.mp4') + ) + ).toEqual({ + filename: 'movie (1).mp4', + partialPath: join('/downloads', 'movie (1).mp4.part'), + path: join('/downloads', 'movie (1).mp4'), + }); + }); +}); + describe('removePartialDownload', () => { it('removes only the actual partial save path', () => { const requestedPath = join('/downloads', 'movie.mp4'); @@ -88,3 +134,73 @@ describe('removePartialDownload', () => { expect(removeFile).not.toHaveBeenCalled(); }); }); + +describe('partial download helpers', () => { + it('derives, removes, and sizes .part files from the final path', () => { + const finalPath = join('/downloads', 'movie.mp4'); + const partialPath = join('/downloads', 'movie.mp4.part'); + const removeFile = jest.fn(); + + expect(getPartialDownloadPath(finalPath)).toBe(partialPath); + expect( + removePartialDownloadFile( + finalPath, + (filePath) => filePath === partialPath, + removeFile + ) + ).toBe(true); + expect(removeFile).toHaveBeenCalledWith(partialPath); + expect( + getPartialDownloadSize(finalPath, (filePath) => { + expect(filePath).toBe(partialPath); + return { size: 128 }; + }) + ).toBe(128); + }); + + it('refuses to size a .part that is not a regular file', () => { + const directory = mkdtempSync(join(tmpdir(), 'iptvnator-part-')); + const finalPath = join(directory, 'movie.mp4'); + const partialPath = `${finalPath}.part`; + + try { + writeFileSync(join(directory, 'target.bin'), 'attacker'); + symlinkSync(join(directory, 'target.bin'), partialPath); + + expect(() => getPartialDownloadSize(finalPath)).toThrow( + 'not a regular file' + ); + } finally { + rmSync(directory, { force: true, recursive: true }); + } + }); + + it('finds the next numbered destination whose final and partial paths are free', () => { + const occupied = new Set([ + join('/downloads', 'movie.mp4'), + join('/downloads', 'movie (1).mp4'), + join('/downloads', 'movie (2).mp4.part'), + ]); + + expect( + findAvailableFinalPath(join('/downloads', 'movie.mp4'), (path) => + occupied.has(path) + ) + ).toEqual({ + filename: 'movie (3).mp4', + path: join('/downloads', 'movie (3).mp4'), + }); + }); + + it('reports zero for a missing .part with the default stat reader', () => { + const directory = mkdtempSync(join(tmpdir(), 'iptvnator-part-')); + + try { + expect( + getPartialDownloadSize(join(directory, 'missing.mp4')) + ).toBe(0); + } finally { + rmSync(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-file-path.ts b/apps/electron-backend/src/app/events/database/download-file-path.ts index 6bab7324f..0e106f0d5 100644 --- a/apps/electron-backend/src/app/events/database/download-file-path.ts +++ b/apps/electron-backend/src/app/events/database/download-file-path.ts @@ -1,16 +1,61 @@ -import { closeSync, existsSync, openSync, unlinkSync } from 'node:fs'; -import { extname, join } from 'node:path'; +import { + closeSync, + existsSync, + lstatSync, + openSync, + unlinkSync, +} from 'node:fs'; +import { basename, dirname, extname, join } from 'node:path'; export interface ReservedDownloadFile { filename: string; path: string; } +export interface ReservedPartialDownloadFile extends ReservedDownloadFile { + partialPath: string; +} + function createExclusiveFile(filePath: string): void { const descriptor = openSync(filePath, 'wx'); closeSync(descriptor); } +function createExistsError(filePath: string): NodeJS.ErrnoException { + const error = new Error(`File already exists: ${filePath}`) as NodeJS.ErrnoException; + error.code = 'EEXIST'; + return error; +} + +export function getPartialDownloadPath(filePath: string): string { + return `${filePath}.part`; +} + +/** + * Next numbered destination whose final and .part paths are both free. Used + * when a retained download's recorded destination got occupied while it was + * paused or failed — the occupying file is never inspected or deleted. + */ +export function findAvailableFinalPath( + filePath: string, + pathExists: (candidate: string) => boolean = existsSync +): { filename: string; path: string } { + const directory = dirname(filePath); + const extension = extname(filePath); + const stem = basename(filePath, extension); + + for (let suffix = 1; ; suffix++) { + const filename = `${stem} (${suffix})${extension}`; + const candidate = join(directory, filename); + if ( + !pathExists(candidate) && + !pathExists(getPartialDownloadPath(candidate)) + ) { + return { filename, path: candidate }; + } + } +} + export function reserveAvailableDownloadFile( directory: string, requestedFilename: string, @@ -38,6 +83,38 @@ export function reserveAvailableDownloadFile( } } +export function reserveAvailablePartialDownloadFile( + directory: string, + requestedFilename: string, + reserveFile: (filePath: string) => void = createExclusiveFile, + pathExists: (filePath: string) => boolean = existsSync +): ReservedPartialDownloadFile { + const extension = extname(requestedFilename); + const stem = extension + ? requestedFilename.slice(0, -extension.length) + : requestedFilename; + let candidate = requestedFilename; + let suffix = 1; + + for (;;) { + const filePath = join(directory, candidate); + const partialPath = getPartialDownloadPath(filePath); + try { + if (pathExists(filePath)) { + throw createExistsError(filePath); + } + reserveFile(partialPath); + return { filename: candidate, partialPath, path: filePath }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + candidate = `${stem} (${suffix})${extension}`; + suffix += 1; + } + } +} + export interface DownloadSavePathItem { getSavePath(): string; } @@ -55,3 +132,49 @@ export function removePartialDownload( removeFile(savePath); return true; } + +export function removePartialDownloadFile( + filePath: string | null | undefined, + pathExists: (filePath: string) => boolean = existsSync, + removeFile: (filePath: string) => void = unlinkSync +): boolean { + if (!filePath) { + return false; + } + + const partialPath = getPartialDownloadPath(filePath); + if (!pathExists(partialPath)) { + return false; + } + + removeFile(partialPath); + return true; +} + +function getRegularFileStats(filePath: string): { size: number } { + // lstat + isFile so appending to a retained .part can never follow a + // symlink planted in its place while the download was paused. + const stats = lstatSync(filePath); + if (!stats.isFile()) { + throw new Error(`Partial download is not a regular file: ${filePath}`); + } + return stats; +} + +export function getPartialDownloadSize( + filePath: string | null | undefined, + getStats: (filePath: string) => { size: number } = getRegularFileStats +): number { + if (!filePath) { + return 0; + } + + try { + return getStats(getPartialDownloadPath(filePath)).size; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + return 0; + } +} diff --git a/apps/electron-backend/src/app/events/database/download-finalize.spec.ts b/apps/electron-backend/src/app/events/database/download-finalize.spec.ts new file mode 100644 index 000000000..c0b261a2d --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-finalize.spec.ts @@ -0,0 +1,276 @@ +import { PassThrough, Readable } from 'node:stream'; +import { + createTask, + waitForCallCount, + waitForStatus, +} from './download.test-helpers'; + +describe('download finalization', () => { + it('falls back to copying the completed partial when hard links are unsupported', async () => { + jest.resetModules(); + + const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const db = { + update: jest.fn(() => ({ set })), + }; + const requestWithValidatedRedirects = jest.fn( + async () => + ({ + data: Readable.from([Buffer.from('full')]), + headers: { 'content-length': '4' }, + status: 200, + }) as never + ); + const link = jest.fn(async () => { + const error = new Error('not supported') as NodeJS.ErrnoException; + error.code = 'EXDEV'; + throw error; + }); + const copyFile = jest.fn(async () => undefined); + const unlink = jest.fn(async () => undefined); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects, + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream: jest.fn(() => new PassThrough()), + existsSync: jest.fn(() => false), + })); + jest.doMock('node:fs/promises', () => ({ + copyFile, + link, + stat: jest.fn(async () => ({ size: 4 })), + unlink, + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => 0), + removePartialDownloadFile: jest.fn(), + reserveAvailablePartialDownloadFile: jest.fn( + (directory: string, filename: string) => ({ + filename, + partialPath: `${directory}/${filename}.part`, + path: `${directory}/${filename}`, + }) + ), + })); + + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + runtime.enqueueDownload(createTask()); + await waitForCallCount(requestWithValidatedRedirects, 1); + await waitForStatus(set, 'completed'); + + expect(link).toHaveBeenCalledWith( + '/downloads/movie.mp4.part', + '/downloads/movie.mp4' + ); + expect(copyFile).toHaveBeenCalledWith( + '/downloads/movie.mp4.part', + '/downloads/movie.mp4', + expect.any(Number) + ); + expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part'); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 4, + filePath: '/downloads/movie.mp4', + status: 'completed', + }) + ); + }); + + it('completes when copied output exists but partial cleanup fails', async () => { + jest.resetModules(); + + const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const db = { + update: jest.fn(() => ({ set })), + }; + const requestWithValidatedRedirects = jest.fn( + async () => + ({ + data: Readable.from([Buffer.from('full')]), + headers: { 'content-length': '4' }, + status: 200, + }) as never + ); + const link = jest.fn(async () => { + const error = new Error('not supported') as NodeJS.ErrnoException; + error.code = 'EXDEV'; + throw error; + }); + const copyFile = jest.fn(async () => undefined); + const unlink = jest.fn(async () => { + const error = new Error('cleanup denied') as NodeJS.ErrnoException; + error.code = 'EACCES'; + throw error; + }); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects, + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream: jest.fn(() => new PassThrough()), + existsSync: jest.fn(() => false), + })); + jest.doMock('node:fs/promises', () => ({ + copyFile, + link, + stat: jest.fn(async () => ({ size: 4 })), + unlink, + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => 0), + removePartialDownloadFile: jest.fn(), + reserveAvailablePartialDownloadFile: jest.fn( + (directory: string, filename: string) => ({ + filename, + partialPath: `${directory}/${filename}.part`, + path: `${directory}/${filename}`, + }) + ), + })); + + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + runtime.enqueueDownload(createTask()); + await waitForCallCount(requestWithValidatedRedirects, 1); + await waitForStatus(set, 'completed'); + + expect(copyFile).toHaveBeenCalledWith( + '/downloads/movie.mp4.part', + '/downloads/movie.mp4', + expect.any(Number) + ); + expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part'); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 4, + errorMessage: null, + fileName: 'movie.mp4', + filePath: '/downloads/movie.mp4', + status: 'completed', + totalBytes: 4, + }) + ); + } finally { + consoleError.mockRestore(); + } + }); + + it('preserves a completed partial when finalization fails', async () => { + jest.resetModules(); + + const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const db = { + update: jest.fn(() => ({ set })), + }; + const requestWithValidatedRedirects = jest.fn( + async () => + ({ + data: Readable.from([Buffer.from('full')]), + headers: { 'content-length': '4' }, + status: 200, + }) as never + ); + const link = jest.fn(async () => { + const error = new Error('not supported') as NodeJS.ErrnoException; + error.code = 'EXDEV'; + throw error; + }); + const copyFile = jest.fn(async () => { + const error = new Error('disk full') as NodeJS.ErrnoException; + error.code = 'ENOSPC'; + throw error; + }); + const unlink = jest.fn(async () => undefined); + const removePartialDownloadFile = jest.fn(); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects, + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream: jest.fn(() => new PassThrough()), + existsSync: jest.fn(() => false), + })); + jest.doMock('node:fs/promises', () => ({ + copyFile, + link, + stat: jest.fn(async () => ({ size: 4 })), + unlink, + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => 0), + removePartialDownloadFile, + reserveAvailablePartialDownloadFile: jest.fn( + (directory: string, filename: string) => ({ + filename, + partialPath: `${directory}/${filename}.part`, + path: `${directory}/${filename}`, + }) + ), + })); + + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + runtime.enqueueDownload(createTask()); + await waitForCallCount(requestWithValidatedRedirects, 1); + await waitForStatus(set, 'failed'); + + expect(copyFile).toHaveBeenCalledWith( + '/downloads/movie.mp4.part', + '/downloads/movie.mp4', + expect.any(Number) + ); + expect(unlink).not.toHaveBeenCalled(); + expect(removePartialDownloadFile).not.toHaveBeenCalled(); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 4, + errorMessage: 'disk full', + fileName: 'movie.mp4', + filePath: '/downloads/movie.mp4', + status: 'failed', + totalBytes: 4, + }) + ); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-finalize.ts b/apps/electron-backend/src/app/events/database/download-finalize.ts new file mode 100644 index 000000000..955fe1ac4 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-finalize.ts @@ -0,0 +1,349 @@ +import { eq, sql } from 'drizzle-orm'; +import { constants, existsSync } from 'node:fs'; +import { copyFile, link, stat, unlink } from 'node:fs/promises'; +import * as schema from '../../database/schema'; +import { + getPartialDownloadPath, + getPartialDownloadSize, + removePartialDownloadFile, + type ReservedPartialDownloadFile, +} from './download-file-path'; +import type { + CompletedPartialProgress, + DownloadsDatabase, + DownloadTask, + TransferProgress, +} from './download-task'; +import { describeError, TruncatedTransferError } from './download-transfer'; + +/** + * Persistence for a failed startDownload() attempt, after its cancel/pause + * checkpoints have been ruled out. Chooses between: retaining a truncated + * transfer for a Range retry, committing an already-finalized file, + * retaining a completed partial, or the generic delete-partial failure. + */ +export async function handleDownloadFailure( + db: DownloadsDatabase, + task: DownloadTask, + reservation: ReservedPartialDownloadFile | undefined, + error: unknown +): Promise { + if (error instanceof TruncatedTransferError && reservation) { + // The short response is retained so a retry can continue the + // transfer via Range instead of starting over. + await persistCompletedPartialFailure( + db, + task, + { + bytesDownloaded: error.progress.bytesDownloaded, + filePath: reservation.path, + totalBytes: error.progress.totalBytes, + }, + error + ); + return; + } + + const existingCompletedFileProgress = + await getExistingCompletedFileProgress(task); + if (existingCompletedFileProgress) { + removePartialFile(existingCompletedFileProgress.filePath); + await persistCompletion( + db, + task, + task.fileName, + existingCompletedFileProgress.filePath, + existingCompletedFileProgress.bytesDownloaded, + existingCompletedFileProgress.totalBytes + ); + return; + } + + const completedPartialProgress = getCompletedPartialProgress(task); + if (completedPartialProgress) { + await persistCompletedPartialFailure( + db, + task, + completedPartialProgress, + error + ); + return; + } + + console.error( + `[Downloads] Error downloading ${task.fileName}:`, + describeError(error) + ); + removePartialFile(task.filePath); + await db + .update(schema.downloads) + .set({ + errorMessage: describeError(error), + filePath: null, + resumeValidator: null, + status: 'failed', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); +} + +export async function completeDownloadFromPartial( + db: DownloadsDatabase, + task: DownloadTask, + reservation: ReservedPartialDownloadFile, + progress: TransferProgress +): Promise { + let fileSize: number; + try { + fileSize = await finalizePartialDownload( + reservation, + progress.bytesDownloaded + ); + } catch (error) { + if (task.cancelRequested || task.pauseRequested) { + throw error; + } + await persistFinalizationFailure( + db, + task, + reservation, + progress, + error + ); + return; + } + + await persistCompletion( + db, + task, + reservation.filename, + reservation.path, + fileSize, + progress.totalBytes + ); +} + +export async function persistCompletion( + db: DownloadsDatabase, + task: DownloadTask, + fileName: string, + filePath: string, + fileSize: number, + totalBytes: number | null +): Promise { + console.log(`[Downloads] Completed: ${fileName}`); + await db + .update(schema.downloads) + .set({ + bytesDownloaded: fileSize, + errorMessage: null, + fileName, + filePath, + resumeValidator: null, + status: 'completed', + totalBytes: totalBytes ?? fileSize, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); +} + +async function persistFinalizationFailure( + db: DownloadsDatabase, + task: DownloadTask, + reservation: ReservedPartialDownloadFile, + progress: TransferProgress, + error: unknown +): Promise { + await persistRetainedPartialFailure( + db, + task, + reservation.filename, + reservation.path, + progress, + error, + `[Downloads] Error finalizing ${reservation.filename}:` + ); +} + +export async function persistCompletedPartialFailure( + db: DownloadsDatabase, + task: DownloadTask, + progress: CompletedPartialProgress, + error: unknown +): Promise { + await persistRetainedPartialFailure( + db, + task, + task.fileName, + progress.filePath, + progress, + error, + `[Downloads] Error downloading ${task.fileName}:` + ); +} + +async function persistRetainedPartialFailure( + db: DownloadsDatabase, + task: DownloadTask, + fileName: string, + filePath: string, + progress: TransferProgress, + error: unknown, + logMessage: string +): Promise { + console.error(logMessage, describeError(error)); + const totalBytes = progress.totalBytes ?? progress.bytesDownloaded; + task.totalBytes = totalBytes; + await db + .update(schema.downloads) + .set({ + bytesDownloaded: progress.bytesDownloaded, + errorMessage: describeError(error), + fileName, + filePath, + status: 'failed', + totalBytes, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); +} + +export async function getExistingCompletedFileProgress( + task: DownloadTask +): Promise { + if ( + !task.filePath || + task.totalBytes === null || + task.totalBytes === undefined + ) { + return null; + } + + try { + const fileStats = await stat(task.filePath); + if (fileStats.size !== task.totalBytes) { + return null; + } + return { + bytesDownloaded: fileStats.size, + filePath: task.filePath, + totalBytes: task.totalBytes, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[Downloads] Failed to inspect target file:', error); + } + return null; + } +} + +export function getCompletedPartialProgress( + task: DownloadTask +): CompletedPartialProgress | null { + if ( + !task.filePath || + task.totalBytes === null || + task.totalBytes === undefined + ) { + return null; + } + + if (!existsSync(getPartialDownloadPath(task.filePath))) { + return null; + } + + try { + const bytesDownloaded = getPartialDownloadSize(task.filePath); + if (bytesDownloaded !== task.totalBytes) { + return null; + } + return { + bytesDownloaded, + filePath: task.filePath, + totalBytes: task.totalBytes, + }; + } catch (error) { + console.error('[Downloads] Failed to inspect partial file:', error); + return null; + } +} + +export function getPausedByteCount(task: DownloadTask): number { + try { + return getPartialDownloadSize(task.filePath); + } catch (error) { + console.error('[Downloads] Failed to inspect partial file:', error); + return 0; + } +} + +async function finalizePartialDownload( + reservation: ReservedPartialDownloadFile, + expectedFileSize: number +): Promise { + try { + await link(reservation.partialPath, reservation.path); + } catch (error) { + if (!canCopyCompletedPartialAfterLinkFailure(error)) { + throw error; + } + await copyFile( + reservation.partialPath, + reservation.path, + constants.COPYFILE_EXCL + ); + } + try { + await unlink(reservation.partialPath); + } catch (error) { + const fileSize = await getExpectedFinalFileSize( + reservation.path, + expectedFileSize + ); + if (fileSize !== null) { + console.error( + '[Downloads] Failed to delete completed partial file:', + error + ); + return fileSize; + } + throw error; + } + const fileStats = await stat(reservation.path); + return fileStats.size; +} + +async function getExpectedFinalFileSize( + filePath: string, + expectedFileSize: number +): Promise { + try { + const fileStats = await stat(filePath); + return fileStats.size === expectedFileSize ? fileStats.size : null; + } catch { + return null; + } +} + +function canCopyCompletedPartialAfterLinkFailure(error: unknown): boolean { + const errorCode = (error as NodeJS.ErrnoException).code; + return ( + errorCode === 'EACCES' || + errorCode === 'ENOSYS' || + errorCode === 'ENOTSUP' || + errorCode === 'EOPNOTSUPP' || + errorCode === 'EPERM' || + errorCode === 'EXDEV' + ); +} + +/** @returns false when a .part exists but could not be deleted. */ +export function removePartialFile(filePath: string | null | undefined): boolean { + try { + removePartialDownloadFile(filePath); + return true; + } catch (error) { + console.error('[Downloads] Failed to delete partial file:', error); + return false; + } +} diff --git a/apps/electron-backend/src/app/events/database/download-recovery.spec.ts b/apps/electron-backend/src/app/events/database/download-recovery.spec.ts index 4456b0585..776717c79 100644 --- a/apps/electron-backend/src/app/events/database/download-recovery.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-recovery.spec.ts @@ -1,48 +1,397 @@ -import { cleanupStaleDownloadFiles } from './stale-download-files'; +type StatSyncStub = (path: string) => { isFile: () => boolean; size: number }; +let statSyncOverride: StatSyncStub | null = null; +jest.mock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + statSync: (path: string) => + statSyncOverride + ? statSyncOverride(path) + : jest.requireActual('node:fs').statSync(path), +})); -describe('cleanupStaleDownloadFiles', () => { - it('removes every persisted partial path and ignores empty paths', () => { - const removeFile = jest.fn(); +describe('resetStaleDownloads', () => { + it('keeps interrupted partial downloads as paused and pauses queued rows', async () => { + jest.resetModules(); - cleanupStaleDownloadFiles( - [ - { filePath: '/downloads/one.mp4' }, - { filePath: null }, - { filePath: '/downloads/two.mp4' }, - ], - removeFile + const staleDownloads = [ + { + filePath: '/downloads/movie.mp4', + id: 1, + status: 'downloading', + totalBytes: 100, + }, + { + filePath: null, + id: 2, + status: 'queued', + totalBytes: null, + }, + ]; + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(staleDownloads), + })), + })), + update: jest.fn(() => ({ set })), + }; + const removePartialDownloadFile = jest.fn(); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadSize: jest.fn(() => 64), + removePartialDownloadFile, + })); + + const { resetStaleDownloads } = await import('./download-recovery'); + + await resetStaleDownloads(); + + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 64, + status: 'paused', + }) ); - - expect(removeFile).toHaveBeenCalledTimes(2); - expect(removeFile).toHaveBeenNthCalledWith(1, '/downloads/one.mp4'); - expect(removeFile).toHaveBeenNthCalledWith(2, '/downloads/two.mp4'); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 0, + status: 'paused', + }) + ); + expect(set).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }) + ); + expect(removePartialDownloadFile).not.toHaveBeenCalled(); }); - it('continues cleaning other stale files after one removal fails', () => { - const removeFile = jest.fn((filePath: string) => { - if (filePath.endsWith('one.mp4')) { - throw new Error('locked'); - } + it('commits a finalized download whose completion was interrupted', async () => { + jest.resetModules(); + + // Crash happened after finalizePartialDownload() (final file on disk, + // .part gone) but before persistCompletion() flipped the row. + const staleDownloads = [ + { + filePath: '/downloads/movie.mp4', + id: 9, + status: 'downloading', + totalBytes: 100, + }, + ]; + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(staleDownloads), + })), + })), + update: jest.fn(() => ({ set })), + }; + const removePartialDownloadFile = jest.fn(); + + statSyncOverride = () => ({ isFile: () => true, size: 100 }); + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadSize: jest.fn(() => 0), + removePartialDownloadFile, + })); + + try { + const recovery = await import('./download-recovery'); + await recovery.resetStaleDownloads(); + } finally { + statSyncOverride = null; + } + + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 100, + errorMessage: null, + status: 'completed', + }) + ); + // Any leftover .part from the interrupted commit is cleaned up, and + // the final file's path is never cleared or deleted. + expect(removePartialDownloadFile).toHaveBeenCalledWith( + '/downloads/movie.mp4' + ); + expect(set).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }) + ); + }); + + it('keeps the retained partial of a resumed download that was still queued', async () => { + jest.resetModules(); + + const staleDownloads = [ + { + filePath: '/downloads/resumed.mp4', + id: 7, + status: 'queued', + totalBytes: 1000, + }, + ]; + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(staleDownloads), + })), + })), + update: jest.fn(() => ({ set })), + }; + const removePartialDownloadFile = jest.fn(); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadSize: jest.fn(() => 900), + removePartialDownloadFile, + })); + + const { resetStaleDownloads } = await import('./download-recovery'); + + await resetStaleDownloads(); + + expect(removePartialDownloadFile).not.toHaveBeenCalled(); + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 900, + errorMessage: null, + status: 'paused', + totalBytes: 1000, + }) + ); + }); + + it('removes non-recoverable interrupted partial files before clearing the persisted path', async () => { + jest.resetModules(); + + const staleDownloads = [ + { + filePath: '/downloads/empty.mp4', + id: 1, + status: 'downloading', + totalBytes: 100, + }, + ]; + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(staleDownloads), + })), + })), + update: jest.fn(() => ({ set })), + }; + const removePartialDownloadFile = jest.fn(); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadSize: jest.fn(() => 0), + removePartialDownloadFile, + })); + + const { resetStaleDownloads } = await import('./download-recovery'); + + await resetStaleDownloads(); + + expect(removePartialDownloadFile).toHaveBeenCalledWith( + '/downloads/empty.mp4' + ); + expect(removePartialDownloadFile.mock.invocationCallOrder[0]).toBeLessThan( + set.mock.invocationCallOrder[0] + ); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + errorMessage: 'Download interrupted by application restart', + filePath: null, + status: 'failed', + }) + ); + }); + + it('keeps the persisted path when non-recoverable partial cleanup fails', async () => { + jest.resetModules(); + + const staleDownloads = [ + { + filePath: '/downloads/locked.mp4', + id: 1, + status: 'downloading', + totalBytes: 100, + }, + ]; + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(staleDownloads), + })), + })), + update: jest.fn(() => ({ set })), + }; + const deleteError = new Error('permission denied'); + const removePartialDownloadFile = jest.fn(() => { + throw deleteError; }); const consoleError = jest .spyOn(console, 'error') .mockImplementation(() => undefined); - cleanupStaleDownloadFiles( - [ - { filePath: '/downloads/one.mp4' }, - { filePath: '/downloads/two.mp4' }, - ], - removeFile - ); + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadSize: jest.fn(() => 0), + removePartialDownloadFile, + })); - expect(removeFile).toHaveBeenCalledTimes(2); - expect(consoleError).toHaveBeenCalledWith( - '[Downloads] Failed to delete stale partial file:', - '/downloads/one.mp4', - expect.any(Error) - ); + const { resetStaleDownloads } = await import('./download-recovery'); - consoleError.mockRestore(); + try { + await resetStaleDownloads(); + + expect(removePartialDownloadFile).toHaveBeenCalledWith( + '/downloads/locked.mp4' + ); + const failedUpdate = (set.mock.calls as unknown[][]).find( + ([value]) => + (value as { status?: string } | undefined)?.status === + 'failed' + )?.[0] as Record | undefined; + expect(failedUpdate).toEqual( + expect.objectContaining({ + errorMessage: 'Download interrupted by application restart', + status: 'failed', + }) + ); + expect(failedUpdate).not.toHaveProperty('filePath'); + expect(consoleError).toHaveBeenCalledWith( + '[Downloads] Failed to delete interrupted partial file:', + '/downloads/locked.mp4', + deleteError + ); + } finally { + consoleError.mockRestore(); + } + }); + + it('removes leftover partial files for completed downloads without changing their status', async () => { + jest.resetModules(); + + const downloads = [ + { + filePath: '/downloads/movie.mp4', + id: 1, + status: 'completed', + totalBytes: 100, + }, + ]; + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(downloads), + })), + })), + update: jest.fn(() => ({ set })), + }; + const removePartialDownloadFile = jest.fn(); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadSize: jest.fn(), + removePartialDownloadFile, + })); + + const { resetStaleDownloads } = await import('./download-recovery'); + + await resetStaleDownloads(); + + expect(removePartialDownloadFile).toHaveBeenCalledWith( + '/downloads/movie.mp4' + ); + expect(set).not.toHaveBeenCalled(); + }); + + it('keeps completed downloads completed when leftover partial cleanup still fails', async () => { + jest.resetModules(); + + const downloads = [ + { + filePath: '/downloads/movie.mp4', + id: 1, + status: 'completed', + totalBytes: 100, + }, + ]; + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(downloads), + })), + })), + update: jest.fn(() => ({ set })), + }; + const cleanupError = new Error('locked'); + const removePartialDownloadFile = jest.fn(() => { + throw cleanupError; + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadSize: jest.fn(), + removePartialDownloadFile, + })); + + const { resetStaleDownloads } = await import('./download-recovery'); + + try { + await resetStaleDownloads(); + + expect(removePartialDownloadFile).toHaveBeenCalledWith( + '/downloads/movie.mp4' + ); + expect(set).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + '[Downloads] Failed to delete completed partial file:', + '/downloads/movie.mp4', + cleanupError + ); + } finally { + consoleError.mockRestore(); + } }); }); diff --git a/apps/electron-backend/src/app/events/database/download-recovery.ts b/apps/electron-backend/src/app/events/database/download-recovery.ts index 09f3fc8f3..227529070 100644 --- a/apps/electron-backend/src/app/events/database/download-recovery.ts +++ b/apps/electron-backend/src/app/events/database/download-recovery.ts @@ -1,43 +1,215 @@ import { inArray, sql } from 'drizzle-orm'; +import { statSync } from 'node:fs'; import { getDatabase } from '../../database/connection'; import * as schema from '../../database/schema'; -import { cleanupStaleDownloadFiles } from './stale-download-files'; +import { + getPartialDownloadSize, + removePartialDownloadFile, +} from './download-file-path'; + +interface StaleDownload { + filePath: string | null; + id: number; + status: string; + totalBytes: number | null; +} + +function getRecoverablePartialSize(download: StaleDownload): number { + if ( + (download.status !== 'downloading' && download.status !== 'queued') || + !download.filePath + ) { + return 0; + } + + try { + return getPartialDownloadSize(download.filePath); + } catch (error) { + console.error( + '[Downloads] Failed to inspect interrupted partial file:', + error + ); + return 0; + } +} + +/** + * A crash between finalizePartialDownload() and persistCompletion() leaves a + * 'downloading' row whose .part is gone but whose final file is fully on + * disk. Recognize that file (size must match the recorded total) so recovery + * commits the completion instead of orphaning the file and re-downloading. + */ +function getFinalizedFileSize(download: StaleDownload): number | null { + if ( + download.status !== 'downloading' || + !download.filePath || + download.totalBytes === null + ) { + return null; + } + + try { + const stats = statSync(download.filePath); + return stats.isFile() && stats.size === download.totalBytes + ? stats.size + : null; + } catch { + return null; + } +} + +function removeFailedPartial(download: StaleDownload): boolean { + if (!download.filePath) { + return true; + } + + try { + removePartialDownloadFile(download.filePath); + return true; + } catch (error) { + console.error( + '[Downloads] Failed to delete interrupted partial file:', + download.filePath, + error + ); + return false; + } +} + +function removeCompletedPartial(download: StaleDownload): void { + if (!download.filePath) { + return; + } + + try { + removePartialDownloadFile(download.filePath); + } catch (error) { + console.error( + '[Downloads] Failed to delete completed partial file:', + download.filePath, + error + ); + } +} export async function resetStaleDownloads(): Promise { try { const db = await getDatabase(); - const staleDownloads = await db + const downloads = await db .select({ filePath: schema.downloads.filePath, id: schema.downloads.id, + status: schema.downloads.status, + totalBytes: schema.downloads.totalBytes, }) .from(schema.downloads) - .where(inArray(schema.downloads.status, ['queued', 'downloading'])); - const ownedReservations = staleDownloads.filter( - (item) => item.filePath + .where( + inArray(schema.downloads.status, [ + 'queued', + 'downloading', + 'completed', + ]) + ); + const completedDownloads = downloads.filter( + (download) => download.status === 'completed' ); + const staleDownloads = downloads.filter( + (download) => download.status !== 'completed' + ); + const finalizedDownloads = staleDownloads + .map((download) => ({ + ...download, + finalizedSize: getFinalizedFileSize(download), + })) + .filter((download) => download.finalizedSize !== null); + const finalizedIds = new Set( + finalizedDownloads.map((download) => download.id) + ); + // Queued rows are recoverable even without partial bytes: a resumed + // download waiting behind an active one is persisted as 'queued' with + // its retained .part, and a never-started queued row loses nothing by + // becoming 'paused' instead of 'failed'. + const recoverableDownloads = staleDownloads + .filter((download) => !finalizedIds.has(download.id)) + .map((download) => ({ + ...download, + bytesDownloaded: getRecoverablePartialSize(download), + })) + .filter( + (download) => + download.status === 'queued' || download.bytesDownloaded > 0 + ); + const recoverableIds = new Set( + recoverableDownloads.map((download) => download.id) + ); + const failedDownloads = staleDownloads.filter( + (download) => + !recoverableIds.has(download.id) && + !finalizedIds.has(download.id) + ); + const cleanupResult = failedDownloads.map((download) => ({ + ...download, + partialRemoved: removeFailedPartial(download), + })); + const failedIdsWithRemovedPartials = cleanupResult + .filter((download) => download.partialRemoved) + .map((download) => download.id); + const failedIdsWithRetainedPartials = cleanupResult + .filter((download) => !download.partialRemoved) + .map((download) => download.id); - cleanupStaleDownloadFiles(ownedReservations); - await db - .update(schema.downloads) - .set({ - errorMessage: 'Download interrupted by application restart', - status: 'failed', - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(inArray(schema.downloads.status, ['queued', 'downloading'])); + completedDownloads.forEach(removeCompletedPartial); - if (ownedReservations.length > 0) { + for (const download of finalizedDownloads) { + // The interrupted commit may also have left the .part behind. + removeCompletedPartial(download); await db .update(schema.downloads) - .set({ filePath: null }) - .where( - inArray( - schema.downloads.id, - ownedReservations.map((item) => item.id) - ) - ); + .set({ + bytesDownloaded: download.finalizedSize, + errorMessage: null, + status: 'completed', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(inArray(schema.downloads.id, [download.id])); } + + for (const download of recoverableDownloads) { + await db + .update(schema.downloads) + .set({ + bytesDownloaded: download.bytesDownloaded, + errorMessage: null, + status: 'paused', + totalBytes: download.totalBytes, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(inArray(schema.downloads.id, [download.id])); + } + + if (failedIdsWithRemovedPartials.length > 0) { + await db + .update(schema.downloads) + .set({ + errorMessage: 'Download interrupted by application restart', + filePath: null, + status: 'failed', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(inArray(schema.downloads.id, failedIdsWithRemovedPartials)); + } + + if (failedIdsWithRetainedPartials.length > 0) { + await db + .update(schema.downloads) + .set({ + errorMessage: 'Download interrupted by application restart', + status: 'failed', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(inArray(schema.downloads.id, failedIdsWithRetainedPartials)); + } + console.log('[Downloads] Reset stale downloads'); } catch (error) { console.error('[Downloads] Error resetting stale downloads:', error); diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts new file mode 100644 index 000000000..68835167e --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -0,0 +1,351 @@ +import type { DownloadDirectoryAuthorizer } from './download-directory-authorization'; + +describe('download requests resume', () => { + it('enqueues a paused download with stored headers and original target path', async () => { + jest.resetModules(); + + const row = { + filePath: '/downloads/movie.mp4', + id: 42, + requestHeaders: JSON.stringify({ 'User-Agent': 'IPTVnator' }), + resumeValidator: '"etag-9"', + status: 'paused', + title: 'Movie', + totalBytes: 100, + url: 'https://example.test/movie.mp4', + }; + const limit = jest.fn().mockResolvedValue([row]); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })), + update: jest.fn(() => ({ + set: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })), + })), + }; + const enqueueDownload = jest.fn(); + const authorizer = { + requireAuthorized: jest.fn(async (directory: string) => directory), + } as unknown as DownloadDirectoryAuthorizer; + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../url-safety', () => ({ + assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('./download-runtime', () => ({ + enqueueDownload, + })); + + const { resumeDownloadRequest } = await import('./download-requests'); + + await expect( + resumeDownloadRequest(42, '/unused', authorizer) + ).resolves.toEqual({ success: true }); + + // DB-recorded retained paths stay usable after folder switches. + expect(authorizer.requireAuthorized).not.toHaveBeenCalled(); + expect(enqueueDownload).toHaveBeenCalledWith({ + directory: '/downloads', + fileName: 'movie.mp4', + filePath: '/downloads/movie.mp4', + headers: { 'User-Agent': 'IPTVnator' }, + id: 42, + resumeValidator: '"etag-9"', + totalBytes: 100, + url: 'https://example.test/movie.mp4', + }); + }); + + it('does not enqueue when a concurrent resume already claimed the row', async () => { + jest.resetModules(); + + const row = { + filePath: '/downloads/movie.mp4', + id: 42, + requestHeaders: null, + resumeValidator: null, + status: 'paused', + title: 'Movie', + totalBytes: 100, + url: 'https://example.test/movie.mp4', + }; + const limit = jest.fn().mockResolvedValue([row]); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })), + update: jest.fn(() => ({ + set: jest.fn(() => ({ + // The conditional status='paused' claim matched no rows. + where: jest.fn().mockResolvedValue({ changes: 0 }), + })), + })), + }; + const enqueueDownload = jest.fn(); + const authorizer = { + requireAuthorized: jest.fn(async (directory: string) => directory), + } as unknown as DownloadDirectoryAuthorizer; + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../url-safety', () => ({ + assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('./download-runtime', () => ({ + enqueueDownload, + })); + + const { resumeDownloadRequest } = await import('./download-requests'); + + await expect( + resumeDownloadRequest(42, '/unused', authorizer) + ).resolves.toEqual({ + error: 'Can only resume paused downloads', + success: false, + }); + expect(enqueueDownload).not.toHaveBeenCalled(); + }); + + it('retries a failed download with a retained partial at the original target path', async () => { + jest.resetModules(); + + const row = { + filePath: '/downloads/movie.mp4', + id: 42, + requestHeaders: JSON.stringify({ 'User-Agent': 'IPTVnator' }), + resumeValidator: '"etag-9"', + status: 'failed', + title: 'Movie', + totalBytes: 100, + url: 'https://example.test/movie.mp4', + }; + const limit = jest.fn().mockResolvedValue([row]); + const set = jest.fn< + { where: jest.Mock }, + [Record] + >(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })), + update: jest.fn(() => ({ set })), + }; + const enqueueDownload = jest.fn(); + const authorizer = { + requireAuthorized: jest.fn(async (directory: string) => directory), + } as unknown as DownloadDirectoryAuthorizer; + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../url-safety', () => ({ + assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('./download-runtime', () => ({ + enqueueDownload, + })); + + const { retryDownloadRequest } = await import('./download-requests'); + + await expect( + retryDownloadRequest(42, '/unused', authorizer) + ).resolves.toEqual({ success: true }); + + const update = set.mock.calls[0][0]; + expect(authorizer.requireAuthorized).not.toHaveBeenCalled(); + expect(update).toEqual( + expect.objectContaining({ + errorMessage: null, + fileName: 'movie.mp4', + status: 'queued', + }) + ); + expect(update).not.toHaveProperty('bytesDownloaded'); + expect(update).not.toHaveProperty('filePath'); + expect(update).not.toHaveProperty('totalBytes'); + expect(enqueueDownload).toHaveBeenCalledWith({ + directory: '/downloads', + fileName: 'movie.mp4', + filePath: '/downloads/movie.mp4', + headers: { 'User-Agent': 'IPTVnator' }, + id: 42, + resumeValidator: '"etag-9"', + totalBytes: 100, + url: 'https://example.test/movie.mp4', + }); + }); + + it('deletes the retained partial before re-downloading a failed row from scratch', async () => { + jest.resetModules(); + + const failedRow = { + contentType: 'vod', + filePath: '/downloads/movie.mp4', + id: 42, + playlistId: 'playlist-1', + status: 'failed', + title: 'Movie', + url: 'https://example.test/movie.mp4', + xtreamId: 7, + }; + const limit = jest + .fn() + .mockResolvedValueOnce([{ id: 'playlist-1' }]) + .mockResolvedValueOnce([failedRow]); + const set = jest.fn< + { where: jest.Mock }, + [Record] + >(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })), + update: jest.fn(() => ({ set })), + }; + const enqueueDownload = jest.fn(); + const removePartialDownloadFile = jest.fn(); + const authorizer = { + requireAuthorized: jest.fn(async (directory: string) => directory), + } as unknown as DownloadDirectoryAuthorizer; + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../url-safety', () => ({ + assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('./download-file-path', () => ({ + removePartialDownloadFile, + })); + jest.doMock('./download-runtime', () => ({ + enqueueDownload, + })); + + const { startDownloadRequest } = await import('./download-requests'); + + await expect( + startDownloadRequest( + { + contentType: 'vod', + downloadFolder: '/downloads', + playlistId: 'playlist-1', + title: 'Movie', + url: 'https://example.test/movie.mp4', + xtreamId: 7, + }, + authorizer + ) + ).resolves.toEqual({ id: 42, success: true }); + + expect(removePartialDownloadFile).toHaveBeenCalledWith( + '/downloads/movie.mp4' + ); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: null, + resumeValidator: null, + status: 'queued', + }) + ); + }); + + it('fails the re-download when the retained partial cannot be deleted', async () => { + jest.resetModules(); + + const failedRow = { + contentType: 'vod', + filePath: '/downloads/movie.mp4', + id: 42, + playlistId: 'playlist-1', + status: 'failed', + title: 'Movie', + url: 'https://example.test/movie.mp4', + xtreamId: 7, + }; + const limit = jest + .fn() + .mockResolvedValueOnce([{ id: 'playlist-1' }]) + .mockResolvedValueOnce([failedRow]); + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })), + update: jest.fn(() => ({ set })), + }; + const enqueueDownload = jest.fn(); + const removePartialDownloadFile = jest.fn(() => { + throw new Error('EPERM: locked'); + }); + const authorizer = { + requireAuthorized: jest.fn(async (directory: string) => directory), + } as unknown as DownloadDirectoryAuthorizer; + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../url-safety', () => ({ + assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('./download-file-path', () => ({ + removePartialDownloadFile, + })); + jest.doMock('./download-runtime', () => ({ + enqueueDownload, + })); + + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + const { startDownloadRequest } = await import( + './download-requests' + ); + + await expect( + startDownloadRequest( + { + contentType: 'vod', + downloadFolder: '/downloads', + playlistId: 'playlist-1', + title: 'Movie', + url: 'https://example.test/movie.mp4', + xtreamId: 7, + }, + authorizer + ) + ).resolves.toEqual({ + error: 'Could not delete the previous partial file', + id: 42, + success: false, + }); + } finally { + consoleError.mockRestore(); + } + + // The row keeps its filePath ownership and nothing is enqueued. + expect(set).not.toHaveBeenCalled(); + expect(enqueueDownload).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index deb643ac7..515402aa9 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -1,9 +1,10 @@ import { and, eq, sql } from 'drizzle-orm'; -import { extname } from 'node:path'; +import { basename, dirname, extname } from 'node:path'; import { getDatabase } from '../../database/connection'; import * as schema from '../../database/schema'; import { assertRemoteUrlAllowed } from '../url-safety'; import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; +import { removePartialDownloadFile } from './download-file-path'; import { enqueueDownload } from './download-runtime'; export interface StartDownloadRequest { @@ -25,18 +26,21 @@ export interface StartDownloadRequest { macAddress?: string; } +function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*]/g, '_').trim(); +} + function getExtensionFromUrl(url: string): string { try { - return extname(new URL(url).pathname) || '.mp4'; + // Sanitize too: URL pathnames may legally contain characters like ':' + // that would create NTFS alternate data streams on Windows. + const extension = sanitizeFilename(extname(new URL(url).pathname)); + return extension.startsWith('.') ? extension : '.mp4'; } catch { return '.mp4'; } } -function sanitizeFilename(name: string): string { - return name.replace(/[<>:"/\\|?*]/g, '_').trim(); -} - function createFileName(title: string, url: string): string { return sanitizeFilename(title) + getExtensionFromUrl(url); } @@ -44,13 +48,62 @@ function createFileName(title: string, url: string): string { function createHeaders( headers: StartDownloadRequest['headers'] ): Record | undefined { - return headers - ? { - 'User-Agent': headers.userAgent || '', - Origin: headers.origin || '', - Referer: headers.referer || '', - } - : undefined; + if (!headers) { + return undefined; + } + + const result: Record = {}; + if (headers.userAgent) { + result['User-Agent'] = headers.userAgent; + } + if (headers.origin) { + result.Origin = headers.origin; + } + if (headers.referer) { + result.Referer = headers.referer; + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function serializeHeaders( + headers: Record | undefined +): string | null { + return headers ? JSON.stringify(headers) : null; +} + +const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const; + +function parseStoredHeaders( + value: string | null +): Record | undefined { + if (!value) { + return undefined; + } + + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + + // Re-apply the write-time allowlist so a tampered or imported + // database row cannot smuggle arbitrary headers into requests. + const entries = parsed as Record; + const headers = STORED_HEADER_ALLOWLIST.reduce>( + (acc, key) => { + const headerValue = entries[key]; + if (typeof headerValue === 'string') { + acc[key] = headerValue; + } + return acc; + }, + {} + ); + return Object.keys(headers).length > 0 ? headers : undefined; + } catch { + return undefined; + } } export async function startDownloadRequest( @@ -98,6 +151,7 @@ export async function startDownloadRequest( ) .limit(1); const fileName = createFileName(data.title, data.url); + const headers = createHeaders(data.headers); if (existing.length > 0) { const item = existing[0]; @@ -109,6 +163,26 @@ export async function startDownloadRequest( }; } + if (item.status === 'failed' && item.filePath) { + // A failed row can still reference a retained .part; delete it + // before the restart clears filePath, or the file is orphaned. + // A locked .part must keep its database owner, so fail the + // restart instead of proceeding without the cleanup. + try { + removePartialDownloadFile(item.filePath); + } catch (error) { + console.error( + '[Downloads] Failed to delete retained partial before re-download:', + error + ); + return { + error: 'Could not delete the previous partial file', + id: item.id, + success: false, + }; + } + } + await db .update(schema.downloads) .set({ @@ -116,6 +190,8 @@ export async function startDownloadRequest( errorMessage: null, fileName, filePath: null, + requestHeaders: serializeHeaders(headers), + resumeValidator: null, status: 'queued', totalBytes: null, updatedAt: sql`CURRENT_TIMESTAMP`, @@ -125,7 +201,7 @@ export async function startDownloadRequest( enqueueDownload({ directory, fileName, - headers: createHeaders(data.headers), + headers, id: item.id, url: data.url, }); @@ -138,6 +214,7 @@ export async function startDownloadRequest( fileName, playlistId: data.playlistId, posterUrl: data.posterUrl, + requestHeaders: serializeHeaders(headers), seasonNumber: data.seasonNumber, seriesXtreamId: data.seriesXtreamId, status: 'queued', @@ -149,7 +226,7 @@ export async function startDownloadRequest( enqueueDownload({ directory, fileName, - headers: createHeaders(data.headers), + headers, id: insertedId, url: data.url, }); @@ -162,7 +239,6 @@ export async function retryDownloadRequest( authorizer: DownloadDirectoryAuthorizer ): Promise<{ success: boolean; error?: string }> { console.log('[Downloads] Retry download:', downloadId); - const directory = await authorizer.requireAuthorized(downloadFolder); const db = await getDatabase(); const existing = await db .select() @@ -183,24 +259,127 @@ export async function retryDownloadRequest( }; } - const fileName = createFileName(item.title, item.url); + const retainedFilePath = + item.status === 'failed' && item.filePath ? item.filePath : null; + // A retained filePath was written by the main process after its folder + // was authorized; requiring the folder to still be the CURRENT selection + // would strand the retry after the user switches download folders. + const directory = retainedFilePath + ? dirname(retainedFilePath) + : await authorizer.requireAuthorized(downloadFolder); + const fileName = retainedFilePath + ? basename(retainedFilePath) + : createFileName(item.title, item.url); + const queuedUpdate = retainedFilePath + ? { + errorMessage: null, + fileName, + status: 'queued' as const, + updatedAt: sql`CURRENT_TIMESTAMP`, + } + : { + bytesDownloaded: 0, + errorMessage: null, + fileName, + filePath: null, + resumeValidator: null, + status: 'queued' as const, + totalBytes: null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }; await db .update(schema.downloads) - .set({ - bytesDownloaded: 0, - errorMessage: null, - fileName, - filePath: null, - status: 'queued', - totalBytes: null, - updatedAt: sql`CURRENT_TIMESTAMP`, - }) + .set(queuedUpdate) .where(eq(schema.downloads.id, downloadId)); enqueueDownload({ directory, fileName, + filePath: retainedFilePath, + headers: parseStoredHeaders(item.requestHeaders), id: item.id, + resumeValidator: retainedFilePath ? item.resumeValidator : null, + totalBytes: retainedFilePath ? item.totalBytes : null, url: item.url, }); return { success: true }; } + +export async function resumeDownloadRequest( + downloadId: number, + downloadFolder: string, + authorizer: DownloadDirectoryAuthorizer +): Promise<{ success: boolean; error?: string }> { + console.log('[Downloads] Resume download:', downloadId); + const db = await getDatabase(); + const existing = await db + .select() + .from(schema.downloads) + .where(eq(schema.downloads.id, downloadId)) + .limit(1); + + if (existing.length === 0) { + return { error: 'Download not found', success: false }; + } + + const item = existing[0]; + await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true }); + if (item.status !== 'paused') { + return { + error: 'Can only resume paused downloads', + success: false, + }; + } + + // See retryDownloadRequest: DB-recorded retained paths stay usable after + // the user switches download folders. + const directory = item.filePath + ? dirname(item.filePath) + : await authorizer.requireAuthorized(downloadFolder); + const fileName = item.filePath + ? basename(item.filePath) + : createFileName(item.title, item.url); + + // Claim the row atomically: a concurrent resume for the same id loses + // this conditional update and must not enqueue a second task. + const claim = await db + .update(schema.downloads) + .set({ + errorMessage: null, + fileName, + status: 'queued', + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where( + and( + eq(schema.downloads.id, downloadId), + eq(schema.downloads.status, 'paused') + ) + ); + if (hasNoChanges(claim)) { + return { + error: 'Can only resume paused downloads', + success: false, + }; + } + + enqueueDownload({ + directory, + fileName, + filePath: item.filePath, + headers: parseStoredHeaders(item.requestHeaders), + id: item.id, + resumeValidator: item.resumeValidator, + totalBytes: item.totalBytes, + url: item.url, + }); + return { success: true }; +} + +function hasNoChanges(result: unknown): boolean { + return ( + typeof result === 'object' && + result !== null && + 'changes' in result && + (result as { changes: number }).changes === 0 + ); +} diff --git a/apps/electron-backend/src/app/events/database/download-reserve.spec.ts b/apps/electron-backend/src/app/events/database/download-reserve.spec.ts new file mode 100644 index 000000000..46941058f --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-reserve.spec.ts @@ -0,0 +1,151 @@ +import { PassThrough, Readable } from 'node:stream'; +import { createTask, waitForStatus } from './download.test-helpers'; + +describe('destination collision handling', () => { + it('redirects a retained partial to a numbered destination instead of unlinking', async () => { + jest.resetModules(); + + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { update: jest.fn(() => ({ set })) }; + const occupied = new Set([ + '/downloads/movie.mp4', + '/downloads/movie.mp4.part', + ]); + const renameMock = jest.fn(async () => undefined); + const unlinkMock = jest.fn(async () => undefined); + const requestWithValidatedRedirects = jest.fn( + async () => + ({ + data: Readable.from([Buffer.alloc(50, 'r')]), + headers: { 'content-range': 'bytes 50-99/100' }, + status: 206, + }) as never + ); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects, + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream: jest.fn(() => new PassThrough()), + existsSync: jest.fn((path: string) => occupied.has(path)), + })); + jest.doMock('node:fs/promises', () => ({ + copyFile: jest.fn(async () => undefined), + link: jest.fn(async () => undefined), + rename: renameMock, + stat: jest.fn(async () => ({ size: 100 })), + unlink: unlinkMock, + })); + jest.doMock('./download-file-path', () => ({ + findAvailableFinalPath: jest.fn(() => ({ + filename: 'movie (1).mp4', + path: '/downloads/movie (1).mp4', + })), + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => 50), + removePartialDownloadFile: jest.fn(), + reserveAvailablePartialDownloadFile: jest.fn(), + })); + + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + runtime.enqueueDownload({ + ...createTask(), + filePath: '/downloads/movie.mp4', + totalBytes: 100, + }); + await waitForStatus(set, 'completed'); + + // The foreign file at the recorded destination is never touched. + expect(unlinkMock).not.toHaveBeenCalledWith('/downloads/movie.mp4'); + expect(renameMock).toHaveBeenCalledWith( + '/downloads/movie.mp4.part', + '/downloads/movie (1).mp4.part' + ); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + fileName: 'movie (1).mp4', + filePath: '/downloads/movie (1).mp4', + }) + ); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/downloads/movie (1).mp4', + status: 'completed', + }) + ); + }); +}); + +describe('queue deduplication', () => { + it('ignores a second enqueue for an id that is already queued', async () => { + jest.resetModules(); + + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { update: jest.fn(() => ({ set })) }; + const removePartialDownloadFile = jest.fn(); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects: jest.fn( + () => new Promise(() => undefined) + ), + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream: jest.fn(() => new PassThrough()), + existsSync: jest.fn(() => false), + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => 0), + removePartialDownloadFile, + reserveAvailablePartialDownloadFile: jest.fn( + (directory: string, filename: string) => ({ + filename, + partialPath: `${directory}/${filename}.part`, + path: `${directory}/${filename}`, + }) + ), + })); + + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + // Occupy the active slot, then enqueue id 43 twice (double resume). + runtime.enqueueDownload(createTask()); + runtime.enqueueDownload({ ...createTask(), id: 43 }); + runtime.enqueueDownload({ ...createTask(), id: 43 }); + + // Only one queue entry exists: the first cancel finds it, the second + // finds nothing (and no paused DB row backs id 43 in this harness). + const dbSelect = jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ + limit: jest.fn().mockResolvedValue([]), + })), + })), + })); + (db as { select?: jest.Mock }).select = dbSelect; + + await expect(runtime.cancelDownload(43)).resolves.toBe(true); + await expect(runtime.cancelDownload(43)).resolves.toBe(false); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-resume.spec.ts b/apps/electron-backend/src/app/events/database/download-resume.spec.ts new file mode 100644 index 000000000..5688fa4cc --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-resume.spec.ts @@ -0,0 +1,337 @@ +import { PassThrough, Readable } from 'node:stream'; +import type { DownloadTask } from './download-task'; + +interface ResumeHarness { + createWriteStream: jest.Mock; + removePartialDownloadFile: jest.Mock; + requestWithValidatedRedirects: jest.Mock; + set: jest.Mock; + runtime: typeof import('./download-runtime'); +} + +interface ResumeHarnessOptions { + partialSize: number; + response: { + data: Readable; + headers: Record; + status: number; + }; + /** 'enoent' makes stat() report a missing target file. */ + finalSize: number | 'enoent'; +} + +function createTask(overrides: Partial = {}): DownloadTask { + return { + directory: '/downloads', + fileName: 'movie.mp4', + id: 42, + url: 'https://example.test/movie.mp4', + ...overrides, + }; +} + +async function waitForStatus(set: jest.Mock, status: string): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + if (set.mock.calls.some(([value]) => value?.status === status)) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + + expect(set).toHaveBeenCalledWith(expect.objectContaining({ status })); +} + +async function setupResumeHarness( + options: ResumeHarnessOptions +): Promise { + jest.resetModules(); + + const set = jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })); + const db = { update: jest.fn(() => ({ set })) }; + const requestWithValidatedRedirects = jest.fn( + async () => options.response as never + ); + const createWriteStream = jest.fn(() => new PassThrough()); + const removePartialDownloadFile = jest.fn(); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects, + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream, + existsSync: jest.fn(() => false), + })); + jest.doMock('node:fs/promises', () => ({ + copyFile: jest.fn(async () => undefined), + link: jest.fn(async () => undefined), + stat: jest.fn(async () => { + if (options.finalSize === 'enoent') { + const error = new Error('missing') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return { size: options.finalSize }; + }), + unlink: jest.fn(async () => undefined), + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => options.partialSize), + removePartialDownloadFile, + reserveAvailablePartialDownloadFile: jest.fn( + (directory: string, filename: string) => ({ + filename, + partialPath: `${directory}/${filename}.part`, + path: `${directory}/${filename}`, + }) + ), + })); + + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + return { + createWriteStream, + removePartialDownloadFile, + requestWithValidatedRedirects, + set, + runtime, + }; +} + +describe('download resume validation', () => { + it('sends the stored validator as If-Range alongside the Range header', async () => { + const harness = await setupResumeHarness({ + finalSize: 54, + partialSize: 50, + response: { + data: Readable.from([Buffer.from('rest')]), + headers: { 'content-range': 'bytes 50-53/54' }, + status: 206, + }, + }); + + harness.runtime.enqueueDownload( + createTask({ + filePath: '/downloads/movie.mp4', + resumeValidator: '"etag-1"', + totalBytes: 54, + }) + ); + await waitForStatus(harness.set, 'completed'); + + expect(harness.requestWithValidatedRedirects).toHaveBeenCalledWith( + 'https://example.test/movie.mp4', + expect.objectContaining({ + headers: expect.objectContaining({ + 'If-Range': '"etag-1"', + Range: 'bytes=50-', + }), + }), + { allowPrivateNetworks: true } + ); + expect(harness.createWriteStream).toHaveBeenCalledWith( + '/downloads/movie.mp4.part', + { flags: 'a' } + ); + }); + + it('restarts from byte zero when a resume request is answered with 200', async () => { + const harness = await setupResumeHarness({ + finalSize: 4, + partialSize: 50, + response: { + data: Readable.from([Buffer.from('full')]), + headers: { 'content-length': '4', etag: '"etag-2"' }, + status: 200, + }, + }); + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + try { + harness.runtime.enqueueDownload( + createTask({ + filePath: '/downloads/movie.mp4', + resumeValidator: '"etag-1"', + totalBytes: 54, + }) + ); + await waitForStatus(harness.set, 'completed'); + + expect(harness.createWriteStream).toHaveBeenCalledWith( + '/downloads/movie.mp4.part', + { flags: 'w' } + ); + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 0, + resumeValidator: '"etag-2"', + totalBytes: 4, + }) + ); + expect(harness.set).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }) + ); + expect(harness.removePartialDownloadFile).not.toHaveBeenCalled(); + } finally { + consoleWarn.mockRestore(); + } + }); + + it('fails the transfer when the 206 response starts at the wrong offset', async () => { + const body = new PassThrough(); + body.write('rest'); + const harness = await setupResumeHarness({ + finalSize: 'enoent', + partialSize: 50, + response: { + data: body, + headers: { 'content-range': 'bytes 0-53/54' }, + status: 206, + }, + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + harness.runtime.enqueueDownload( + createTask({ + filePath: '/downloads/movie.mp4', + totalBytes: 54, + }) + ); + await waitForStatus(harness.set, 'failed'); + + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ + errorMessage: 'Server returned an invalid resume range', + status: 'failed', + }) + ); + expect(body.destroyed).toBe(true); + } finally { + consoleError.mockRestore(); + } + }); + + it('retains the partial when a 206 ends before the advertised size', async () => { + const harness = await setupResumeHarness({ + finalSize: 'enoent', + partialSize: 50, + response: { + // Only 20 of the 50 remaining bytes arrive before EOF. + data: Readable.from([Buffer.alloc(20, 'r')]), + headers: { 'content-range': 'bytes 50-99/100' }, + status: 206, + }, + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + harness.runtime.enqueueDownload( + createTask({ + filePath: '/downloads/movie.mp4', + totalBytes: 100, + }) + ); + await waitForStatus(harness.set, 'failed'); + + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 70, + errorMessage: 'Transfer ended before the advertised size', + filePath: '/downloads/movie.mp4', + status: 'failed', + }) + ); + expect(harness.removePartialDownloadFile).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }); + + it('captures a strong ETag from the first response for later resumes', async () => { + const harness = await setupResumeHarness({ + finalSize: 4, + partialSize: 0, + response: { + data: Readable.from([Buffer.from('full')]), + headers: { 'content-length': '4', etag: '"etag-3"' }, + status: 200, + }, + }); + + harness.runtime.enqueueDownload(createTask()); + await waitForStatus(harness.set, 'completed'); + + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ resumeValidator: '"etag-3"' }) + ); + }); + + it('pauses before reservation without a network request or file path', async () => { + const harness = await setupResumeHarness({ + finalSize: 4, + partialSize: 0, + response: { + data: Readable.from([Buffer.from('full')]), + headers: { 'content-length': '4' }, + status: 200, + }, + }); + + harness.runtime.enqueueDownload({ + ...createTask(), + pauseRequested: true, + }); + await waitForStatus(harness.set, 'paused'); + + expect(harness.requestWithValidatedRedirects).not.toHaveBeenCalled(); + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 0, + filePath: null, + status: 'paused', + }) + ); + }); + + it('falls back to Last-Modified when the ETag is weak', async () => { + const harness = await setupResumeHarness({ + finalSize: 4, + partialSize: 0, + response: { + data: Readable.from([Buffer.from('full')]), + headers: { + 'content-length': '4', + etag: 'W/"weak-etag"', + 'last-modified': 'Wed, 01 Jul 2026 10:00:00 GMT', + }, + status: 200, + }, + }); + + harness.runtime.enqueueDownload(createTask()); + await waitForStatus(harness.set, 'completed'); + + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ + resumeValidator: 'Wed, 01 Jul 2026 10:00:00 GMT', + }) + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-retained-partial.spec.ts b/apps/electron-backend/src/app/events/database/download-retained-partial.spec.ts new file mode 100644 index 000000000..35679e7c9 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-retained-partial.spec.ts @@ -0,0 +1,154 @@ +import { PassThrough } from 'node:stream'; +import { createTask, waitForStatus } from './download.test-helpers'; + +describe('retained completed partials', () => { + it('finalizes a retained completed partial without another HTTP request', async () => { + jest.resetModules(); + + const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const db = { + update: jest.fn(() => ({ set })), + }; + const requestWithValidatedRedirects = jest.fn(); + const link = jest.fn(async () => undefined); + const unlink = jest.fn(async () => undefined); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects, + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream: jest.fn(() => new PassThrough()), + existsSync: jest.fn((filePath: string) => + filePath.endsWith('.part') + ), + })); + jest.doMock('node:fs/promises', () => ({ + copyFile: jest.fn(async () => undefined), + link, + stat: jest.fn(async () => ({ size: 4 })), + unlink, + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => 4), + removePartialDownloadFile: jest.fn(), + reserveAvailablePartialDownloadFile: jest.fn(), + })); + + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + runtime.enqueueDownload({ + ...createTask(), + filePath: '/downloads/movie.mp4', + totalBytes: 4, + }); + await waitForStatus(set, 'completed'); + + expect(requestWithValidatedRedirects).not.toHaveBeenCalled(); + expect(link).toHaveBeenCalledWith( + '/downloads/movie.mp4.part', + '/downloads/movie.mp4' + ); + expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part'); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 4, + filePath: '/downloads/movie.mp4', + status: 'completed', + totalBytes: 4, + }) + ); + }); + + it('finalizes a retained completed partial beside an occupied destination without touching it', async () => { + jest.resetModules(); + + const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const db = { + update: jest.fn(() => ({ set })), + }; + const requestWithValidatedRedirects = jest.fn(); + const link = jest.fn(async () => undefined); + const unlink = jest.fn(async () => undefined); + const occupied = new Set([ + '/downloads/movie.mp4', + '/downloads/movie.mp4.part', + ]); + const rename = jest.fn(async (from: string, to: string) => { + occupied.delete(from); + occupied.add(to); + }); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects, + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream: jest.fn(() => new PassThrough()), + existsSync: jest.fn((filePath: string) => occupied.has(filePath)), + })); + jest.doMock('node:fs/promises', () => ({ + copyFile: jest.fn(async () => undefined), + link, + rename, + stat: jest.fn(async () => ({ size: 4 })), + unlink, + })); + jest.doMock('./download-file-path', () => ({ + findAvailableFinalPath: jest.fn(() => ({ + filename: 'movie (1).mp4', + path: '/downloads/movie (1).mp4', + })), + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => 4), + removePartialDownloadFile: jest.fn(), + reserveAvailablePartialDownloadFile: jest.fn(), + })); + + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + runtime.enqueueDownload({ + ...createTask(), + filePath: '/downloads/movie.mp4', + totalBytes: 4, + }); + await waitForStatus(set, 'completed'); + + // The occupied destination is never unlinked or overwritten; the + // retained partial moves aside and finalizes under a numbered name. + expect(requestWithValidatedRedirects).not.toHaveBeenCalled(); + expect(unlink).not.toHaveBeenCalledWith('/downloads/movie.mp4'); + expect(rename).toHaveBeenCalledWith( + '/downloads/movie.mp4.part', + '/downloads/movie (1).mp4.part' + ); + expect(link).toHaveBeenCalledWith( + '/downloads/movie (1).mp4.part', + '/downloads/movie (1).mp4' + ); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 4, + fileName: 'movie (1).mp4', + filePath: '/downloads/movie (1).mp4', + status: 'completed', + totalBytes: 4, + }) + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-runtime.spec.ts b/apps/electron-backend/src/app/events/database/download-runtime.spec.ts index 64b7bfd2b..9856b8de9 100644 --- a/apps/electron-backend/src/app/events/database/download-runtime.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-runtime.spec.ts @@ -1,124 +1,91 @@ -import type { DownloadItem } from 'electron'; +import { PassThrough, Readable } from 'node:stream'; import { - attachDownloadItem, requestDownloadCancellation, - type DownloadTask, + requestDownloadPause, } from './download-task'; +import { + createTask, + waitForCallCount, + waitForStatus, + waitForStatusCount, +} from './download.test-helpers'; -function createTask(): DownloadTask { - return { - directory: '/downloads', - fileName: 'movie.mp4', - id: 42, - url: 'https://example.test/movie.mp4', - }; -} - -async function waitForCallCount( - mock: jest.Mock, - expectedCallCount: number -): Promise { - for (let attempt = 0; attempt < 20; attempt++) { - if (mock.mock.calls.length === expectedCallCount) { - return; - } - await new Promise((resolve) => setImmediate(resolve)); - } - - expect(mock).toHaveBeenCalledTimes(expectedCallCount); -} - -describe('download runtime cancellation', () => { - it('cancels the item when an earlier cancellation request reaches onStarted', () => { - const task = createTask(); - const cancel = jest.fn(); - - requestDownloadCancellation(task); - attachDownloadItem(task, { cancel } as unknown as DownloadItem); - - expect(task.cancelRequested).toBe(true); - expect(cancel).toHaveBeenCalledTimes(1); - }); - - it('cancels an already-started item immediately', () => { - const cancel = jest.fn(); +describe('download task interruption', () => { + it('aborts an active task when cancellation is requested', () => { + const abort = jest.fn(); const task = { ...createTask(), - downloadItem: { cancel } as unknown as DownloadItem, + abortController: { abort } as unknown as AbortController, }; requestDownloadCancellation(task); expect(task.cancelRequested).toBe(true); - expect(cancel).toHaveBeenCalledTimes(1); + expect(abort).toHaveBeenCalledTimes(1); }); - it('continues the queue when cancellation persistence fails', async () => { - jest.resetModules(); - - class TestCancelError extends Error {} - - let cancellationCallback: Promise | undefined; - const download = jest - .fn() - .mockImplementationOnce( - async ( - _window: unknown, - _url: string, - options: { - onCancel: (item: DownloadItem) => Promise; - } - ) => { - cancellationCallback = options.onCancel({ - getSavePath: () => '/downloads/movie.mp4', - } as unknown as DownloadItem); - throw new TestCancelError(); - } - ) - .mockImplementationOnce( - async ( - _window: unknown, - _url: string, - options: { - onCompleted: (file: { - fileSize: number; - filename: string; - path: string; - }) => Promise; - } - ) => { - await options.onCompleted({ - fileSize: 1, - filename: 'second.mp4', - path: '/downloads/second.mp4', - }); - } - ); - - const where = jest - .fn() - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('database is busy')) - .mockResolvedValue(undefined); - const db = { - update: jest.fn(() => ({ - set: jest.fn(() => ({ where })), - })), + it('aborts an active task when pause is requested', () => { + const abort = jest.fn(); + const task = { + ...createTask(), + abortController: { abort } as unknown as AbortController, }; - jest.doMock('electron-dl', () => ({ - CancelError: TestCancelError, - download, - })); + requestDownloadPause(task); + + expect(task.pauseRequested).toBe(true); + expect(abort).toHaveBeenCalledTimes(1); + }); +}); + +describe('download runtime pause and resume', () => { + it('persists active pause without deleting the partial file', async () => { + jest.resetModules(); + + const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const db = { + update: jest.fn(() => ({ set })), + }; + const removePartialDownloadFile = jest.fn(); + const stream = new PassThrough(); + const requestWithValidatedRedirects = jest.fn( + async ( + _url: string, + options: { signal?: AbortSignal } + ) => { + options.signal?.addEventListener('abort', () => { + stream.destroy(new Error('aborted')); + }); + return { + data: stream, + headers: { 'content-length': '100' }, + status: 200, + }; + } + ); + jest.doMock('../../database/connection', () => ({ getDatabase: jest.fn().mockResolvedValue(db), })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects, + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream: jest.fn(() => new PassThrough()), + existsSync: jest.fn(() => false), + })); jest.doMock('./download-file-path', () => ({ - removePartialDownload: jest.fn(), - reserveAvailableDownloadFile: jest.fn( + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest + .fn() + .mockReturnValueOnce(0) + .mockReturnValue(25), + removePartialDownloadFile, + reserveAvailablePartialDownloadFile: jest.fn( (directory: string, filename: string) => ({ filename, + partialPath: `${directory}/${filename}.part`, path: `${directory}/${filename}`, }) ), @@ -127,96 +94,134 @@ describe('download runtime cancellation', () => { const consoleError = jest .spyOn(console, 'error') .mockImplementation(() => undefined); + try { + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + runtime.enqueueDownload(createTask()); + await waitForCallCount(requestWithValidatedRedirects, 1); + + await expect(runtime.pauseDownload(42)).resolves.toBe(true); + await waitForStatus(set, 'paused'); + + expect(removePartialDownloadFile).not.toHaveBeenCalled(); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 25, + status: 'paused', + }) + ); + } finally { + consoleError.mockRestore(); + } + }); + + it('uses an HTTP Range header when a partial file already exists', async () => { + jest.resetModules(); + + const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const db = { + update: jest.fn(() => ({ set })), + }; + const requestWithValidatedRedirects = jest.fn( + async () => + ({ + data: Readable.from([Buffer.from('rest')]), + headers: { 'content-range': 'bytes 50-53/54' }, + status: 206, + }) as never + ); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects, + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream: jest.fn(() => new PassThrough()), + existsSync: jest.fn(() => false), + })); + jest.doMock('node:fs/promises', () => ({ + copyFile: jest.fn(async () => undefined), + link: jest.fn(async () => undefined), + stat: jest.fn(async () => ({ size: 54 })), + unlink: jest.fn(async () => undefined), + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => 50), + removePartialDownloadFile: jest.fn(), + reserveAvailablePartialDownloadFile: jest.fn(), + })); + const runtime = await import('./download-runtime'); runtime.setMainWindow({ isDestroyed: () => false, webContents: { send: jest.fn() }, } as never); - runtime.enqueueDownload(createTask()); runtime.enqueueDownload({ ...createTask(), - fileName: 'second.mp4', - id: 43, + filePath: '/downloads/movie.mp4', }); + await waitForCallCount(requestWithValidatedRedirects, 1); - await waitForCallCount(download, 2); - await expect(cancellationCallback).resolves.toBeUndefined(); - - consoleError.mockRestore(); + expect(requestWithValidatedRedirects).toHaveBeenCalledWith( + 'https://example.test/movie.mp4', + expect.objectContaining({ + headers: expect.objectContaining({ Range: 'bytes=50-' }), + }), + { allowPrivateNetworks: true } + ); + await waitForStatus(set, 'completed'); }); - it('continues the queue when completion persistence fails', async () => { + it('deletes a queued resumed partial file when the queued task is canceled', async () => { jest.resetModules(); - class TestCancelError extends Error {} - - let completionCallback: Promise | undefined; - const download = jest - .fn() - .mockImplementationOnce( - async ( - _window: unknown, - _url: string, - options: { - onCompleted: (file: { - fileSize: number; - filename: string; - path: string; - }) => Promise; - } - ) => { - completionCallback = options.onCompleted({ - fileSize: 1, - filename: 'movie.mp4', - path: '/downloads/movie.mp4', - }); - } - ) - .mockImplementationOnce( - async ( - _window: unknown, - _url: string, - options: { - onCompleted: (file: { - fileSize: number; - filename: string; - path: string; - }) => Promise; - } - ) => { - await options.onCompleted({ - fileSize: 1, - filename: 'second.mp4', - path: '/downloads/second.mp4', - }); - } - ); - - const where = jest - .fn() - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('database is busy')) - .mockResolvedValue(undefined); + const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); const db = { - update: jest.fn(() => ({ - set: jest.fn(() => ({ where })), - })), + update: jest.fn(() => ({ set })), }; + const removePartialDownloadFile = jest.fn(); + const activeStream = new PassThrough(); + const requestWithValidatedRedirects = jest.fn( + async (_url: string, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener('abort', () => { + activeStream.destroy(new Error('aborted')); + }); + return { + data: activeStream, + headers: { 'content-length': '100' }, + status: 200, + }; + } + ); - jest.doMock('electron-dl', () => ({ - CancelError: TestCancelError, - download, - })); jest.doMock('../../database/connection', () => ({ getDatabase: jest.fn().mockResolvedValue(db), })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects, + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream: jest.fn(() => new PassThrough()), + existsSync: jest.fn(() => false), + })); jest.doMock('./download-file-path', () => ({ - removePartialDownload: jest.fn(), - reserveAvailableDownloadFile: jest.fn( + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => 0), + removePartialDownloadFile, + reserveAvailablePartialDownloadFile: jest.fn( (directory: string, filename: string) => ({ filename, + partialPath: `${directory}/${filename}.part`, path: `${directory}/${filename}`, }) ), @@ -233,64 +238,66 @@ describe('download runtime cancellation', () => { } as never); runtime.enqueueDownload(createTask()); + await waitForCallCount(requestWithValidatedRedirects, 1); + runtime.enqueueDownload({ ...createTask(), - fileName: 'second.mp4', + fileName: 'resume.mp4', + filePath: '/downloads/resume.mp4', id: 43, }); - await waitForCallCount(download, 2); - await expect(completionCallback).resolves.toBeUndefined(); + await expect(runtime.cancelDownload(43)).resolves.toBe(true); + + expect(removePartialDownloadFile).toHaveBeenCalledWith( + '/downloads/resume.mp4' + ); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 0, + filePath: null, + status: 'canceled', + totalBytes: null, + }) + ); + + await runtime.cancelDownload(42); + await waitForStatusCount(set, 'canceled', 2); } finally { consoleError.mockRestore(); } }); - it('continues the queue when initial database access fails', async () => { + it('retains the partial path when canceling a queued task whose partial cannot be deleted', async () => { jest.resetModules(); - class TestCancelError extends Error {} + const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const db = { update: jest.fn(() => ({ set })) }; + const removePartialDownloadFile = jest.fn(() => { + throw new Error('EPERM: locked'); + }); - const download = jest.fn( - async ( - _window: unknown, - _url: string, - options: { - onCompleted: (file: { - fileSize: number; - filename: string; - path: string; - }) => Promise; - } - ) => { - await options.onCompleted({ - fileSize: 1, - filename: 'second.mp4', - path: '/downloads/second.mp4', - }); - } - ); - const where = jest.fn().mockResolvedValue(undefined); - const db = { - update: jest.fn(() => ({ - set: jest.fn(() => ({ where })), - })), - }; - const getDatabase = jest - .fn() - .mockRejectedValueOnce(new Error('database unavailable')) - .mockResolvedValue(db); - - jest.doMock('electron-dl', () => ({ - CancelError: TestCancelError, - download, + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../../util/validated-axios', () => ({ + requestWithValidatedRedirects: jest.fn( + () => new Promise(() => undefined) + ), + })); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + createWriteStream: jest.fn(() => new PassThrough()), + existsSync: jest.fn(() => false), })); - jest.doMock('../../database/connection', () => ({ getDatabase })); jest.doMock('./download-file-path', () => ({ - removePartialDownload: jest.fn(), - reserveAvailableDownloadFile: jest.fn( + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => 0), + removePartialDownloadFile, + reserveAvailablePartialDownloadFile: jest.fn( (directory: string, filename: string) => ({ filename, + partialPath: `${directory}/${filename}.part`, path: `${directory}/${filename}`, }) ), @@ -306,18 +313,81 @@ describe('download runtime cancellation', () => { webContents: { send: jest.fn() }, } as never); + // Occupy the active slot so the second task stays queued. runtime.enqueueDownload(createTask()); runtime.enqueueDownload({ ...createTask(), - fileName: 'second.mp4', + fileName: 'resume.mp4', + filePath: '/downloads/resume.mp4', id: 43, }); - await waitForCallCount(download, 1); - expect(download).toHaveBeenCalledWith( - expect.anything(), - 'https://example.test/movie.mp4', - expect.objectContaining({ filename: 'second.mp4' }) + await expect(runtime.cancelDownload(43)).resolves.toBe(true); + + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/downloads/resume.mp4', + status: 'canceled', + }) + ); + } finally { + consoleError.mockRestore(); + } + }); + + it('retains the partial path when canceling a paused row whose partial cannot be deleted', async () => { + jest.resetModules(); + + const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ + limit: jest.fn().mockResolvedValue([ + { + filePath: '/downloads/paused.mp4', + status: 'paused', + }, + ]), + })), + })), + })), + update: jest.fn(() => ({ set })), + }; + const removePartialDownloadFile = jest.fn(() => { + throw new Error('EPERM: locked'); + }); + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('./download-file-path', () => ({ + getPartialDownloadPath: (filePath: string) => `${filePath}.part`, + getPartialDownloadSize: jest.fn(() => 0), + removePartialDownloadFile, + reserveAvailablePartialDownloadFile: jest.fn(), + })); + + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + const runtime = await import('./download-runtime'); + runtime.setMainWindow({ + isDestroyed: () => false, + webContents: { send: jest.fn() }, + } as never); + + await expect(runtime.cancelDownload(77)).resolves.toBe(true); + + expect(removePartialDownloadFile).toHaveBeenCalledWith( + '/downloads/paused.mp4' + ); + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/downloads/paused.mp4', + status: 'canceled', + }) ); } finally { consoleError.mockRestore(); diff --git a/apps/electron-backend/src/app/events/database/download-runtime.ts b/apps/electron-backend/src/app/events/database/download-runtime.ts index c5837524c..4e670a310 100644 --- a/apps/electron-backend/src/app/events/database/download-runtime.ts +++ b/apps/electron-backend/src/app/events/database/download-runtime.ts @@ -1,41 +1,56 @@ import { eq, sql } from 'drizzle-orm'; -import type { BrowserWindow } from 'electron'; -import { CancelError, download } from 'electron-dl'; +import { existsSync } from 'node:fs'; +import { rename } from 'node:fs/promises'; import { getDatabase } from '../../database/connection'; import * as schema from '../../database/schema'; +import { broadcastDownloadUpdate } from './download-broadcast'; import { - removePartialDownload, - reserveAvailableDownloadFile, + findAvailableFinalPath, + getPartialDownloadPath, + reserveAvailablePartialDownloadFile, + type ReservedPartialDownloadFile, } from './download-file-path'; import { - attachDownloadItem, + completeDownloadFromPartial, + getCompletedPartialProgress, + getPausedByteCount, + handleDownloadFailure, + removePartialFile, +} from './download-finalize'; +import { requestDownloadCancellation, + requestDownloadPause, + type DownloadsDatabase, type DownloadTask, } from './download-task'; +import { describeError, transferToPartialFile } from './download-transfer'; + +export { + broadcastDownloadUpdate, + setMainWindow, +} from './download-broadcast'; const downloadQueue: DownloadTask[] = []; let activeDownload: DownloadTask | null = null; -let mainWindow: BrowserWindow | null = null; - -export function setMainWindow(win: BrowserWindow): void { - mainWindow = win; -} - -export function broadcastDownloadUpdate(): void { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT'); - } -} export function enqueueDownload(task: DownloadTask): void { + // A duplicate id (e.g. two rapid Resume clicks racing the status + // refresh) must not produce two transfers for the same row. + if ( + activeDownload?.id === task.id || + downloadQueue.some((queued) => queued.id === task.id) + ) { + return; + } + downloadQueue.push(task); broadcastDownloadUpdate(); void processQueue(); } -export async function cancelDownload(downloadId: number): Promise { +export async function pauseDownload(downloadId: number): Promise { if (activeDownload?.id === downloadId) { - requestDownloadCancellation(activeDownload); + requestDownloadPause(activeDownload); return true; } @@ -52,8 +67,7 @@ export async function cancelDownload(downloadId: number): Promise { .update(schema.downloads) .set({ errorMessage: null, - filePath: null, - status: 'canceled', + status: 'paused', updatedAt: sql`CURRENT_TIMESTAMP`, }) .where(eq(schema.downloads.id, downloadId)); @@ -61,6 +75,52 @@ export async function cancelDownload(downloadId: number): Promise { return true; } +export async function cancelDownload(downloadId: number): Promise { + if (activeDownload?.id === downloadId) { + requestDownloadCancellation(activeDownload); + return true; + } + + const queueIndex = downloadQueue.findIndex( + (task) => task.id === downloadId + ); + if (queueIndex !== -1) { + const [queuedTask] = downloadQueue.splice(queueIndex, 1); + const removed = removePartialFile(queuedTask?.filePath); + const db = await getDatabase(); + await persistQueuedCancellation( + db, + downloadId, + removed ? null : (queuedTask?.filePath ?? null) + ); + broadcastDownloadUpdate(); + return true; + } + + const db = await getDatabase(); + const rows = await db + .select({ + filePath: schema.downloads.filePath, + status: schema.downloads.status, + }) + .from(schema.downloads) + .where(eq(schema.downloads.id, downloadId)) + .limit(1); + const item = rows[0]; + if (item?.status !== 'paused') { + return false; + } + + const removed = removePartialFile(item.filePath); + await persistQueuedCancellation( + db, + downloadId, + removed ? null : item.filePath + ); + broadcastDownloadUpdate(); + return true; +} + export function removeDownloadFromRuntime(downloadId: number): void { if (activeDownload?.id === downloadId) { requestDownloadCancellation(activeDownload); @@ -90,7 +150,7 @@ async function processQueue(): Promise { } catch (error) { console.error( `[Downloads] Unhandled error for ${task.fileName}:`, - error + describeError(error) ); finishTask(task); } @@ -104,39 +164,25 @@ function finishTask(task: DownloadTask): void { void processQueue(); } -function createCancellationHandler( - task: DownloadTask, - db: Awaited>, - reservation: ReturnType -): (item: Parameters[0]) => Promise { - let cancellationPromise: Promise | undefined; - - return (item) => { - cancellationPromise ??= (async () => { - console.log(`[Downloads] Canceled: ${reservation.filename}`); - removePartialFile(item); - try { - await db - .update(schema.downloads) - .set({ - errorMessage: null, - filePath: null, - status: 'canceled', - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, task.id)); - } catch (error) { - console.error( - '[Downloads] Failed to persist cancellation:', - error - ); - } finally { - finishTask(task); - } - })(); - - return cancellationPromise; - }; +async function persistQueuedCancellation( + db: DownloadsDatabase, + downloadId: number, + // Keep the path when the retained .part could not be deleted, so a later + // remove/clear can retry the cleanup instead of orphaning the file. + retainedFilePath: string | null = null +): Promise { + await db + .update(schema.downloads) + .set({ + bytesDownloaded: 0, + errorMessage: null, + filePath: retainedFilePath, + resumeValidator: null, + status: 'canceled', + totalBytes: null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, downloadId)); } async function startDownload(task: DownloadTask): Promise { @@ -145,40 +191,26 @@ async function startDownload(task: DownloadTask): Promise { .update(schema.downloads) .set({ errorMessage: null, - filePath: null, status: 'downloading', updatedAt: sql`CURRENT_TIMESTAMP`, }) .where(eq(schema.downloads.id, task.id)); broadcastDownloadUpdate(); - if (!mainWindow || mainWindow.isDestroyed()) { - console.error('[Downloads] No main window available'); - await db - .update(schema.downloads) - .set({ - errorMessage: 'No window available for download', - status: 'failed', - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, task.id)); - finishTask(task); - return; - } - - let lastProgressUpdate = 0; - const progressThrottleMs = 500; - let handleCancellation: - | ReturnType - | undefined; - + let reservation: ReservedPartialDownloadFile | undefined; try { - const reservation = reserveAvailableDownloadFile( - task.directory, - task.fileName - ); - task.reservedPath = reservation.path; - handleCancellation = createCancellationHandler(task, db, reservation); + if (task.cancelRequested) { + await persistCancellation(db, task); + return; + } + if (task.pauseRequested) { + await persistPause(db, task); + return; + } + + reservation = await reserveTarget(task); + task.fileName = reservation.filename; + task.filePath = reservation.path; await db .update(schema.downloads) .set({ @@ -189,107 +221,131 @@ async function startDownload(task: DownloadTask): Promise { }) .where(eq(schema.downloads.id, task.id)); - const downloadOptions: Parameters[2] = { - directory: task.directory, - filename: reservation.filename, - onStarted: (item) => { - console.log(`[Downloads] Started: ${reservation.filename}`); - attachDownloadItem(task, item); - }, - onProgress: async (progress) => { - const now = Date.now(); - if (now - lastProgressUpdate < progressThrottleMs) { - return; - } - lastProgressUpdate = now; - await db - .update(schema.downloads) - .set({ - bytesDownloaded: progress.transferredBytes, - totalBytes: progress.totalBytes, - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, task.id)); - broadcastDownloadUpdate(); - }, - onCompleted: async (file) => { - console.log(`[Downloads] Completed: ${file.filename}`); - try { - await db - .update(schema.downloads) - .set({ - bytesDownloaded: file.fileSize, - errorMessage: null, - fileName: file.filename, - filePath: file.path, - status: 'completed', - totalBytes: file.fileSize, - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, task.id)); - } catch (error) { - console.error( - '[Downloads] Failed to persist completion:', - error - ); - } finally { - finishTask(task); - } - }, - onCancel: handleCancellation, - }; - - if (task.headers) { - ( - downloadOptions as typeof downloadOptions & { - headers: Record; - } - ).headers = task.headers; + if (task.cancelRequested) { + await persistCancellation(db, task); + return; } - - await download(mainWindow, task.url, downloadOptions); - } catch (error) { - if (error instanceof CancelError) { - const reservedPath = task.reservedPath; - if (handleCancellation) { - await handleCancellation( - task.downloadItem ?? - (reservedPath - ? { getSavePath: () => reservedPath } - : undefined) - ); - } else { - finishTask(task); - } + if (task.pauseRequested) { + await persistPause(db, task); return; } - console.error(`[Downloads] Error downloading ${task.fileName}:`, error); - const reservedPath = task.reservedPath; - removePartialFile( - task.downloadItem ?? - (reservedPath ? { getSavePath: () => reservedPath } : undefined) - ); - await db - .update(schema.downloads) - .set({ - errorMessage: - error instanceof Error ? error.message : String(error), - filePath: null, - status: 'failed', - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(schema.downloads.id, task.id)); + const completedPartialProgress = getCompletedPartialProgress(task); + if (completedPartialProgress) { + await completeDownloadFromPartial( + db, + task, + reservation, + completedPartialProgress + ); + return; + } + + const progress = await transferToPartialFile(db, task, reservation); + if (task.cancelRequested) { + await persistCancellation(db, task); + return; + } + if (task.pauseRequested) { + await persistPause(db, task); + return; + } + + await completeDownloadFromPartial(db, task, reservation, progress); + } catch (error) { + if (task.cancelRequested) { + await persistCancellation(db, task); + return; + } + if (task.pauseRequested) { + await persistPause(db, task); + return; + } + + await handleDownloadFailure(db, task, reservation, error); + } finally { + task.abortController = undefined; finishTask(task); } } -function removePartialFile( - item: Parameters[0] -): void { +async function reserveTarget( + task: DownloadTask +): Promise { + if (task.filePath) { + if (!existsSync(task.filePath)) { + return { + filename: task.fileName, + partialPath: getPartialDownloadPath(task.filePath), + path: task.filePath, + }; + } + + // Something now occupies the recorded destination — possibly a file + // the user created while this download was paused or failed. Never + // inspect or delete it: move the retained .part to the next free + // numbered destination and finalize there instead. + const redirected = findAvailableFinalPath(task.filePath); + const currentPartial = getPartialDownloadPath(task.filePath); + const redirectedPartial = getPartialDownloadPath(redirected.path); + if (existsSync(currentPartial)) { + await rename(currentPartial, redirectedPartial); + } + + return { + filename: redirected.filename, + partialPath: redirectedPartial, + path: redirected.path, + }; + } + + return reserveAvailablePartialDownloadFile(task.directory, task.fileName); +} + +async function persistCancellation( + db: DownloadsDatabase, + task: DownloadTask +): Promise { + console.log(`[Downloads] Canceled: ${task.fileName}`); + const removed = removePartialFile(task.filePath); try { - removePartialDownload(item); + await db + .update(schema.downloads) + .set({ + bytesDownloaded: 0, + errorMessage: null, + filePath: removed ? null : (task.filePath ?? null), + resumeValidator: null, + status: 'canceled', + totalBytes: null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); } catch (error) { - console.error('[Downloads] Failed to delete partial file:', error); + console.error('[Downloads] Failed to persist cancellation:', error); + } +} + +async function persistPause( + db: DownloadsDatabase, + task: DownloadTask +): Promise { + console.log(`[Downloads] Paused: ${task.fileName}`); + const bytesDownloaded = getPausedByteCount(task); + try { + await db + .update(schema.downloads) + .set({ + bytesDownloaded, + errorMessage: null, + fileName: task.fileName, + filePath: task.filePath ?? null, + status: 'paused', + totalBytes: task.totalBytes ?? null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + } catch (error) { + console.error('[Downloads] Failed to persist pause:', error); } } diff --git a/apps/electron-backend/src/app/events/database/download-task.ts b/apps/electron-backend/src/app/events/database/download-task.ts index 93c0f890e..ec6bdea98 100644 --- a/apps/electron-backend/src/app/events/database/download-task.ts +++ b/apps/electron-backend/src/app/events/database/download-task.ts @@ -1,4 +1,15 @@ -import type { DownloadItem } from 'electron'; +import type { getDatabase } from '../../database/connection'; + +export type DownloadsDatabase = Awaited>; + +export interface TransferProgress { + bytesDownloaded: number; + totalBytes: number | null; +} + +export interface CompletedPartialProgress extends TransferProgress { + filePath: string; +} export interface DownloadTask { id: number; @@ -7,21 +18,20 @@ export interface DownloadTask { directory: string; headers?: Record; cancelRequested?: boolean; - downloadItem?: DownloadItem; - reservedPath?: string; -} - -export function attachDownloadItem( - task: DownloadTask, - item: DownloadItem -): void { - task.downloadItem = item; - if (task.cancelRequested) { - item.cancel(); - } + pauseRequested?: boolean; + abortController?: AbortController; + filePath?: string | null; + totalBytes?: number | null; + /** ETag/Last-Modified of the entity the partial belongs to (If-Range). */ + resumeValidator?: string | null; } export function requestDownloadCancellation(task: DownloadTask): void { task.cancelRequested = true; - task.downloadItem?.cancel(); + task.abortController?.abort(); +} + +export function requestDownloadPause(task: DownloadTask): void { + task.pauseRequested = true; + task.abortController?.abort(); } diff --git a/apps/electron-backend/src/app/events/database/download-transfer.ts b/apps/electron-backend/src/app/events/database/download-transfer.ts new file mode 100644 index 000000000..cb8df4a81 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-transfer.ts @@ -0,0 +1,262 @@ +import { eq, sql } from 'drizzle-orm'; +import { createWriteStream } from 'node:fs'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import * as schema from '../../database/schema'; +import { requestWithValidatedRedirects } from '../../util/validated-axios'; +import { broadcastDownloadUpdate } from './download-broadcast'; +import { + getPartialDownloadSize, + type ReservedPartialDownloadFile, +} from './download-file-path'; +import type { + DownloadsDatabase, + DownloadTask, + TransferProgress, +} from './download-task'; + +/** + * Log transfer failures by message only: a raw AxiosError dumps its request + * config, and download URLs can embed portal credentials. + */ +export function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * The response stream ended cleanly before the advertised representation + * size was reached (e.g. a proxy that caps each response). The partial is + * valid — the caller must retain it so a retry can continue via Range. + */ +export class TruncatedTransferError extends Error { + constructor(readonly progress: TransferProgress) { + super('Transfer ended before the advertised size'); + } +} + +export async function transferToPartialFile( + db: DownloadsDatabase, + task: DownloadTask, + reservation: ReservedPartialDownloadFile +): Promise { + const resumeOffset = getResumeOffset(task, reservation); + + const headers = { + ...(task.headers ?? {}), + }; + if (resumeOffset > 0) { + headers.Range = `bytes=${resumeOffset}-`; + if (task.resumeValidator) { + headers['If-Range'] = task.resumeValidator; + } + } + + const abortController = new AbortController(); + task.abortController = abortController; + if (task.cancelRequested || task.pauseRequested) { + abortController.abort(); + } + + console.log(`[Downloads] Started: ${reservation.filename}`); + const response = await requestWithValidatedRedirects( + task.url, + { + headers, + method: 'GET', + responseType: 'stream', + signal: abortController.signal, + validateStatus: (status) => status >= 200 && status < 300, + }, + { allowPrivateNetworks: true } + ); + + const readable = response.data; + let effectiveOffset = resumeOffset; + try { + effectiveOffset = validateResumeResponse( + reservation, + response.status, + response.headers, + resumeOffset + ); + } catch (error) { + // Abandon the unconsumed response body; swallow its error events so + // destroying a stream nobody is piping cannot crash the process. + readable.on('error', () => undefined); + readable.destroy(); + throw error; + } + + const totalBytes = getTotalBytes(response.headers, effectiveOffset); + task.totalBytes = totalBytes; + if (effectiveOffset === 0) { + task.resumeValidator = getResponseValidator(response.headers); + } + await persistTransferStart(db, task, effectiveOffset, totalBytes); + + let bytesDownloaded = effectiveOffset; + let lastProgressUpdate = 0; + const progressThrottleMs = 500; + const output = createWriteStream(reservation.partialPath, { + flags: effectiveOffset > 0 ? 'a' : 'w', + }); + const abortStream = () => { + readable.destroy(new Error('Download aborted')); + }; + + if (abortController.signal.aborted) { + abortStream(); + } else { + abortController.signal.addEventListener('abort', abortStream, { + once: true, + }); + } + readable.on('data', (chunk: Buffer | string) => { + bytesDownloaded += Buffer.isBuffer(chunk) + ? chunk.length + : Buffer.byteLength(chunk); + const now = Date.now(); + if (now - lastProgressUpdate < progressThrottleMs) { + return; + } + lastProgressUpdate = now; + void persistProgress(db, task, { + bytesDownloaded, + totalBytes, + }).catch((error) => { + console.error('[Downloads] Failed to persist progress:', error); + }); + }); + + try { + await pipeline(readable, output); + } finally { + abortController.signal.removeEventListener('abort', abortStream); + } + + await persistProgress(db, task, { bytesDownloaded, totalBytes }); + if (totalBytes !== null && bytesDownloaded < totalBytes) { + throw new TruncatedTransferError({ bytesDownloaded, totalBytes }); + } + return { bytesDownloaded, totalBytes }; +} + +function getResumeOffset( + task: DownloadTask, + reservation: ReservedPartialDownloadFile +): number { + const resumeOffset = getPartialDownloadSize(reservation.path); + if ( + task.totalBytes !== null && + task.totalBytes !== undefined && + resumeOffset > task.totalBytes + ) { + throw new Error('Partial download is larger than expected'); + } + return resumeOffset; +} + +function validateResumeResponse( + reservation: ReservedPartialDownloadFile, + status: number, + headers: unknown, + resumeOffset: number +): number { + if (resumeOffset === 0) { + return 0; + } + + if (status !== 206) { + // Either the server ignored Range or If-Range detected that the + // remote entity changed. The retained partial is unusable either way, + // so restart from byte zero instead of failing the download. + console.warn( + `[Downloads] Restarting ${reservation.filename} from the beginning (resume request answered with HTTP ${status})` + ); + return 0; + } + + const contentRange = getHeaderValue( + headers as Record, + 'content-range' + ); + const start = contentRange?.match(/^bytes\s+(\d+)-/i)?.[1]; + if (start === undefined || Number(start) !== resumeOffset) { + throw new Error('Server returned an invalid resume range'); + } + return resumeOffset; +} + +function getResponseValidator(headers: unknown): string | null { + const headerMap = headers as Record; + const etag = getHeaderValue(headerMap, 'etag'); + // If-Range only accepts strong validators, so skip weak W/ ETags. + if (etag && !etag.startsWith('W/')) { + return etag; + } + return getHeaderValue(headerMap, 'last-modified') ?? null; +} + +async function persistTransferStart( + db: DownloadsDatabase, + task: DownloadTask, + bytesDownloaded: number, + totalBytes: number | null +): Promise { + await db + .update(schema.downloads) + .set({ + bytesDownloaded, + resumeValidator: task.resumeValidator ?? null, + totalBytes, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + broadcastDownloadUpdate(); +} + +async function persistProgress( + db: DownloadsDatabase, + task: DownloadTask, + progress: TransferProgress +): Promise { + await db + .update(schema.downloads) + .set({ + bytesDownloaded: progress.bytesDownloaded, + totalBytes: progress.totalBytes, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, task.id)); + broadcastDownloadUpdate(); +} + +function getTotalBytes(headers: unknown, resumeOffset: number): number | null { + const headerMap = headers as Record; + const contentRange = getHeaderValue(headerMap, 'content-range'); + if (contentRange) { + const match = contentRange.match(/\/(\d+)$/); + if (match) { + return Number(match[1]); + } + } + + const contentLength = getHeaderValue(headerMap, 'content-length'); + if (!contentLength) { + return null; + } + + const parsed = Number(contentLength); + return Number.isFinite(parsed) ? resumeOffset + parsed : null; +} + +function getHeaderValue( + headers: Record, + name: string +): string | undefined { + const value = headers[name] ?? headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value.length > 0 ? String(value[0]) : undefined; + } + return value === undefined ? undefined : String(value); +} diff --git a/apps/electron-backend/src/app/events/database/download.test-helpers.ts b/apps/electron-backend/src/app/events/database/download.test-helpers.ts new file mode 100644 index 000000000..7fe5e4442 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download.test-helpers.ts @@ -0,0 +1,58 @@ +import type { DownloadTask } from './download-task'; + +export function createTask(): DownloadTask { + return { + directory: '/downloads', + fileName: 'movie.mp4', + id: 42, + url: 'https://example.test/movie.mp4', + }; +} + +export async function waitForCallCount( + mock: jest.Mock, + expectedCallCount: number +): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + if (mock.mock.calls.length === expectedCallCount) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + + expect(mock).toHaveBeenCalledTimes(expectedCallCount); +} + +export async function waitForStatus( + set: jest.Mock, + status: string +): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + if (set.mock.calls.some(([value]) => value?.status === status)) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + + expect(set).toHaveBeenCalledWith(expect.objectContaining({ status })); +} + +export async function waitForStatusCount( + set: jest.Mock, + status: string, + expectedCount: number +): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + const statusCount = set.mock.calls.filter( + ([value]) => value?.status === status + ).length; + if (statusCount >= expectedCount) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + + expect( + set.mock.calls.filter(([value]) => value?.status === status) + ).toHaveLength(expectedCount); +} diff --git a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts new file mode 100644 index 000000000..ccced7e98 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts @@ -0,0 +1,331 @@ +type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise; + +const mockRegisteredHandlers = new Map(); +const mockGetDatabase = jest.fn(); +const mockRemoveDownloadFromRuntime = jest.fn(); +const mockBroadcastDownloadUpdate = jest.fn(); +const mockRemovePartialDownloadFile = jest.fn(); +const mockPauseDownload = jest.fn(); +const mockResumeDownloadRequest = jest.fn(); + +function getHandler(channel: string): IpcHandler { + const handler = mockRegisteredHandlers.get(channel); + if (!handler) { + throw new Error(`Expected IPC handler for ${channel}`); + } + + return handler; +} + +function createDownloadRow(status: string) { + return { + filePath: '/downloads/resume.mp4', + status, + }; +} + +describe('downloads events', () => { + beforeEach(async () => { + jest.resetModules(); + mockRegisteredHandlers.clear(); + mockGetDatabase.mockReset(); + mockRemoveDownloadFromRuntime.mockReset(); + mockBroadcastDownloadUpdate.mockReset(); + mockRemovePartialDownloadFile.mockReset(); + mockPauseDownload.mockReset(); + mockResumeDownloadRequest.mockReset(); + + jest.doMock('electron', () => ({ + app: { + getPath: jest.fn((name: string) => + name === 'userData' ? '/user-data' : '/downloads' + ), + }, + dialog: { + showOpenDialog: jest.fn(), + }, + ipcMain: { + handle: jest.fn((channel: string, handler: IpcHandler) => { + mockRegisteredHandlers.set(channel, handler); + }), + }, + shell: { + openPath: jest.fn(), + showItemInFolder: jest.fn(), + }, + })); + jest.doMock('../../database/connection', () => ({ + getDatabase: mockGetDatabase, + })); + jest.doMock('./download-file-path', () => ({ + removePartialDownloadFile: mockRemovePartialDownloadFile, + })); + jest.doMock('./download-runtime', () => ({ + broadcastDownloadUpdate: mockBroadcastDownloadUpdate, + cancelDownload: jest.fn(), + pauseDownload: mockPauseDownload, + removeDownloadFromRuntime: mockRemoveDownloadFromRuntime, + setMainWindow: jest.fn(), + })); + jest.doMock('./download-requests', () => ({ + resumeDownloadRequest: mockResumeDownloadRequest, + retryDownloadRequest: jest.fn(), + startDownloadRequest: jest.fn(), + })); + jest.doMock('./download-recovery', () => ({ + resetStaleDownloads: jest.fn(), + })); + + await import('./downloads.events'); + }); + + function mockDownloadRow(row: { filePath: string | null; status: string }) { + const deleteWhere = jest.fn().mockResolvedValue(undefined); + const db = { + delete: jest.fn(() => ({ where: deleteWhere })), + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ + limit: jest.fn().mockResolvedValue([row]), + })), + })), + })), + }; + mockGetDatabase.mockResolvedValue(db); + return { db, deleteWhere }; + } + + function mockTerminalRows( + rows: Array<{ filePath: string | null; status: string }> + ) { + const deleteWhere = jest.fn().mockResolvedValue(undefined); + const selectWhere = jest + .fn() + .mockResolvedValue(rows.map((row, index) => ({ id: index + 1, ...row }))); + const db = { + delete: jest.fn(() => ({ where: deleteWhere })), + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: selectWhere, + })), + })), + }; + mockGetDatabase.mockResolvedValue(db); + return { db, deleteWhere, selectWhere }; + } + + it('removes queued resumed partial files before deleting the row', async () => { + const { deleteWhere } = mockDownloadRow(createDownloadRow('queued')); + + await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({ + success: true, + }); + + expect(mockRemoveDownloadFromRuntime).toHaveBeenCalledWith(42); + expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith( + '/downloads/resume.mp4' + ); + expect( + mockRemovePartialDownloadFile.mock.invocationCallOrder[0] + ).toBeLessThan(mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0]); + expect( + mockRemovePartialDownloadFile.mock.invocationCallOrder[0] + ).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]); + expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1); + }); + + it('removes completed partial files before deleting the row', async () => { + const { deleteWhere } = mockDownloadRow(createDownloadRow('completed')); + + await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({ + success: true, + }); + + expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith( + '/downloads/resume.mp4' + ); + expect( + mockRemovePartialDownloadFile.mock.invocationCallOrder[0] + ).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]); + }); + + it('keeps the queued runtime entry and row when partial cleanup fails', async () => { + const cleanupError = new Error('permission denied'); + const { deleteWhere } = mockDownloadRow(createDownloadRow('queued')); + mockRemovePartialDownloadFile.mockImplementation(() => { + throw cleanupError; + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + + try { + // The row and its .part must survive, but the renderer gets a + // structured failure it can surface instead of an IPC rejection. + await expect( + getHandler('DOWNLOADS_REMOVE')(null, 42) + ).resolves.toEqual({ + error: 'Could not delete the partial file', + success: false, + }); + } finally { + consoleError.mockRestore(); + consoleLog.mockRestore(); + } + + expect(deleteWhere).not.toHaveBeenCalled(); + expect(mockRemoveDownloadFromRuntime).not.toHaveBeenCalled(); + }); + + it('removes completed, failed, and canceled partial files before clearing terminal downloads', async () => { + const { deleteWhere } = mockTerminalRows([ + { filePath: '/downloads/done.mp4', status: 'completed' }, + { filePath: '/downloads/failed.mp4', status: 'failed' }, + { filePath: '/downloads/canceled.mp4', status: 'canceled' }, + ]); + + await expect( + getHandler('DOWNLOADS_CLEAR_COMPLETED')(null) + ).resolves.toEqual({ success: true }); + + expect(mockRemovePartialDownloadFile).toHaveBeenCalledTimes(3); + expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith( + 1, + '/downloads/done.mp4' + ); + expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith( + 2, + '/downloads/failed.mp4' + ); + expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith( + 3, + '/downloads/canceled.mp4' + ); + expect( + mockRemovePartialDownloadFile.mock.invocationCallOrder[0] + ).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]); + expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1); + }); + + it('retains only downloads whose partial cleanup fails when clearing terminal downloads', async () => { + const cleanupError = new Error('permission denied'); + const { deleteWhere } = mockTerminalRows([ + { filePath: '/downloads/done.mp4', status: 'completed' }, + { filePath: '/downloads/failed.mp4', status: 'failed' }, + ]); + mockRemovePartialDownloadFile.mockImplementation((filePath) => { + if (filePath !== '/downloads/failed.mp4') { + return; + } + throw cleanupError; + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + await expect( + getHandler('DOWNLOADS_CLEAR_COMPLETED')(null) + ).resolves.toEqual({ success: true }); + } finally { + consoleError.mockRestore(); + } + + expect(deleteWhere).toHaveBeenCalledTimes(1); + expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1); + }); + + it('removes the row once a previously locked partial becomes deletable', async () => { + const { deleteWhere } = mockDownloadRow(createDownloadRow('paused')); + mockRemovePartialDownloadFile + .mockImplementationOnce(() => { + throw new Error('EPERM: locked'); + }) + .mockImplementation(() => true); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + + try { + await expect( + getHandler('DOWNLOADS_REMOVE')(null, 42) + ).resolves.toEqual({ + error: 'Could not delete the partial file', + success: false, + }); + expect(deleteWhere).not.toHaveBeenCalled(); + + // Retry after the lock is released: cleanup and delete succeed. + await expect( + getHandler('DOWNLOADS_REMOVE')(null, 42) + ).resolves.toEqual({ success: true }); + } finally { + consoleError.mockRestore(); + consoleLog.mockRestore(); + } + + expect(mockRemovePartialDownloadFile).toHaveBeenCalledTimes(2); + expect(deleteWhere).toHaveBeenCalledTimes(1); + }); + + it('maps a successful runtime pause to a success response', async () => { + mockPauseDownload.mockResolvedValue(true); + const consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + + try { + await expect( + getHandler('DOWNLOADS_PAUSE')(null, 42) + ).resolves.toEqual({ success: true }); + } finally { + consoleLog.mockRestore(); + } + + expect(mockPauseDownload).toHaveBeenCalledWith(42); + }); + + it('maps an unknown pause target to an error response', async () => { + mockPauseDownload.mockResolvedValue(false); + const consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + + try { + await expect( + getHandler('DOWNLOADS_PAUSE')(null, 42) + ).resolves.toEqual({ + error: 'Download not found in queue', + success: false, + }); + } finally { + consoleLog.mockRestore(); + } + }); + + it('forwards resume requests with the download folder and returns the result', async () => { + mockResumeDownloadRequest.mockResolvedValue({ + error: 'Can only resume paused downloads', + success: false, + }); + + await expect( + getHandler('DOWNLOADS_RESUME')(null, 42, '/downloads') + ).resolves.toEqual({ + error: 'Can only resume paused downloads', + success: false, + }); + + expect(mockResumeDownloadRequest).toHaveBeenCalledWith( + 42, + '/downloads', + expect.anything() + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/downloads.events.ts b/apps/electron-backend/src/app/events/database/downloads.events.ts index 2f41b83f0..57c8e7ae0 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.ts @@ -6,7 +6,9 @@ import { join } from 'node:path'; import { getDatabase } from '../../database/connection'; import * as schema from '../../database/schema'; import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; +import { removePartialDownloadFile } from './download-file-path'; import { + resumeDownloadRequest, retryDownloadRequest, startDownloadRequest, type StartDownloadRequest, @@ -15,10 +17,19 @@ import { resetStaleDownloads } from './download-recovery'; import { broadcastDownloadUpdate, cancelDownload, + pauseDownload, removeDownloadFromRuntime, setMainWindow, } from './download-runtime'; +const removablePartialStatuses = new Set([ + 'queued', + 'paused', + 'completed', + 'failed', + 'canceled', +]); + function getDownloadAuthorizationPath(): string { return join( app.getPath('userData'), @@ -104,6 +115,34 @@ ipcMain.handle('DOWNLOADS_CANCEL', async (_event, downloadId: number) => { } }); +ipcMain.handle('DOWNLOADS_PAUSE', async (_event, downloadId: number) => { + try { + console.log('[Downloads] Pause download:', downloadId); + return (await pauseDownload(downloadId)) + ? { success: true } + : { error: 'Download not found in queue', success: false }; + } catch (error) { + console.error('[Downloads] Error pausing download:', error); + throw error; + } +}); + +ipcMain.handle( + 'DOWNLOADS_RESUME', + async (_event, downloadId: number, downloadFolder: string) => { + try { + return await resumeDownloadRequest( + downloadId, + downloadFolder, + downloadDirectoryAuthorizer + ); + } catch (error) { + console.error('[Downloads] Error resuming download:', error); + throw error; + } + } +); + ipcMain.handle( 'DOWNLOADS_RETRY', async (_event, downloadId: number, downloadFolder: string) => { @@ -123,8 +162,36 @@ ipcMain.handle( ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => { try { console.log('[Downloads] Remove download:', downloadId); - removeDownloadFromRuntime(downloadId); const db = await getDatabase(); + const rows = await db + .select({ + filePath: schema.downloads.filePath, + status: schema.downloads.status, + }) + .from(schema.downloads) + .where(eq(schema.downloads.id, downloadId)) + .limit(1); + const row = rows[0]; + if (row?.filePath && removablePartialStatuses.has(row.status)) { + try { + removePartialDownloadFile(row.filePath); + } catch (cleanupError) { + // Keep the row (and its runtime entry) so the .part is never + // orphaned, but answer with a structured failure the UI can + // surface instead of an opaque IPC rejection. Retrying the + // remove re-attempts the deletion. + console.error( + '[Downloads] Failed to delete partial file on remove:', + row.filePath, + cleanupError + ); + return { + error: 'Could not delete the partial file', + success: false, + }; + } + } + removeDownloadFromRuntime(downloadId); await db .delete(schema.downloads) .where(eq(schema.downloads.id, downloadId)); @@ -210,17 +277,43 @@ ipcMain.handle( 'failed', 'canceled', ]); - await db - .delete(schema.downloads) - .where( - playlistId - ? and( - eq(schema.downloads.playlistId, playlistId), - terminalStatus - ) - : terminalStatus - ); - broadcastDownloadUpdate(); + const terminalFilter = playlistId + ? and(eq(schema.downloads.playlistId, playlistId), terminalStatus) + : terminalStatus; + const rows = await db + .select({ + id: schema.downloads.id, + filePath: schema.downloads.filePath, + status: schema.downloads.status, + }) + .from(schema.downloads) + .where(terminalFilter); + const downloadIdsToDelete: number[] = []; + for (const row of rows) { + if (row.filePath && removablePartialStatuses.has(row.status)) { + try { + removePartialDownloadFile(row.filePath); + } catch (error) { + console.error( + '[Downloads] Retaining download after partial cleanup failed:', + error + ); + continue; + } + } + downloadIdsToDelete.push(row.id); + } + if (downloadIdsToDelete.length > 0) { + await db + .delete(schema.downloads) + .where( + and( + terminalFilter, + inArray(schema.downloads.id, downloadIdsToDelete) + ) + ); + broadcastDownloadUpdate(); + } return { success: true }; } catch (error) { console.error('[Downloads] Error clearing completed:', error); diff --git a/apps/electron-backend/src/app/events/database/stale-download-files.ts b/apps/electron-backend/src/app/events/database/stale-download-files.ts deleted file mode 100644 index 71d1f159e..000000000 --- a/apps/electron-backend/src/app/events/database/stale-download-files.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { removePartialDownload } from './download-file-path'; - -interface StaleDownloadFile { - filePath: string | null; -} - -function removePersistedPartial(filePath: string): void { - removePartialDownload({ getSavePath: () => filePath }); -} - -export function cleanupStaleDownloadFiles( - downloads: readonly StaleDownloadFile[], - removeFile: (filePath: string) => void = removePersistedPartial -): void { - for (const download of downloads) { - if (!download.filePath) { - continue; - } - try { - removeFile(download.filePath); - } catch (error) { - console.error( - '[Downloads] Failed to delete stale partial file:', - download.filePath, - error - ); - } - } -} diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index ed10b1fad..771be4a7c 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "لم يعد بالإمكان فتح هذا التنزيل في المكتبة لأنه تمت إزالة قائمة التشغيل المصدر.", "FILE_NOT_FOUND": "هذا الملف الذي تم تنزيله لم يعد متاحًا على القرص.", "FILE_ACTION_ERROR": "تعذر إكمال الإجراء على الملف.", + "ACTION_FAILED": "فشل الإجراء", + "PAUSE": "إيقاف مؤقت", + "RESUME": "استئناف", "CANCEL": "إلغاء", "RETRY": "إعادة المحاولة", "REMOVE": "إزالة", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "جارٍ التنزيل", "COMPLETED": "مكتمل", "FAILED": "فشل", - "CANCELED": "ملغى" + "CANCELED": "ملغى", + "PAUSED": "متوقف مؤقتًا" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index e0d58c5b7..3f867dc10 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "هاد التحميل ما يقدرش يتحل في المكتبة حيت قائمة التشغيل المصدر تحيدات.", "FILE_NOT_FOUND": "هاد الملف المحمل ما بقاش متوفر على القرص.", "FILE_ACTION_ERROR": "العملية على الملف ما تكملاتش.", + "ACTION_FAILED": "العملية ما نجحاتش", + "PAUSE": "وقّف مؤقتاً", + "RESUME": "كمّل", "CANCEL": "إلغاء", "RETRY": "حاول مرة أخرى", "REMOVE": "حيد", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "جاري التحميل", "COMPLETED": "كمل", "FAILED": "فشل", - "CANCELED": "تلغى" + "CANCELED": "تلغى", + "PAUSED": "موقوف مؤقتاً" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 485e0f796..2951c8474 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Гэта спампаванне больш нельга адкрыць у бібліятэцы, бо яго зыходны плэйліст быў выдалены.", "FILE_NOT_FOUND": "Гэты спампаваны файл больш недаступны на дыску.", "FILE_ACTION_ERROR": "Не ўдалося выканаць дзеянне з файлам.", + "ACTION_FAILED": "Дзеянне не выканана", + "PAUSE": "Паўза", + "RESUME": "Узнавіць", "CANCEL": "Скасаваць", "RETRY": "Паўтарыць", "REMOVE": "Выдаліць", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "Спампоўваецца", "COMPLETED": "Завершана", "FAILED": "Няўдала", - "CANCELED": "Скасавана" + "CANCELED": "Скасавана", + "PAUSED": "На паўзе" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index ee8c4cbb6..860ad97ce 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Dieser Download kann nicht mehr in der Bibliothek geöffnet werden, weil die zugehörige Playlist entfernt wurde.", "FILE_NOT_FOUND": "Diese heruntergeladene Datei ist auf der Festplatte nicht mehr verfügbar.", "FILE_ACTION_ERROR": "Die Dateiaktion konnte nicht ausgeführt werden.", + "ACTION_FAILED": "Aktion fehlgeschlagen", + "PAUSE": "Pausieren", + "RESUME": "Fortsetzen", "CANCEL": "Abbrechen", "RETRY": "Wiederholen", "REMOVE": "Entfernen", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "Wird heruntergeladen", "COMPLETED": "Abgeschlossen", "FAILED": "Fehlgeschlagen", - "CANCELED": "Abgebrochen" + "CANCELED": "Abgebrochen", + "PAUSED": "Pausiert" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 92828089d..b7100e1ec 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Αυτή η λήψη δεν μπορεί πλέον να ανοίξει στη βιβλιοθήκη επειδή η πηγαία λίστα αναπαραγωγής αφαιρέθηκε.", "FILE_NOT_FOUND": "Αυτό το ληφθέν αρχείο δεν είναι πλέον διαθέσιμο στον δίσκο.", "FILE_ACTION_ERROR": "Δεν ήταν δυνατή η ολοκλήρωση της ενέργειας αρχείου.", + "ACTION_FAILED": "Η ενέργεια απέτυχε", + "PAUSE": "Παύση", + "RESUME": "Συνέχιση", "CANCEL": "Ακύρωση", "RETRY": "Επανάληψη", "REMOVE": "Αφαίρεση", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "Λήψη", "COMPLETED": "Ολοκληρώθηκε", "FAILED": "Απέτυχε", - "CANCELED": "Ακυρώθηκε" + "CANCELED": "Ακυρώθηκε", + "PAUSED": "Σε παύση" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index c88c47858..05d7dc92f 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "This download can no longer open in the library because its source playlist was removed.", "FILE_NOT_FOUND": "This downloaded file is no longer available on disk.", "FILE_ACTION_ERROR": "The file action could not be completed.", + "ACTION_FAILED": "Action failed", + "PAUSE": "Pause", + "RESUME": "Resume", "CANCEL": "Cancel", "RETRY": "Retry", "REMOVE": "Remove", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "Downloading", "COMPLETED": "Completed", "FAILED": "Failed", - "CANCELED": "Canceled" + "CANCELED": "Canceled", + "PAUSED": "Paused" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 7cdbc4bde..133151e42 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Esta descarga ya no se puede abrir en la biblioteca porque su lista de reproducción de origen fue eliminada.", "FILE_NOT_FOUND": "Este archivo descargado ya no está disponible en el disco.", "FILE_ACTION_ERROR": "No se pudo completar la acción del archivo.", + "ACTION_FAILED": "La acción ha fallado", + "PAUSE": "Pausar", + "RESUME": "Reanudar", "CANCEL": "Cancelar", "RETRY": "Reintentar", "REMOVE": "Eliminar", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "Descargando", "COMPLETED": "Completado", "FAILED": "Fallido", - "CANCELED": "Cancelado" + "CANCELED": "Cancelado", + "PAUSED": "En pausa" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 569cb0a9e..28fe4e577 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Ce téléchargement ne peut plus être ouvert dans la bibliothèque car sa liste de lecture source a été supprimée.", "FILE_NOT_FOUND": "Ce fichier téléchargé n'est plus disponible sur le disque.", "FILE_ACTION_ERROR": "L'action sur le fichier n'a pas pu être effectuée.", + "ACTION_FAILED": "Échec de l'action", + "PAUSE": "Mettre en pause", + "RESUME": "Reprendre", "CANCEL": "Annuler", "RETRY": "Réessayer", "REMOVE": "Supprimer", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "En cours", "COMPLETED": "Terminé", "FAILED": "Échec", - "CANCELED": "Annulé" + "CANCELED": "Annulé", + "PAUSED": "En pause" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index c16dcf61e..6c6825239 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Questo download non può più essere aperto nella libreria perché la playlist di origine è stata rimossa.", "FILE_NOT_FOUND": "Questo file scaricato non è più disponibile su disco.", "FILE_ACTION_ERROR": "Impossibile completare l'azione sul file.", + "ACTION_FAILED": "Azione non riuscita", + "PAUSE": "Pausa", + "RESUME": "Riprendi", "CANCEL": "Annulla", "RETRY": "Riprova", "REMOVE": "Rimuovi", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "In download", "COMPLETED": "Completato", "FAILED": "Non riuscito", - "CANCELED": "Annullato" + "CANCELED": "Annullato", + "PAUSED": "In pausa" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 1fe40c681..adf2fa981 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "ソースのプレイリストが削除されたため、このダウンロードはライブラリで開けなくなりました。", "FILE_NOT_FOUND": "このダウンロードファイルはディスク上で見つかりませんでした。", "FILE_ACTION_ERROR": "ファイル操作を完了できませんでした。", + "ACTION_FAILED": "操作に失敗しました", + "PAUSE": "一時停止", + "RESUME": "再開", "CANCEL": "キャンセル", "RETRY": "再試行", "REMOVE": "削除", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "ダウンロード中", "COMPLETED": "完了", "FAILED": "失敗", - "CANCELED": "キャンセル済み" + "CANCELED": "キャンセル済み", + "PAUSED": "一時停止中" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d64b729cd..05a9a4511 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "원본 재생목록이 제거되어 이 다운로드 항목을 라이브러리에서 더 이상 열 수 없습니다.", "FILE_NOT_FOUND": "이 다운로드한 파일을 디스크에서 더 이상 사용할 수 없습니다.", "FILE_ACTION_ERROR": "파일 작업을 완료하지 못했습니다.", + "ACTION_FAILED": "작업 실패", + "PAUSE": "일시정지", + "RESUME": "재개", "CANCEL": "취소", "RETRY": "다시 시도", "REMOVE": "제거", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "다운로드 중", "COMPLETED": "완료됨", "FAILED": "실패", - "CANCELED": "취소됨" + "CANCELED": "취소됨", + "PAUSED": "일시정지됨" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index dd5af527d..3b67ccf84 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Deze download kan niet meer worden geopend in de bibliotheek omdat de bronafspeellijst is verwijderd.", "FILE_NOT_FOUND": "Dit gedownloade bestand is niet meer beschikbaar op de schijf.", "FILE_ACTION_ERROR": "De bestandsactie kon niet worden voltooid.", + "ACTION_FAILED": "Actie mislukt", + "PAUSE": "Pauzeren", + "RESUME": "Hervatten", "CANCEL": "Annuleren", "RETRY": "Opnieuw proberen", "REMOVE": "Verwijderen", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "Downloaden", "COMPLETED": "Voltooid", "FAILED": "Mislukt", - "CANCELED": "Geannuleerd" + "CANCELED": "Geannuleerd", + "PAUSED": "Gepauzeerd" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index bffa8dc82..7d1f64bc4 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Tego pobrania nie można już otworzyć w bibliotece, ponieważ jego źródłowa lista odtwarzania została usunięta.", "FILE_NOT_FOUND": "Ten pobrany plik nie jest już dostępny na dysku.", "FILE_ACTION_ERROR": "Nie udało się wykonać akcji na pliku.", + "ACTION_FAILED": "Akcja nie powiodła się", + "PAUSE": "Wstrzymaj", + "RESUME": "Wznów", "CANCEL": "Anuluj", "RETRY": "Spróbuj ponownie", "REMOVE": "Usuń", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "Pobieranie", "COMPLETED": "Ukończone", "FAILED": "Niepowodzenie", - "CANCELED": "Anulowane" + "CANCELED": "Anulowane", + "PAUSED": "Wstrzymane" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 360be5be3..6afa19dbb 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Este download não pode mais ser aberto na biblioteca porque sua playlist de origem foi removida.", "FILE_NOT_FOUND": "Este arquivo baixado não está mais disponível no disco.", "FILE_ACTION_ERROR": "A ação do arquivo não pôde ser concluída.", + "ACTION_FAILED": "Falha na ação", + "PAUSE": "Pausar", + "RESUME": "Retomar", "CANCEL": "Cancelar", "RETRY": "Tentar novamente", "REMOVE": "Remover", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "Baixando", "COMPLETED": "Concluído", "FAILED": "Falhou", - "CANCELED": "Cancelado" + "CANCELED": "Cancelado", + "PAUSED": "Pausado" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 02d4026a7..ee2abbae2 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Эту загрузку больше нельзя открыть в библиотеке, потому что исходный плейлист был удален.", "FILE_NOT_FOUND": "Этот загруженный файл больше недоступен на диске.", "FILE_ACTION_ERROR": "Не удалось выполнить действие с файлом.", + "ACTION_FAILED": "Действие не выполнено", + "PAUSE": "Пауза", + "RESUME": "Возобновить", "CANCEL": "Отмена", "RETRY": "Повторить", "REMOVE": "Удалить", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "Скачивается", "COMPLETED": "Завершено", "FAILED": "Ошибка", - "CANCELED": "Отменено" + "CANCELED": "Отменено", + "PAUSED": "На паузе" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index f699a9cf5..3d044c8d3 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Bu indirme, kaynak oynatma listesi kaldırıldığı için kütüphanede artık açılamıyor.", "FILE_NOT_FOUND": "Bu indirilen dosya artık diskte mevcut değil.", "FILE_ACTION_ERROR": "Dosya işlemi tamamlanamadı.", + "ACTION_FAILED": "İşlem başarısız oldu", + "PAUSE": "Duraklat", + "RESUME": "Devam et", "CANCEL": "İptal", "RETRY": "Tekrar Dene", "REMOVE": "Kaldır", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "İndiriliyor", "COMPLETED": "Tamamlandı", "FAILED": "Başarısız", - "CANCELED": "İptal Edildi" + "CANCELED": "İptal Edildi", + "PAUSED": "Duraklatıldı" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 4a6cf0e4a..4e928c56f 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "由于源播放列表已被移除,此下载项无法在库中打开。", "FILE_NOT_FOUND": "此下载文件已不在磁盘上。", "FILE_ACTION_ERROR": "无法完成此文件操作。", + "ACTION_FAILED": "操作失败", + "PAUSE": "暂停", + "RESUME": "继续", "CANCEL": "取消", "RETRY": "重试", "REMOVE": "移除", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "下载中", "COMPLETED": "已完成", "FAILED": "失败", - "CANCELED": "已取消" + "CANCELED": "已取消", + "PAUSED": "已暂停" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 7bf0abebe..c0914a0f0 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1004,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "此下載項目無法在媒體庫中開啟,因為其來源播放清單已被移除。", "FILE_NOT_FOUND": "此下載檔案在磁碟上已不存在。", "FILE_ACTION_ERROR": "無法完成檔案操作。", + "ACTION_FAILED": "操作失敗", + "PAUSE": "暫停", + "RESUME": "繼續", "CANCEL": "取消", "RETRY": "重試", "REMOVE": "移除", @@ -1017,7 +1020,8 @@ "DOWNLOADING": "下載中", "COMPLETED": "已完成", "FAILED": "失敗", - "CANCELED": "已取消" + "CANCELED": "已取消", + "PAUSED": "已暫停" } }, "REMOTE_CONTROL": { diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 0608f1fb1..3217951e6 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -5,27 +5,35 @@ The download manager is a desktop-only feature that layers a curated queue, prog ## Backend responsibilities - **Queue control (`apps/electron-backend/src/app/events/database/download-runtime.ts`)** - `DownloadTask` mirrors a row of the shared `downloads` table (type `Download` in `libs/shared/database/src/lib/schema.ts`) plus transient cancel/progress helpers. Request validation and row creation live in `download-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. -- **electron-dl integration** - `startDownload()` calls `electron-dl`'s `download()` helper. Headers (user agent, referer, origin) are attached, and the `onStarted`, `onProgress`, `onCompleted`, and `onCancel` callbacks translate the helper's payload into Drizzle updates. A cancellation requested before `onStarted` is remembered and applied as soon as Electron supplies the `DownloadItem`, so the request cannot be lost in the startup race. + `DownloadTask` mirrors a row of the shared `downloads` table (type `Download` in `libs/shared/database/src/lib/schema.ts`) plus transient cancel/pause/progress helpers (shared task types live in `download-task.ts`). Request validation and row creation live in `download-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. The byte transfer itself lives in `download-transfer.ts`, finalization and retained-partial persistence in `download-finalize.ts`, and the renderer update broadcast in `download-broadcast.ts`. +- **Range-aware transfer (`download-transfer.ts`)** + The transfer streams the response through the backend's validated Axios redirect helper instead of `electron-dl`. Headers (user agent, referer, origin) are persisted in `request_headers` and re-applied through the same allowlist when read back on retry/resume. Active pause/cancel operations abort the current request with `AbortController`; pause keeps the partial file and cancel removes it. Resume checks the existing `.part` size (rejecting anything that is not a regular file, so a symlink planted while paused is never followed) and sends `Range: bytes=-` plus `If-Range` with the stored entity validator. The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator` for exactly this purpose. A `206 Partial Content` answer must start at the requested offset (`Content-Range` is verified) before bytes are appended; any other 2xx answer — the server ignoring `Range`, or `If-Range` detecting that the remote file changed — restarts the transfer from byte zero over the same `.part` instead of failing the download. - **Destination collision policy** - Existing destination files are never overwritten. Before starting Electron's - download, the backend atomically reserves a free numbered filename with an - exclusive filesystem create. Electron may overwrite that empty reservation, - but cannot overwrite a file that existed before the reservation. The selected - `filePath` and `fileName` are persisted before transfer begins. Errors, - cancellations, and startup recovery remove that exact partial path and clear - it from the row; completed downloads replace it with Electron's final values. + Existing destination files are never overwritten, inspected, or deleted. + Before starting a new transfer, the backend atomically reserves a free + numbered `.part` path while leaving the final destination path absent. The + selected final `filePath` and `fileName` are persisted before transfer + begins. When a retained download's recorded destination got occupied while + it was paused or failed (for example by a file the user created), the + retained `.part` is renamed aside and finalized to the next free numbered + destination (`Movie (1).mp4`) instead of resolving the collision by size or + `unlink()`. Completion creates the final `filePath` from the `.part` without + overwriting an existing file; cancel and ordinary transfer failures remove + the `.part`, while finalization failures and completed-partial failures + deliberately retain it (the row keeps `filePath` so a later retry can finish + without re-downloading); pause and restart recovery keep it for a later + resume. Re-downloading such a failed row from a detail page + (`DOWNLOADS_START`) deletes the retained `.part` before the row is reset. - **IPC surface** - The backend exposes `DOWNLOADS_*` handlers for list retrieval, start/cancel/retry/remove operations, folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the renderer listens to in order to refresh its signal store. + The backend exposes `DOWNLOADS_*` handlers for list retrieval, start/pause/resume/cancel/retry/remove operations, folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the renderer listens to in order to refresh its signal store. ## Renderer architecture - **Downloads service** (`libs/services/src/lib/downloads.service.ts`) - Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download the service asks the main process for the authorized folder and calls `downloadsStart`. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path. + Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download/resume the service asks the main process for the authorized folder and calls the download IPC command. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `pauseDownload`, `resumeDownload`, `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path. - **Downloads view** (`libs/portal/downloads/feature`) A standalone page exposes the queue, desktop-only messaging, folder picker, and action buttons. `downloads.component.html` wraps the list inside a scrollable panel (`downloads__list-wrapper`) so long queues stay reachable, and `downloads.component.scss` drives gradient cards with theme-aware styling through Angular Material system CSS variables (`var(--mat-sys-*)`, `var(--app-*)`, `color-mix`) — theming tracks the active Material theme rather than a `body.dark-theme` hook. - Failed/canceled cards now show retry/delete controls, queued/downloading cards show a cancel icon, and completed cards render inline play/open buttons with `mat-icon` cues. The header also shows the resolved download folder and a `CHANGE FOLDER` action. + Failed/canceled cards show retry/delete controls, queued/downloading cards show pause/cancel controls, paused cards show resume/cancel/delete controls, and completed cards render inline play/open buttons with `mat-icon` cues. Pause/resume/cancel/retry surface backend `success: false` results in a snackbar instead of failing silently. The header also shows the resolved download folder and a `CHANGE FOLDER` action. VOD and episode detail views render a paused download as an active "Resume" button (`DownloadsService.isPaused()` / `resumeDownloadByContent()`) rather than a disabled "Downloading" state. ## Global API surface @@ -39,8 +47,15 @@ The download manager is a desktop-only feature that layers a curated queue, prog ## Queuing, persistence, and UX notes -- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, and Xtream identifiers. On startup, `download-recovery.ts` deletes persisted partial reservations before stale queued/downloading rows become `failed`. -- Queue cancellation removes a queued task or records an active cancellation request and calls `downloadItem.cancel()` when the item is available; retries reuse the same database entry, preventing duplicate rows. +- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `paused`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, `requestHeaders`, `resumeValidator`, and Xtream identifiers. Existing SQLite tables are rebuilt on startup when their status CHECK still lacks `paused`; the `resume_validator` column is added through the idempotent column migrations. +- On startup, `download-recovery.ts` converts stale `downloading` rows with a non-empty `.part` file to `paused`, converts stale `queued` rows to `paused` while keeping any retained `.part` (a resumed download waiting behind an active one persists as `queued` with its partial), and marks stale `downloading` rows without recoverable partial bytes as `failed`. +- Queue cancellation removes a queued task or records an active cancellation request and aborts the request when available. Pausing follows the same abort path but persists `paused` and keeps the `.part`. Retries reuse the same database entry: a failed row with a retained `filePath` resumes its `.part` through HTTP Range, otherwise the retry starts from zero. Resume appends to the existing `.part` through HTTP Range with `If-Range` validation. +- A `.part` that cannot be deleted (locked, permission denied) never loses its database path: cancel persists `canceled` while retaining `filePath` for later cleanup, and `DOWNLOADS_REMOVE` keeps the row and answers `success: false` (surfaced as a snackbar) so retrying the remove re-attempts the deletion once the lock is released. +- Resume claims the row atomically (`paused` → `queued` as a conditional update) and the runtime queue rejects duplicate ids, so two rapid Resume clicks racing the status refresh can never produce two transfers for the same download. +- A response that ends cleanly before the advertised representation size (for example a proxy that caps each response) is never committed as completed: the transfer fails with `Transfer ended before the advertised size` while retaining the `.part` and `filePath`, so a retry continues via Range from where it stopped. +- Retained `filePath`s recorded in the database stay usable after the user switches download folders — resume/retry of a retained row does not re-require the folder to be the current selection. Fresh downloads still authorize against the currently selected folder. +- Startup recovery recognizes a finalization that crashed between creating the final file and committing the row (`downloading` row, no partial, final file present with the recorded size) and marks it `completed` instead of failing it and orphaning the file. +- Pause/resume is covered end to end by `apps/electron-backend-e2e/src/downloads.e2e.ts`: a throttled Range-capable mock server verifies the paused `.part` on disk, the `Range`/`If-Range` resume request, and byte-exact assembly of the final file. - The OS downloads path is always authorized. A custom folder becomes authorized only after native folder selection, and the main process persists that selection under Electron `userData`. Renderer settings may display the diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index e33245a04..461e43fbf 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -228,6 +228,7 @@ @if ( item.status === 'downloading' || + item.status === 'paused' || item.status === 'completed' ) { @@ -253,6 +254,17 @@
@if (item.status === 'downloading') { + + } @else if ( + item.status === 'paused' && item.totalBytes + ) { @switch (item.status) { @case ('queued') { + + + + } @case ('completed') { + } @else if (isPausedDownload()) { + } @else if (isDownloading()) { + } @else if ( + isEpisodePaused(episode) + ) { + } @else if ( isEpisodeDownloading(episode) ) { @@ -413,6 +432,22 @@ > folder_open + } @else if (isEpisodePaused(episode)) { + } @else if (isEpisodeDownloading(episode)) { + } @else if (isPausedDownload()) { + } @else if (isDownloading()) {
} +@if (fullscreenMediaTitle(); as mediaTitle) { +
+
+ {{ mediaTitle.primary }} +
+ @if (mediaTitle.secondary) { +
+ {{ mediaTitle.secondary }} +
+ } +
+} + @if (showControls()) {
= signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + togglePlay: jest.fn(), + seekTo: jest.fn(), + seekBy: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + togglePictureInPicture: jest.fn(), + }; + const controller: PlayerController = { capabilities, state, commands }; + return { controller, capabilities, state, commands }; +} + +describe('PlayerControlsComponent fullscreen media title', () => { + let fixture: ComponentFixture; + let component: PlayerControlsComponent; + let fake: ReturnType; + let surface: HTMLElement; + let fullscreenElement: Element | null; + + const setState = (overrides: Partial) => + fake.state.set({ ...createEmptyControlsState(), ...overrides }); + + const enterFullscreen = () => { + fullscreenElement = surface; + document.dispatchEvent(new Event('fullscreenchange')); + fixture.detectChanges(); + }; + + const queryTitle = (): HTMLElement | null => + fixture.nativeElement.querySelector( + '[data-test-id="player-controls-media-title"]' + ); + + beforeEach(async () => { + localStorage.removeItem('volume'); + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + + surface = document.createElement('div'); + document.body.appendChild(surface); + + fullscreenElement = null; + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => fullscreenElement, + }); + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('controller', fake.controller); + fixture.componentRef.setInput('playerSurface', surface); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + surface.remove(); + jest.useRealTimers(); + }); + + it('stays hidden outside fullscreen even when a media title is set', () => { + fixture.componentRef.setInput('mediaTitle', { + primary: 'Some Movie', + secondary: null, + }); + fixture.detectChanges(); + + expect(queryTitle()).toBeNull(); + }); + + it('shows both title lines in fullscreen and hides them again on exit', () => { + fixture.componentRef.setInput('mediaTitle', { + primary: 'Breaking Code', + secondary: 'S01E02', + }); + fixture.detectChanges(); + enterFullscreen(); + + const title = queryTitle(); + expect(title).not.toBeNull(); + expect( + title?.querySelector('.player-controls__title-primary')?.textContent + ).toContain('Breaking Code'); + expect( + title?.querySelector('.player-controls__title-secondary') + ?.textContent + ).toContain('S01E02'); + + fullscreenElement = null; + document.dispatchEvent(new Event('fullscreenchange')); + fixture.detectChanges(); + expect(queryTitle()).toBeNull(); + }); + + it('renders a single line when no secondary text is provided', () => { + fixture.componentRef.setInput('mediaTitle', { + primary: 'Channel One', + secondary: null, + }); + fixture.detectChanges(); + enterFullscreen(); + + const title = queryTitle(); + expect(title).not.toBeNull(); + expect( + title?.querySelector('.player-controls__title-secondary') + ).toBeNull(); + }); + + it('stays hidden for a blank primary title and when controls are disabled', () => { + fixture.componentRef.setInput('mediaTitle', { + primary: ' ', + secondary: 'S01E02', + }); + fixture.detectChanges(); + enterFullscreen(); + expect(queryTitle()).toBeNull(); + + fixture.componentRef.setInput('mediaTitle', { + primary: 'Breaking Code', + secondary: null, + }); + fixture.componentRef.setInput('showControls', false); + fixture.detectChanges(); + expect(queryTitle()).toBeNull(); + }); + + it('follows the auto-hide visibility of the controls bar', () => { + jest.useFakeTimers(); + fixture.componentRef.setInput('mediaTitle', { + primary: 'Breaking Code', + secondary: 'S01E02', + }); + fixture.detectChanges(); + enterFullscreen(); + + expect( + queryTitle()?.classList.contains( + 'player-controls__title--visible' + ) + ).toBe(true); + + setState({ status: 'playing' }); + fixture.detectChanges(); + jest.advanceTimersByTime(10000); + fixture.detectChanges(); + + expect(component.controlsAreVisible()).toBe(false); + expect( + queryTitle()?.classList.contains( + 'player-controls__title--visible' + ) + ).toBe(false); + + surface.dispatchEvent(new MouseEvent('pointermove')); + fixture.detectChanges(); + expect( + queryTitle()?.classList.contains( + 'player-controls__title--visible' + ) + ).toBe(true); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss index c3de35ba9..bd4de88fa 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -72,6 +72,59 @@ } } +.player-controls__title { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 2; + display: flex; + flex-direction: column; + gap: 4px; + // Generous bottom padding lets the scrim fall below the text and fade out. + padding: 20px 24px 40px; + opacity: 0; + // Purely informative: never intercept clicks meant for the video surface. + pointer-events: none; + transform: translateY(-8px); + // Mirror of the bottom bar's scrim so the title reads over bright video. + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.7) 0%, + rgba(0, 0, 0, 0.35) 55%, + transparent 100% + ); + transition: + opacity 180ms ease-out, + transform 200ms ease-out; +} + +.player-controls__title--visible { + opacity: 1; + transform: translateY(0); +} + +.player-controls__title-primary { + overflow: hidden; + color: var(--pc-text); + font-size: 1.15rem; + font-weight: 600; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); +} + +.player-controls__title-secondary { + overflow: hidden; + color: var(--pc-text-dim); + font-size: 0.85rem; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); +} + .player-controls__bar { position: absolute; right: 0; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts index 1a74ba787..08fb2e1c9 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -21,11 +21,15 @@ import { ControlsMenuSelection } from './controls-menu-selection'; import { ControlsMenuState } from './controls-menu-state'; import { ControlsShortcuts } from './controls-shortcuts'; import { ControlsSurface } from './controls-surface'; +import { ControlsTimeline } from './controls-timeline'; import { ControlsVisibility } from './controls-visibility'; import { createControlsViewModel } from './controls-view-model'; import { ControlsVolume } from './controls-volume'; import { formatTime, speedLabel } from './controls-format.utils'; -import type { PlayerController } from './player-controls.model'; +import type { + PlayerController, + PlayerMediaTitle, +} from './player-controls.model'; @Component({ selector: 'app-player-controls', @@ -45,6 +49,7 @@ export class PlayerControlsComponent implements OnDestroy { readonly playerSurface = input(null); readonly showControls = input(true); readonly shortcutsEnabled = input(true); + readonly mediaTitle = input(null); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); readonly barHovered = signal(false); @@ -86,29 +91,25 @@ export class PlayerControlsComponent implements OnDestroy { readonly state = computed(() => this.controller().state()); readonly capabilities = computed(() => this.controller().capabilities()); private readonly controllerVolume = computed(() => this.state().volume); - readonly scrubPosition = signal(null); - readonly timelineDuration = computed(() => { - const duration = this.state().durationSeconds; - return typeof duration === 'number' && Number.isFinite(duration) - ? Math.max(0, duration) - : 0; - }); - readonly timelineValue = computed( - () => - this.normalizeTimelineValue( - this.scrubPosition() ?? this.state().positionSeconds - ) ?? 0 - ); - readonly timelineProgress = computed(() => { - const duration = this.timelineDuration(); - return this.state().canSeek && duration > 0 - ? (this.timelineValue() / duration) * 100 - : 0; - }); + private readonly timeline = new ControlsTimeline(this.state); + readonly scrubPosition = this.timeline.scrubPosition; + readonly timelineDuration = this.timeline.duration; + readonly timelineValue = this.timeline.value; + readonly timelineProgress = this.timeline.progress; readonly displayVolume = this.volume.value; readonly isFullscreen = this.fullscreen.isFullscreen; + // The page around the player already names the content; the overlay only + // fills that gap in fullscreen, where no other chrome is visible. + readonly fullscreenMediaTitle = computed(() => { + if (!this.showControls() || !this.isFullscreen()) { + return null; + } + const mediaTitle = this.mediaTitle(); + return mediaTitle?.primary?.trim() ? mediaTitle : null; + }); + private readonly vm = createControlsViewModel({ state: this.state, capabilities: this.capabilities, @@ -246,11 +247,11 @@ export class PlayerControlsComponent implements OnDestroy { } onTimelineInput(event: Event): void { this.reveal(); - this.scrubPosition.set(this.readTimelineValue(event)); + this.scrubPosition.set(this.timeline.readEventValue(event)); } onTimelineCommit(event: Event): void { this.reveal(); - const target = this.readTimelineValue(event); + const target = this.timeline.readEventValue(event); this.scrubPosition.set(null); if ( target === null || @@ -331,24 +332,6 @@ export class PlayerControlsComponent implements OnDestroy { this.volume.adjust(delta); this.reveal(); } - private readTimelineValue(event: Event): number | null { - return this.normalizeTimelineValue( - Number((event.target as HTMLInputElement).value) - ); - } - private normalizeTimelineValue(value: number): number | null { - if (!Number.isFinite(value)) { - return null; - } - - const duration = this.state().durationSeconds; - const upperBound = - typeof duration === 'number' && Number.isFinite(duration) - ? Math.max(0, duration) - : Number.POSITIVE_INFINITY; - return Math.min(Math.max(0, value), upperBound); - } - private closePopovers(): void { if (!this.menus.anyOpen()) { return; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts index 25185df21..bb486f0e0 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts @@ -28,6 +28,18 @@ export interface PlayerTrack { selected: boolean; } +/** + * Content title lines rendered over the player while the controls are visible + * in fullscreen. Hosts pass display-ready strings; the controls never derive + * them from playback data. + */ +export interface PlayerMediaTitle { + /** Movie title, channel name, or series name. */ + primary: string; + /** Optional second line, e.g. the "S01E03" label for series episodes. */ + secondary?: string | null; +} + export interface PlayerPreset { value: T; label: string; 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 868f571c9..b935d10e9 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 @@ -60,6 +60,7 @@ ({})); class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); + readonly mediaTitle = input(null); readonly playback = input(null); readonly startTime = input(0); readonly seriesNavigation = input(null); @@ -123,6 +124,78 @@ describe('PortalInlinePlayerComponent', () => { expect(events).toEqual(['ended', 'previous', 'next']); }); + describe('playerMediaTitle', () => { + it('uses the series title with the episode label for episode playback', () => { + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.test/series/1002.mp4', + title: 'Episode 2', + }); + fixture.componentRef.setInput('episodeMetadata', { + label: 'S01E02', + title: 'Episode 2', + seasonNumber: 1, + episodeNumber: 2, + }); + fixture.componentRef.setInput('seriesTitle', 'Breaking Code'); + fixture.detectChanges(); + + expect(component.playerMediaTitle()).toEqual({ + primary: 'Breaking Code', + secondary: 'S01E02', + }); + + const webPlayer = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + expect(webPlayer.mediaTitle()).toEqual({ + primary: 'Breaking Code', + secondary: 'S01E02', + }); + }); + + it('falls back to the playback title when no series title is provided', () => { + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.test/series/1002.mp4', + title: 'Episode 2', + }); + fixture.componentRef.setInput('episodeMetadata', { + label: 'S01E02', + seasonNumber: 1, + episodeNumber: 2, + }); + fixture.detectChanges(); + + expect(component.playerMediaTitle()).toEqual({ + primary: 'Episode 2', + secondary: 'S01E02', + }); + }); + + it('is a single line for movie playback and ignores the series title', () => { + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.test/vod/1.mp4', + title: 'Some Movie', + }); + fixture.componentRef.setInput('seriesTitle', 'Unrelated Series'); + fixture.detectChanges(); + + expect(component.playerMediaTitle()).toEqual({ + primary: 'Some Movie', + secondary: null, + }); + }); + + it('is null without a playback title', () => { + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.test/vod/1.mp4', + title: '', + }); + fixture.detectChanges(); + + expect(component.playerMediaTitle()).toBeNull(); + }); + }); + it('emits backClicked (not closed) from the back button in the now-playing bar', () => { let backCount = 0; let closedCount = 0; 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 5a1ba0c15..aeeca9d7a 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 @@ -19,6 +19,7 @@ import { import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util'; import { SettingsStore } from '@iptvnator/services'; import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; +import type { PlayerMediaTitle } from '../player-controls'; import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component'; import type { SeriesEpisodeMetadata, @@ -47,6 +48,9 @@ export class PortalInlinePlayerComponent { readonly playback = input(null); readonly episodeMetadata = input(null); readonly seriesNavigation = input(null); + /** Series name for the fullscreen title overlay. Xtream episode playback + * carries the episode title, so the series name must come from the host. */ + readonly seriesTitle = input(null); private readonly settingsStore = inject(SettingsStore); // Strip only live-channel titles — VOD/series titles ("Mission: // Impossible - Fallout") must never lose their leading segment. @@ -111,6 +115,17 @@ export class PortalInlinePlayerComponent { ? `${metadata.label} - ${metadata.title}` : metadata.label; }); + readonly playerMediaTitle = computed(() => { + const metadata = this.episodeMetadata(); + const primary = ( + (metadata ? this.seriesTitle() : null) || this.title() + )?.trim(); + if (!primary) { + return null; + } + + return { primary, secondary: metadata?.label ?? null }; + }); readonly closed = output(); /** Back arrow in the now-playing bar: route-level back, not just close. */ diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html index b4ab37eb4..269f42e2b 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html @@ -13,6 +13,7 @@ (null); readonly interactionEnabled = input(true); readonly showCaptions = input(false); + readonly mediaTitle = input(null); readonly timeUpdate = output<{ currentTime: number; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts new file mode 100644 index 000000000..c5f17c630 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts @@ -0,0 +1,136 @@ +import { + type PlaybackDiagnostic, + PlaybackDiagnosticCode, + getLikelyBrowserUnsupportedCodecLabels, +} from '../playback-diagnostics/playback-diagnostics.util'; + +export type PlaybackDiagnosticDetail = { + readonly labelKey: string; + readonly value: string; +}; + +export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string { + return `${getDiagnosticTranslationBase(issue)}.TITLE`; +} + +export function getDiagnosticDescriptionKey( + issue: PlaybackDiagnostic, + supportsManagedExternalPlayers: boolean +): string { + if ( + issue.code === PlaybackDiagnosticCode.BrowserAccessError && + !supportsManagedExternalPlayers + ) { + return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'; + } + + return `${getDiagnosticTranslationBase(issue)}.DESCRIPTION`; +} + +export function getDiagnosticMeta(issue: PlaybackDiagnostic): string { + const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', '); + if (codecs) { + return codecs; + } + + return issue.container || issue.mimeType || ''; +} + +export function getDiagnosticCodecHint(issue: PlaybackDiagnostic): string { + return getLikelyBrowserUnsupportedCodecLabels(issue).join(', '); +} + +export function getDiagnosticDetails( + issue: PlaybackDiagnostic +): readonly PlaybackDiagnosticDetail[] { + return [ + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE', + value: issue.code, + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER', + value: formatPlayer(issue.player), + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE', + value: formatDiagnosticSource(issue.source), + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER', + value: issue.container, + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_MIME_TYPE', + value: issue.mimeType ?? '', + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS', + value: issue.videoCodecs.join(', '), + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS', + value: issue.audioCodecs.join(', '), + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_CODE', + value: issue.nativeErrorCode?.toString() ?? '', + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE', + value: issue.nativeErrorMessage ?? '', + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS', + value: issue.details ?? '', + }, + ].filter(({ value }) => value.trim().length > 0); +} + +function getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string { + switch (issue.code) { + case PlaybackDiagnosticCode.UnsupportedContainer: + return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER'; + case PlaybackDiagnosticCode.UnsupportedCodec: + return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC'; + case PlaybackDiagnosticCode.MediaDecodeError: + return 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR'; + case PlaybackDiagnosticCode.NetworkError: + return 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR'; + case PlaybackDiagnosticCode.BrowserAccessError: + return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR'; + case PlaybackDiagnosticCode.DrmOrEncryption: + return 'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION'; + case PlaybackDiagnosticCode.UnknownPlaybackError: + default: + return 'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR'; + } +} + +function formatPlayer(player: PlaybackDiagnostic['player']): string { + switch (player) { + case 'videojs': + return 'Video.js'; + case 'html5': + return 'HTML5'; + case 'artplayer': + return 'ArtPlayer'; + default: + return ''; + } +} + +function formatDiagnosticSource(source: PlaybackDiagnostic['source']): string { + switch (source) { + case 'hls': + return 'HLS.js'; + case 'mpegts': + return 'mpegts.js'; + case 'native': + return 'Native media element'; + case 'source': + return 'Stream metadata'; + default: + return source; + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 686578c01..77546f72b 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -2,6 +2,7 @@ @defer (on immediate) { ({})); @Component({ selector: 'app-vjs-player', template: '' }) class StubVjsPlayerComponent { readonly options = input(); + readonly mediaTitle = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly interactionEnabled = input(true); @@ -50,6 +51,7 @@ class StubVjsPlayerComponent { @Component({ selector: 'app-html-video-player', template: '' }) class StubHtmlVideoPlayerComponent { readonly channel = input(); + readonly mediaTitle = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); @@ -66,6 +68,7 @@ class StubHtmlVideoPlayerComponent { @Component({ selector: 'app-art-player', template: '' }) class StubArtPlayerComponent { readonly channel = input(); + readonly mediaTitle = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); @@ -82,6 +85,7 @@ class StubArtPlayerComponent { @Component({ selector: 'app-embedded-mpv-player', template: '' }) class StubEmbeddedMpvPlayerComponent { readonly playback = input.required(); + readonly mediaTitle = input(null); readonly recordingFolder = input(''); readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 444cdf566..9b9d19e63 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -35,6 +35,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); }) class StubVjsPlayerComponent { readonly options = input(); + readonly mediaTitle = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly interactionEnabled = input(true); @@ -53,6 +54,7 @@ class StubVjsPlayerComponent { }) class StubHtmlVideoPlayerComponent { readonly channel = input(); + readonly mediaTitle = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); @@ -72,6 +74,7 @@ class StubHtmlVideoPlayerComponent { }) class StubArtPlayerComponent { readonly channel = input(); + readonly mediaTitle = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); @@ -91,6 +94,7 @@ class StubArtPlayerComponent { }) class StubEmbeddedMpvPlayerComponent { readonly playback = input.required(); + readonly mediaTitle = input(null); readonly recordingFolder = input(''); readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); @@ -165,6 +169,47 @@ describe('WebPlayerViewComponent', () => { expect(fixture.nativeElement.classList).toContain('web-player-view'); }); + describe('resolvedMediaTitle', () => { + it('prefers an explicit media title over the playback title', async () => { + fixture.componentRef.setInput('mediaTitle', { + primary: 'Breaking Code', + secondary: 'S01E02', + }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.resolvedMediaTitle()).toEqual({ + primary: 'Breaking Code', + secondary: 'S01E02', + }); + + const player = fixture.debugElement.query( + By.directive(StubVjsPlayerComponent) + ).componentInstance as StubVjsPlayerComponent; + expect(player.mediaTitle()).toEqual({ + primary: 'Breaking Code', + secondary: 'S01E02', + }); + }); + + it('falls back to the playback title as a single line', () => { + fixture.detectChanges(); + + expect(component.resolvedMediaTitle()).toEqual({ + primary: 'Example Movie', + secondary: null, + }); + }); + + it('is null when the title falls back to the raw stream URL', () => { + fixture.componentRef.setInput('title', ''); + fixture.detectChanges(); + + expect(component.resolvedMediaTitle()).toBeNull(); + }); + }); + it('renders diagnostics and emits MPV fallback requests when managed external players are available', () => { const requests: unknown[] = []; runtimeCapabilities.supportsManagedExternalPlayers = true; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 6d77cf6f7..8e2244764 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -30,23 +30,24 @@ import { ArtPlayerComponent } from '../art-player/art-player.component'; import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component'; import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component'; import { + type PlayerMediaTitle, WEB_PLAYER_SHARED_CONTROLS, WEB_PLAYER_SHARED_CONTROLS_ENABLED, } from '../player-controls'; import { type PlaybackDiagnostic, - PlaybackDiagnosticCode, type PlaybackFallbackRequest, - getLikelyBrowserUnsupportedCodecLabels, getPlaybackMediaExtensionFromUrl, } from '../playback-diagnostics/playback-diagnostics.util'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; import { VjsPlayerComponent } from '../vjs-player/vjs-player.component'; - -type PlaybackDiagnosticDetail = { - readonly labelKey: string; - readonly value: string; -}; +import { + getDiagnosticCodecHint, + getDiagnosticDescriptionKey, + getDiagnosticDetails, + getDiagnosticMeta, + getDiagnosticTitleKey, +} from './web-player-view-diagnostics.utils'; function resolveWebPlayerSharedControls(): boolean { const storedValue = inject(SettingsStore).webPlayerSharedControls?.(); @@ -93,6 +94,9 @@ export class WebPlayerViewComponent { showCaptions = input(false); playerOverride = input(null); seriesNavigation = input(null); + /** Display-ready title lines for the fullscreen overlay; hosts with richer + * context (e.g. series name + episode label) pass it explicitly. */ + mediaTitle = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number; @@ -157,6 +161,20 @@ export class WebPlayerViewComponent { this.settings()?.player ?? VideoPlayer.VideoJs ); + readonly resolvedMediaTitle = computed(() => { + const explicit = this.mediaTitle(); + if (explicit?.primary?.trim()) { + return explicit; + } + const playback = this.resolvedPlayback(); + const title = playback.title?.trim(); + // resolvedPlayback() falls back to the stream URL as title; a raw URL + // is not a watchable overlay title. + if (!title || title === playback.streamUrl) { + return null; + } + return { primary: title, secondary: null }; + }); readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? ''); constructor() { @@ -259,99 +277,16 @@ export class WebPlayerViewComponent { this.setVjsOptions(playback.streamUrl, this.resolvedIsLive()); } - getDiagnosticTitleKey(issue: PlaybackDiagnostic): string { - return `${this.getDiagnosticTranslationBase(issue)}.TITLE`; - } + readonly getDiagnosticTitleKey = getDiagnosticTitleKey; + readonly getDiagnosticMeta = getDiagnosticMeta; + readonly getDiagnosticCodecHint = getDiagnosticCodecHint; + readonly getDiagnosticDetails = getDiagnosticDetails; getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string { - if ( - issue.code === PlaybackDiagnosticCode.BrowserAccessError && - !this.runtime.supportsManagedExternalPlayers - ) { - return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'; - } - - return `${this.getDiagnosticTranslationBase(issue)}.DESCRIPTION`; - } - - getDiagnosticMeta(issue: PlaybackDiagnostic): string { - const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', '); - if (codecs) { - return codecs; - } - - return issue.container || issue.mimeType || ''; - } - - getDiagnosticCodecHint(issue: PlaybackDiagnostic): string { - return getLikelyBrowserUnsupportedCodecLabels(issue).join(', '); - } - - getDiagnosticDetails( - issue: PlaybackDiagnostic - ): readonly PlaybackDiagnosticDetail[] { - return [ - { - labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE', - value: issue.code, - }, - { - labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER', - value: this.formatPlayer(issue.player), - }, - { - labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE', - value: this.formatDiagnosticSource(issue.source), - }, - { - labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER', - value: issue.container, - }, - { - labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_MIME_TYPE', - value: issue.mimeType ?? '', - }, - { - labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS', - value: issue.videoCodecs.join(', '), - }, - { - labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS', - value: issue.audioCodecs.join(', '), - }, - { - labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_CODE', - value: issue.nativeErrorCode?.toString() ?? '', - }, - { - labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE', - value: issue.nativeErrorMessage ?? '', - }, - { - labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS', - value: issue.details ?? '', - }, - ].filter(({ value }) => value.trim().length > 0); - } - - private getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string { - switch (issue.code) { - case PlaybackDiagnosticCode.UnsupportedContainer: - return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER'; - case PlaybackDiagnosticCode.UnsupportedCodec: - return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC'; - case PlaybackDiagnosticCode.MediaDecodeError: - return 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR'; - case PlaybackDiagnosticCode.NetworkError: - return 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR'; - case PlaybackDiagnosticCode.BrowserAccessError: - return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR'; - case PlaybackDiagnosticCode.DrmOrEncryption: - return 'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION'; - case PlaybackDiagnosticCode.UnknownPlaybackError: - default: - return 'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR'; - } + return getDiagnosticDescriptionKey( + issue, + this.runtime.supportsManagedExternalPlayers + ); } private getHeaderValue( @@ -367,34 +302,4 @@ export class WebPlayerViewComponent { ); return matchingKey ? headers[matchingKey] : undefined; } - - private formatPlayer(player: PlaybackDiagnostic['player']): string { - switch (player) { - case 'videojs': - return 'Video.js'; - case 'html5': - return 'HTML5'; - case 'artplayer': - return 'ArtPlayer'; - default: - return ''; - } - } - - private formatDiagnosticSource( - source: PlaybackDiagnostic['source'] - ): string { - switch (source) { - case 'hls': - return 'HLS.js'; - case 'mpegts': - return 'mpegts.js'; - case 'native': - return 'Native media element'; - case 'source': - return 'Stream metadata'; - default: - return source; - } - } }