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/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index bd0abee32..7ddc41e6e 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -12,11 +12,310 @@ on: workflow_dispatch: jobs: - build: + linux-embedded-mpv-runtime: + 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 + 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 + 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: include: # macOS builds - separate runners to avoid native module conflicts @@ -32,26 +331,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 +355,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. @@ -107,18 +406,26 @@ jobs: TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }} run: node tools/tmdb/inject-tmdb-key.mjs + - name: Inject build commit + # Shows " ()" in Settings > About so bug reports + # from test builds identify the exact commit. PR builds use the + # head SHA — github.sha would be the ephemeral merge commit. + env: + BUILD_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + run: node tools/build/inject-build-commit.mjs + - name: Build frontend run: pnpm nx build web --skip-nx-cache - 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 +493,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 +506,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 +536,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 +561,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 +602,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 +830,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 +841,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 +1097,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 +1109,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 @@ -605,10 +1122,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" ' + 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' @@ -622,23 +1167,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,11 +1213,52 @@ 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 + 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: + 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 + concurrency: + group: ${{ github.workflow }}-release-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false permissions: contents: write @@ -797,13 +1391,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 @@ -813,15 +1511,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 @@ -830,30 +1529,57 @@ jobs: 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 + # 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: | - # 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 + 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/{}" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ed059fefc..45ebf9fd3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -109,5 +109,7 @@ jobs: push: ${{ steps.docker-meta.outputs.publish == 'true' }} tags: ${{ steps.docker-meta.outputs.tags }} platforms: ${{ steps.docker-meta.outputs.platforms }} + build-args: | + BUILD_COMMIT=${{ github.event.pull_request.head.sha || github.sha }} cache-from: type=gha cache-to: type=gha,mode=max,ignore-error=true 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..eabdfdcd4 100644 --- a/.gitignore +++ b/.gitignore @@ -84,4 +84,9 @@ 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 + +# MemPalace per-project files (issue #185) +mempalace.yaml +entities.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 69050bcfd..180f023c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,14 +70,12 @@ 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: - -```bash -IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend -``` +- 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. - If local Nx state gets weird before a rerun: @@ -134,6 +132,13 @@ Key files: engine-neutral `PlayerController` contract, standalone `app-player-controls`, generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. +- In fullscreen, `app-player-controls` shows a pointer-transparent media-title + overlay at the top while controls are revealed (`mediaTitle` input: + movie/channel/series name, plus an `S01E03` second line for episodes). Series + names flow from the Xtream/Stalker detail views through + `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`; + movie and live hosts fall back to `playback.title`, skipping raw stream-URL + fallbacks. Outside fullscreen the overlay stays hidden. - Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into @@ -222,6 +227,147 @@ 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 +- 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. +- 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` @@ -244,6 +390,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 b4f96b32f..78d0595ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,14 +127,12 @@ 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: - -```bash -IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend -``` +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. If the Nx daemon gets into a bad state before rerunning Electron: @@ -238,6 +236,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 @@ -573,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) @@ -629,9 +629,137 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use `libs/ui/playback/src/lib/shaka-engine/`; details in `docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback"). - 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"). -- 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/Shaka(DASH)/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/DASH(Shaka)/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/Shaka/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`. +- 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 + `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. 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`. + 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. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). 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/Shaka(DASH)/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/DASH(Shaka)/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/Shaka/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 `pictureInPictureActive`/`canPictureInPicture`, and command @@ -658,8 +786,10 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use **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. - 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. @@ -680,19 +810,20 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - XMLTV format support - Background parsing in worker thread - Stored in database for quick lookup +- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/` **TMDB Metadata Enrichment** (opt-in): - 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; 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 - 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` @@ -751,6 +882,9 @@ Build configurations in `apps/web/project.json`: **Factory Pattern Implementation**: The factory pattern ensures a single codebase works in both environments without conditional checks scattered throughout the application. All environment-specific logic is encapsulated in the service implementations. +**Build Commit In About**: +CI injects the git commit into `apps/web/src/environments/build-commit.ts` via `tools/build/inject-build-commit.mjs` (same placeholder pattern as the TMDB key inject); `Settings > About` then shows `" ()"`. The semver version itself deliberately stays untouched — a `-sha` suffix would flip electron-updater into prerelease mode and leak into installer/artifact version fields. Local/dev builds keep the placeholder empty and show the plain version. + ### Testing Strategy - **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks` 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/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/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-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index c0439b37f..8b4198ab4 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, @@ -1464,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/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-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/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/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 43c7a2de7..771a48359 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) => @@ -832,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/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/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/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/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/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/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/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 63db5b36e..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,5 +1,8 @@ import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm'; -import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + EpgChannelMetadata, + EpgProgram, +} from '@iptvnator/shared/interfaces'; import { getDatabase } from '../database/connection'; import * as schema from '../database/schema'; @@ -36,13 +39,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, @@ -57,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( @@ -90,9 +99,7 @@ export class EpgQueryService { } if (results.length > 0) { - return results - .map(this.transformDbRowToEpgProgram) - .filter(this.isValidEpgProgram); + return this.toEpgPrograms(results); } } @@ -142,9 +149,7 @@ export class EpgQueryService { ); } - return results - .map(this.transformDbRowToEpgProgram) - .filter(this.isValidEpgProgram); + return this.toEpgPrograms(results); } return []; @@ -541,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); } @@ -564,6 +576,11 @@ export class EpgQueryService { { legacyOnly: true } ) ) + .groupBy( + schema.epgPrograms.channelId, + schema.epgPrograms.start, + schema.epgPrograms.title + ) .orderBy(schema.epgPrograms.start) .limit(500); } @@ -595,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); } @@ -883,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, @@ -905,6 +950,75 @@ 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 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({ + epgChannelId: schema.epgChannelMappings.epgChannelId, + }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .innerJoin( + schema.epgChannelMappings, + eq( + schema.epgChannelMappings.channelKey, + sql`'xtream:' || ${schema.categories.playlistId} || ':' || ${schema.content.xtreamId}` + ) + ) + .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; + } 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..1f2977a66 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,47 @@ 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.groupBy = 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 })); @@ -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/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/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/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-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..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 @@ -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(), @@ -35,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', () => ({ @@ -138,10 +153,21 @@ 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(); mainWindowWebContentsOnMock.mockReset(); + mainWindowGetZoomFactorMock.mockReset(); + mainWindowGetZoomFactorMock.mockReturnValue(1); + screenGetDisplayMatchingMock.mockReset(); + screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1 }); appMock.isPackaged = true; tempDirs = []; @@ -230,14 +256,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 +283,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 +307,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 +327,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 +347,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 +360,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 +375,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 +388,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 +425,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 +449,97 @@ 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'); + }); + }); + + 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'); }); }); @@ -455,9 +567,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..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,12 +27,15 @@ 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, 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 +115,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 +131,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 +139,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 +173,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 +190,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()) { @@ -184,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 { @@ -229,7 +277,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 +297,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: 'frame-copy', - frameCopyAvailable: true, + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } @@ -261,7 +309,7 @@ export class EmbeddedMpvNativeService { supported: false, platform: process.platform, reason: missingLinuxMpvExecutableReason, - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), }; } @@ -279,7 +327,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: this.getActiveEngine(), - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } catch (error) { @@ -345,7 +393,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: this.getActiveEngine(), - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } catch (error) { @@ -377,7 +425,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: this.getActiveEngine(), - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } catch (error) { @@ -402,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 ); @@ -461,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 { @@ -709,8 +764,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/app/workers/epg-database.spec.ts b/apps/electron-backend/src/app/workers/epg-database.spec.ts index d448e47f7..d8c5519c2 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); @@ -43,7 +61,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 +80,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 +95,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( @@ -101,6 +128,95 @@ 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_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, source_url) DO UPDATE SET' + ); + expect(insertProgramSql).not.toContain( + 'source_url = excluded.source_url' + ); + }); + + 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, source_url) 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 6123619c0..3865b4bad 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()); @@ -30,14 +30,33 @@ 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. 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. + const dedupIndexReady = this.ensureProgramDedupIndex(); - this.deleteProgramsForSourceStmt = this.db.prepare(` - DELETE FROM epg_programs WHERE source_url = ? - `); + 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, 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` + : `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 @@ -48,21 +67,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(); } @@ -131,6 +157,57 @@ export class EpgDatabase { close(): void { this.db.close(); } + + /** + * 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. + */ + private ensureProgramDedupIndex(): boolean { + try { + const exists = this.db + .prepare( + `SELECT 1 FROM sqlite_master + 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, source_url + )` + ) + .run(); + this.db + .prepare( + `CREATE UNIQUE INDEX idx_epg_programs_dedup_v2 + ON epg_programs(channel_id, start, title, source_url)` + ) + .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/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/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index 68a71617e..8a299a823 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`) | @@ -125,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/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/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/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/apps/web/src/app/settings/settings-about-section.component.html b/apps/web/src/app/settings/settings-about-section.component.html index 184672bcc..c5ab3ddaf 100644 --- a/apps/web/src/app/settings/settings-about-section.component.html +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -19,7 +19,17 @@

{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}

- {{ version() }} + + {{ version() }} + @if (buildCommitShort(); as commit) { + ({{ commit }}) + } + {{ updateMessage() }}
diff --git a/apps/web/src/app/settings/settings-about-section.component.spec.ts b/apps/web/src/app/settings/settings-about-section.component.spec.ts index 2ca97c487..ce5c87265 100644 --- a/apps/web/src/app/settings/settings-about-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-about-section.component.spec.ts @@ -132,3 +132,60 @@ describe('SettingsAboutSectionComponent app updates', () => { expect(getButton(fixture, 'app-update-open-release')).toBeTruthy(); }); }); + +describe('SettingsAboutSectionComponent version display', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + SettingsAboutSectionComponent, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SettingsAboutSectionComponent); + fixture.componentRef.setInput('activeSection', 'about'); + fixture.componentRef.setInput('version', '0.23.0'); + }); + + function getVersionBlock() { + return fixture.nativeElement.querySelector( + '[data-test-id="app-version"]' + ) as HTMLElement; + } + + function getCommitMarker() { + return fixture.nativeElement.querySelector( + '[data-test-id="app-build-commit"]' + ) as HTMLElement | null; + } + + it('shows the shortened build commit next to the version on CI builds', () => { + fixture.componentRef.setInput( + 'buildCommit', + '949ea520aa11bb22cc33dd44ee55ff6677889900' + ); + fixture.detectChanges(); + + expect(getVersionBlock().textContent).toContain('0.23.0'); + expect(getCommitMarker()?.textContent).toBe('(949ea52)'); + expect(getCommitMarker()?.getAttribute('title')).toBe( + '949ea520aa11bb22cc33dd44ee55ff6677889900' + ); + }); + + it('shows the plain version when no build commit was injected', () => { + fixture.detectChanges(); + + expect(getVersionBlock().textContent).toContain('0.23.0'); + expect(getCommitMarker()).toBeNull(); + }); + + it('treats a whitespace-only commit as absent', () => { + fixture.componentRef.setInput('buildCommit', ' '); + fixture.detectChanges(); + + expect(getCommitMarker()).toBeNull(); + }); +}); diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index ed510ab46..eb02ce4e7 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -18,15 +18,25 @@ import { imports: [MatButtonModule, MatIconModule, TranslateModule], templateUrl: './settings-about-section.component.html', encapsulation: ViewEncapsulation.None, - styles: [':host { display: contents; }'], + styles: [ + ':host { display: contents; }', + '.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }', + ], }) export class SettingsAboutSectionComponent { readonly activeSection = input.required(); readonly isDesktop = input(false); readonly version = input(); + readonly buildCommit = input(); readonly updateMessage = input(); readonly appUpdateStatus = input(null); + readonly buildCommitShort = computed(() => { + const commit = this.buildCommit()?.trim(); + + return commit ? commit.slice(0, 7) : undefined; + }); + readonly checkForAppUpdate = output(); readonly downloadAppUpdate = output(); readonly installAppUpdate = output(); 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.html b/apps/web/src/app/settings/settings.component.html index c1bbd7758..0ef375d3d 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -113,6 +113,7 @@ [activeSection]="activeSection()" [isDesktop]="isDesktop" [version]="version" + [buildCommit]="buildCommit" [updateMessage]="updateMessage" [appUpdateStatus]="appUpdateStatus()" (checkForAppUpdate)="checkForAppUpdate()" 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/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 2b67bf278..258e2a1f4 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -45,6 +45,7 @@ import { Theme, VideoPlayer, } from '@iptvnator/shared/interfaces'; +import { BUILD_COMMIT } from '../../environments/build-commit'; import { SettingsStore } from '../services/settings-store.service'; import { SettingsService } from './../services/settings.service'; import { SettingsAboutSectionComponent } from './settings-about-section.component'; @@ -200,6 +201,9 @@ export class SettingsComponent implements OnInit, OnDestroy { /** Current version of the app */ version = ''; + /** Git commit the app was built from (CI builds only) */ + readonly buildCommit = BUILD_COMMIT; + /** Update message to show */ updateMessage = ''; readonly appUpdateStatus = signal( diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 694708e82..771be4a7c 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": "اختر السمة البصرية للتطبيق", @@ -453,6 +455,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "رجوع", "RETRY": "إعادة المحاولة", "STALLED": "يستغرق بدء هذا البث وقتًا أطول من المتوقع.", "PLAYBACK_FAILED": "فشل التشغيل عبر MPV المضمّن.", @@ -569,6 +572,7 @@ "REMOVE_FAVORITE": "إزالة من المفضلة", "ADD_FAVORITE": "إضافة إلى المفضلة", "SHOW_DETAILS": "عرض تفاصيل القناة", + "MAP_EPG": "ربط قناة دليل البرامج", "FAVORITES_UPDATED": "تم تحديث المفضلة!", "SELECT_CHANNEL_PLAYBACK": "يرجى اختيار قناة لبدء التشغيل", "SORT_BY": "ترتيب حسب", @@ -714,6 +718,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": "العربية (اللهجة المغربية)", @@ -766,6 +785,8 @@ "ACTORS": "الممثلون", "DIRECTOR": "المخرج", "SIMILAR": "مشابه", + "CREW_JOB_DIRECTOR": "المخرج", + "CREW_JOB_CREATOR": "المبتكر", "ACTOR_FILMOGRAPHY": "الأعمال الفنية", "ACTOR_FILTER_ALL": "الكل", "ACTOR_SCOPE_THIS_PORTAL": "هذه البوابة", @@ -951,6 +972,11 @@ "CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم", "PLAYBACK_ERROR": "فشل الحصول على رابط التشغيل", "LOADING_SEASONS": "جارٍ تحميل المواسم...", + "LOADING_ALL_CHANNELS": "جارٍ تحميل قائمة القنوات الكاملة…", + "REFRESH_CHANNEL_LIST": "تحديث قائمة القنوات", + "NO_CHANNELS_IN_CATEGORY": "لا توجد قنوات في هذه الفئة", + "ITEM": "عنصر", + "ITEMS": "عناصر", "NO_EPISODES": "لا توجد حلقات متاحة", "ALL_RADIO": "كل الراديو" }, @@ -978,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "لم يعد بالإمكان فتح هذا التنزيل في المكتبة لأنه تمت إزالة قائمة التشغيل المصدر.", "FILE_NOT_FOUND": "هذا الملف الذي تم تنزيله لم يعد متاحًا على القرص.", "FILE_ACTION_ERROR": "تعذر إكمال الإجراء على الملف.", + "ACTION_FAILED": "فشل الإجراء", + "PAUSE": "إيقاف مؤقت", + "RESUME": "استئناف", "CANCEL": "إلغاء", "RETRY": "إعادة المحاولة", "REMOVE": "إزالة", @@ -991,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 aa115e6ed..3f867dc10 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": "اختار المظهر المرئي للتطبيق", @@ -453,6 +455,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "رجوع", "RETRY": "حاول مرة أخرى", "STALLED": "هاد البث كياخد وقت كثر من المتوقع باش يبدا.", "PLAYBACK_FAILED": "فشل التشغيل بـ MPV المدمج.", @@ -569,6 +572,7 @@ "REMOVE_FAVORITE": "حيد من المفضلة", "ADD_FAVORITE": "زيد للمفضلة", "SHOW_DETAILS": "وري تفاصيل القناة", + "MAP_EPG": "ربط قناة ديال EPG", "FAVORITES_UPDATED": "المفضلة تحدثات!", "SELECT_CHANNEL_PLAYBACK": "عفاك اختار قناة باش يبدا التشغيل", "SORT_BY": "رتب بـ", @@ -714,6 +718,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": "العربية (اللهجة المغربية)", @@ -766,6 +785,8 @@ "ACTORS": "الممثلون", "DIRECTOR": "المخرج", "SIMILAR": "بحال هادو", + "CREW_JOB_DIRECTOR": "المخرج", + "CREW_JOB_CREATOR": "المبتكر", "ACTOR_FILMOGRAPHY": "الأفلام ديالو", "ACTOR_FILTER_ALL": "كلشي", "ACTOR_SCOPE_THIS_PORTAL": "هاد البوابة", @@ -951,6 +972,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" }, @@ -978,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "هاد التحميل ما يقدرش يتحل في المكتبة حيت قائمة التشغيل المصدر تحيدات.", "FILE_NOT_FOUND": "هاد الملف المحمل ما بقاش متوفر على القرص.", "FILE_ACTION_ERROR": "العملية على الملف ما تكملاتش.", + "ACTION_FAILED": "العملية ما نجحاتش", + "PAUSE": "وقّف مؤقتاً", + "RESUME": "كمّل", "CANCEL": "إلغاء", "RETRY": "حاول مرة أخرى", "REMOVE": "حيد", @@ -991,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 4c086d4f8..2951c8474 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": "Выбраць візуальную тэму афармлення", @@ -453,6 +455,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Назад", "RETRY": "Паўтарыць", "STALLED": "Гэты паток запускаецца даўжэй, чым чакалася.", "PLAYBACK_FAILED": "Не ўдалося прайграць праз убудаваны MPV.", @@ -569,6 +572,7 @@ "REMOVE_FAVORITE": "Выдаліць са спісу фаварытаў", "ADD_FAVORITE": "Дадаць у выбранае", "SHOW_DETAILS": "Паказаць інфармацыю аб канале", + "MAP_EPG": "Прывязаць канал EPG", "FAVORITES_UPDATED": "Спіс фаварытаў быў абноўлены!", "SELECT_CHANNEL_PLAYBACK": "Калі ласка, выберыце канал, каб пачаць прайграванне", "SORT_BY": "Сартаваць па", @@ -714,6 +718,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": "العربية (اللهجة المغربية)", @@ -766,6 +785,8 @@ "ACTORS": "Акцёры", "DIRECTOR": "Рэжысёр", "SIMILAR": "Падобнае", + "CREW_JOB_DIRECTOR": "Рэжысёр", + "CREW_JOB_CREATOR": "Стваральнік", "ACTOR_FILMOGRAPHY": "Фільмаграфія", "ACTOR_FILTER_ALL": "Усе", "ACTOR_SCOPE_THIS_PORTAL": "Гэты партал", @@ -951,6 +972,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": "Усе радыё" }, @@ -978,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Гэта спампаванне больш нельга адкрыць у бібліятэцы, бо яго зыходны плэйліст быў выдалены.", "FILE_NOT_FOUND": "Гэты спампаваны файл больш недаступны на дыску.", "FILE_ACTION_ERROR": "Не ўдалося выканаць дзеянне з файлам.", + "ACTION_FAILED": "Дзеянне не выканана", + "PAUSE": "Паўза", + "RESUME": "Узнавіць", "CANCEL": "Скасаваць", "RETRY": "Паўтарыць", "REMOVE": "Выдаліць", @@ -991,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 cfc44846a..860ad97ce 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", @@ -453,6 +455,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.", @@ -569,6 +572,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 +718,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": "العربية (اللهجة المغربية)", @@ -766,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", @@ -951,6 +972,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" }, @@ -978,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", @@ -991,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 0f430fa8c..b7100e1ec 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": "Επιλέξτε οπτικό θέμα της εφαρμογής", @@ -453,6 +455,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Πίσω", "RETRY": "Επανάληψη", "STALLED": "Η έναρξη αυτής της ροής καθυστερεί περισσότερο από το αναμενόμενο.", "PLAYBACK_FAILED": "Η αναπαραγωγή με το ενσωματωμένο MPV απέτυχε.", @@ -569,6 +572,7 @@ "REMOVE_FAVORITE": "Αφαίρεση από τα αγαπημένα", "ADD_FAVORITE": "Προσθήκη στα αγαπημένα", "SHOW_DETAILS": "Εμφάνιση λεπτομερειών καναλιού", + "MAP_EPG": "Αντιστοίχιση καναλιού EPG", "FAVORITES_UPDATED": "Τα αγαπημένα ενημερώθηκαν!", "SELECT_CHANNEL_PLAYBACK": "Επιλέξτε ένα κανάλι για να ξεκινήσει η αναπαραγωγή", "SORT_BY": "Ταξινόμηση κατά", @@ -714,6 +718,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": "العربية (اللهجة المغربية)", @@ -766,6 +785,8 @@ "ACTORS": "Ηθοποιοί", "DIRECTOR": "Σκηνοθέτης", "SIMILAR": "Παρόμοια", + "CREW_JOB_DIRECTOR": "Σκηνοθέτης", + "CREW_JOB_CREATOR": "Δημιουργός", "ACTOR_FILMOGRAPHY": "Φιλμογραφία", "ACTOR_FILTER_ALL": "Όλα", "ACTOR_SCOPE_THIS_PORTAL": "Αυτή η πύλη", @@ -951,6 +972,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": "Όλα τα ραδιόφωνα" }, @@ -978,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Αυτή η λήψη δεν μπορεί πλέον να ανοίξει στη βιβλιοθήκη επειδή η πηγαία λίστα αναπαραγωγής αφαιρέθηκε.", "FILE_NOT_FOUND": "Αυτό το ληφθέν αρχείο δεν είναι πλέον διαθέσιμο στον δίσκο.", "FILE_ACTION_ERROR": "Δεν ήταν δυνατή η ολοκλήρωση της ενέργειας αρχείου.", + "ACTION_FAILED": "Η ενέργεια απέτυχε", + "PAUSE": "Παύση", + "RESUME": "Συνέχιση", "CANCEL": "Ακύρωση", "RETRY": "Επανάληψη", "REMOVE": "Αφαίρεση", @@ -991,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 38321a321..05d7dc92f 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", @@ -453,6 +455,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Back", "RETRY": "Retry", "STALLED": "This stream is taking longer than expected to start.", "PLAYBACK_FAILED": "Embedded MPV playback failed.", @@ -569,6 +572,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 +718,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": "العربية (اللهجة المغربية)", @@ -766,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", @@ -951,6 +972,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" }, @@ -978,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", @@ -991,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 e4972fa64..133151e42 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.", @@ -453,6 +455,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.", @@ -569,6 +572,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 +718,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": "العربية (اللهجة المغربية)", @@ -766,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", @@ -951,6 +972,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" }, @@ -978,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", @@ -991,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 8a06f85be..28fe4e577 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", @@ -453,6 +455,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é.", @@ -569,6 +572,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 +718,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": "العربية (اللهجة المغربية)", @@ -766,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", @@ -951,6 +972,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" }, @@ -978,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", @@ -991,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 51b91c1f5..6c6825239 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", @@ -453,6 +455,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.", @@ -569,6 +572,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 +718,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": "العربية (اللهجة المغربية)", @@ -766,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", @@ -951,6 +972,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" }, @@ -978,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", @@ -991,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 3f5fe87f9..adf2fa981 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": "アプリケーションの視覚テーマを選択", @@ -453,6 +455,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "戻る", "RETRY": "再試行", "STALLED": "このストリームの開始に予想より時間がかかっています。", "PLAYBACK_FAILED": "組み込みMPVでの再生に失敗しました。", @@ -569,6 +572,7 @@ "REMOVE_FAVORITE": "お気に入りから削除", "ADD_FAVORITE": "お気に入りに追加", "SHOW_DETAILS": "チャンネルの詳細を表示", + "MAP_EPG": "EPGチャンネルを割り当て", "FAVORITES_UPDATED": "お気に入りが更新されました!", "SELECT_CHANNEL_PLAYBACK": "再生を開始するチャンネルを選択してください", "SORT_BY": "並び替え", @@ -714,6 +718,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": "العربية (اللهجة المغربية)", @@ -766,6 +785,8 @@ "ACTORS": "出演者", "DIRECTOR": "監督", "SIMILAR": "類似作品", + "CREW_JOB_DIRECTOR": "監督", + "CREW_JOB_CREATOR": "クリエイター", "ACTOR_FILMOGRAPHY": "出演作品", "ACTOR_FILTER_ALL": "すべて", "ACTOR_SCOPE_THIS_PORTAL": "このポータル", @@ -951,6 +972,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": "すべてのラジオ" }, @@ -978,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "ソースのプレイリストが削除されたため、このダウンロードはライブラリで開けなくなりました。", "FILE_NOT_FOUND": "このダウンロードファイルはディスク上で見つかりませんでした。", "FILE_ACTION_ERROR": "ファイル操作を完了できませんでした。", + "ACTION_FAILED": "操作に失敗しました", + "PAUSE": "一時停止", + "RESUME": "再開", "CANCEL": "キャンセル", "RETRY": "再試行", "REMOVE": "削除", @@ -991,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 04700d99d..05a9a4511 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": "애플리케이션의 시각적 테마 선택", @@ -453,6 +455,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "뒤로", "RETRY": "다시 시도", "STALLED": "이 스트림의 시작이 예상보다 오래 걸리고 있습니다.", "PLAYBACK_FAILED": "내장 MPV 재생에 실패했습니다.", @@ -569,6 +572,7 @@ "REMOVE_FAVORITE": "즐겨찾기에서 삭제하기", "ADD_FAVORITE": "즐겨찾기에 추가", "SHOW_DETAILS": "채널 세부정보 표시", + "MAP_EPG": "EPG 채널 매핑", "FAVORITES_UPDATED": "즐겨찾기가 업데이트되었습니다!", "SELECT_CHANNEL_PLAYBACK": "재생을 시작할 채널을 선택하세요", "SORT_BY": "정렬 기준", @@ -714,6 +718,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": "العربية (اللهجة المغربية)", @@ -766,6 +785,8 @@ "ACTORS": "배우", "DIRECTOR": "감독", "SIMILAR": "비슷한 콘텐츠", + "CREW_JOB_DIRECTOR": "감독", + "CREW_JOB_CREATOR": "크리에이터", "ACTOR_FILMOGRAPHY": "출연 작품", "ACTOR_FILTER_ALL": "전체", "ACTOR_SCOPE_THIS_PORTAL": "현재 포털", @@ -951,6 +972,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": "모든 라디오" }, @@ -978,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "원본 재생목록이 제거되어 이 다운로드 항목을 라이브러리에서 더 이상 열 수 없습니다.", "FILE_NOT_FOUND": "이 다운로드한 파일을 디스크에서 더 이상 사용할 수 없습니다.", "FILE_ACTION_ERROR": "파일 작업을 완료하지 못했습니다.", + "ACTION_FAILED": "작업 실패", + "PAUSE": "일시정지", + "RESUME": "재개", "CANCEL": "취소", "RETRY": "다시 시도", "REMOVE": "제거", @@ -991,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 eb2a99e17..3b67ccf84 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", @@ -453,6 +455,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.", @@ -569,6 +572,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 +718,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": "العربية (اللهجة المغربية)", @@ -766,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", @@ -951,6 +972,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" }, @@ -978,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", @@ -991,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 ff88234c3..7d1f64bc4 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", @@ -453,6 +455,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ę.", @@ -569,6 +572,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 +718,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": "العربية (اللهجة المغربية)", @@ -766,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", @@ -951,6 +972,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" }, @@ -978,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ń", @@ -991,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 585377af9..6afa19dbb 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", @@ -453,6 +455,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.", @@ -569,6 +572,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 +718,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": "العربية (اللهجة المغربية)", @@ -766,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", @@ -951,6 +972,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" }, @@ -978,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", @@ -991,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 027fe915a..ee2abbae2 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": "Выбрать визуальную тему оформления", @@ -453,6 +455,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Назад", "RETRY": "Повторить", "STALLED": "Запуск этого потока занимает больше времени, чем ожидалось.", "PLAYBACK_FAILED": "Не удалось воспроизвести через встроенный MPV.", @@ -569,6 +572,7 @@ "REMOVE_FAVORITE": "Удалить из списка фаворитов", "ADD_FAVORITE": "Добавить в избранное", "SHOW_DETAILS": "Показать сведения о канале", + "MAP_EPG": "Привязать канал EPG", "FAVORITES_UPDATED": "Список фаворитов был обновлен!", "SELECT_CHANNEL_PLAYBACK": "Пожалуйста, выберите канал для начала воспроизведения", "SORT_BY": "Сортировать по", @@ -714,6 +718,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": "العربية (اللهجة المغربية)", @@ -766,6 +785,8 @@ "ACTORS": "Актеры", "DIRECTOR": "Режиссёр", "SIMILAR": "Похожее", + "CREW_JOB_DIRECTOR": "Режиссёр", + "CREW_JOB_CREATOR": "Создатель", "ACTOR_FILMOGRAPHY": "Фильмография", "ACTOR_FILTER_ALL": "Все", "ACTOR_SCOPE_THIS_PORTAL": "Этот портал", @@ -951,6 +972,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": "Все радио" }, @@ -978,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "Эту загрузку больше нельзя открыть в библиотеке, потому что исходный плейлист был удален.", "FILE_NOT_FOUND": "Этот загруженный файл больше недоступен на диске.", "FILE_ACTION_ERROR": "Не удалось выполнить действие с файлом.", + "ACTION_FAILED": "Действие не выполнено", + "PAUSE": "Пауза", + "RESUME": "Возобновить", "CANCEL": "Отмена", "RETRY": "Повторить", "REMOVE": "Удалить", @@ -991,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 2a3d4a970..3d044c8d3 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", @@ -453,6 +455,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.", @@ -569,6 +572,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 +718,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": "العربية (اللهجة المغربية)", @@ -766,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", @@ -951,6 +972,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" }, @@ -978,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", @@ -991,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 2e831cabb..4e928c56f 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": "选择应用程序的视觉主题", @@ -453,6 +455,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "返回", "RETRY": "重试", "STALLED": "该流的启动时间超出预期。", "PLAYBACK_FAILED": "嵌入式 MPV 播放失败。", @@ -569,6 +572,7 @@ "REMOVE_FAVORITE": "从收藏夹中删除", "ADD_FAVORITE": "添加到收藏", "SHOW_DETAILS": "显示频道详情", + "MAP_EPG": "映射 EPG 频道", "FAVORITES_UPDATED": "收藏夹已更新!", "SELECT_CHANNEL_PLAYBACK": "请选择一个频道开始播放", "SORT_BY": "排序方式", @@ -714,6 +718,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": "العربية (اللهجة المغربية)", @@ -766,6 +785,8 @@ "ACTORS": "演员", "DIRECTOR": "导演", "SIMILAR": "类似影片", + "CREW_JOB_DIRECTOR": "导演", + "CREW_JOB_CREATOR": "主创", "ACTOR_FILMOGRAPHY": "参演作品", "ACTOR_FILTER_ALL": "全部", "ACTOR_SCOPE_THIS_PORTAL": "当前门户", @@ -951,6 +972,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": "所有广播" }, @@ -978,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "由于源播放列表已被移除,此下载项无法在库中打开。", "FILE_NOT_FOUND": "此下载文件已不在磁盘上。", "FILE_ACTION_ERROR": "无法完成此文件操作。", + "ACTION_FAILED": "操作失败", + "PAUSE": "暂停", + "RESUME": "继续", "CANCEL": "取消", "RETRY": "重试", "REMOVE": "移除", @@ -991,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 f7589cdca..c0914a0f0 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": "選擇應用程式視覺主題", @@ -453,6 +455,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "返回", "RETRY": "重試", "STALLED": "此串流的啟動時間比預期長。", "PLAYBACK_FAILED": "內嵌 MPV 播放失敗。", @@ -569,6 +572,7 @@ "REMOVE_FAVORITE": "從我的最愛中移除", "ADD_FAVORITE": "加入我的最愛", "SHOW_DETAILS": "顯示頻道詳細資訊", + "MAP_EPG": "對應 EPG 頻道", "FAVORITES_UPDATED": "我的最愛已更新!", "SELECT_CHANNEL_PLAYBACK": "請選擇一個頻道以開始播放", "SORT_BY": "排序依據", @@ -714,6 +718,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": "العربية (اللهجة المغربية)", @@ -766,6 +785,8 @@ "ACTORS": "演員", "DIRECTOR": "導演", "SIMILAR": "類似作品", + "CREW_JOB_DIRECTOR": "導演", + "CREW_JOB_CREATOR": "主創", "ACTOR_FILMOGRAPHY": "參演作品", "ACTOR_FILTER_ALL": "全部", "ACTOR_SCOPE_THIS_PORTAL": "目前入口", @@ -951,6 +972,11 @@ "CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容", "PLAYBACK_ERROR": "無法取得播放網址", "LOADING_SEASONS": "正在載入季數...", + "LOADING_ALL_CHANNELS": "正在載入完整頻道清單…", + "REFRESH_CHANNEL_LIST": "重新整理頻道清單", + "NO_CHANNELS_IN_CATEGORY": "此分類中沒有頻道", + "ITEM": "項", + "ITEMS": "項", "NO_EPISODES": "沒有可用的集數", "ALL_RADIO": "所有廣播" }, @@ -978,6 +1004,9 @@ "SOURCE_PLAYLIST_MISSING": "此下載項目無法在媒體庫中開啟,因為其來源播放清單已被移除。", "FILE_NOT_FOUND": "此下載檔案在磁碟上已不存在。", "FILE_ACTION_ERROR": "無法完成檔案操作。", + "ACTION_FAILED": "操作失敗", + "PAUSE": "暫停", + "RESUME": "繼續", "CANCEL": "取消", "RETRY": "重試", "REMOVE": "移除", @@ -991,7 +1020,8 @@ "DOWNLOADING": "下載中", "COMPLETED": "已完成", "FAILED": "失敗", - "CANCELED": "已取消" + "CANCELED": "已取消", + "PAUSED": "已暫停" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/environments/build-commit.ts b/apps/web/src/environments/build-commit.ts new file mode 100644 index 000000000..066dc233e --- /dev/null +++ b/apps/web/src/environments/build-commit.ts @@ -0,0 +1,10 @@ +/** + * Git commit the app was built from. Populated by + * tools/build/inject-build-commit.mjs during CI builds; stays empty for + * local/dev builds, in which case Settings > About shows the plain version. + * + * Intentionally separate from AppConfig.version: appending the SHA to the + * semver itself would flip electron-updater into prerelease mode and leak + * into installer/artifact version fields. + */ +export const BUILD_COMMIT = ''; diff --git a/docker/Dockerfile b/docker/Dockerfile index c00b232d9..53368c8c7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -15,6 +15,11 @@ RUN corepack enable && pnpm install --frozen-lockfile --ignore-scripts COPY . . +# CI passes the git commit so Settings > About shows " ()"; +# the inject script is a no-op when BUILD_COMMIT is empty (local builds). +ARG BUILD_COMMIT="" +RUN node tools/build/inject-build-commit.mjs + RUN pnpm nx build web --configuration=pwa RUN pnpm nx build web-backend 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..3217951e6 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -1,33 +1,39 @@ # 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()`. -- **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` 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`. - 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. + 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 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 @@ -37,12 +43,19 @@ 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 -- 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/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 47332c781..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 @@ -188,6 +192,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/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 6dbb15a33..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 @@ -264,6 +287,13 @@ The diagnostic surface covers the inline player viewport when playback fails, wi URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present. +MKV sources are attempted through Chromium's native Matroska path. Video.js +receives `video/matroska` for `.mkv` URLs and explicit query metadata such as +`extension=mkv` or `container=mkv`; ArtPlayer and HTML5 continue to use their +native video paths. This is container support rather than a universal codec +guarantee: native source or decode failures still produce the existing +diagnostic and explicit MPV/VLC fallback. + Portal VOD and episode payloads with `contentInfo` are treated as non-live by the inline players unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. Embedded MPV uses the same live decision rule and shows an unknown duration placeholder for VOD/episode snapshots until MPV reports a finite duration. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior. When a diagnostic is actionable in Electron, the diagnostic surface may offer `Open in MPV`, `Open in VLC`, `Copy URL`, technical details, and `Retry`. Web builds only expose copy/help text and retry. MPV/VLC fallback requests carry the original `ResolvedPortalPlayback` payload so headers, referer, origin, user-agent, content metadata, and resume offset stay intact. Retry clears the current diagnostic and rebuilds the active inline player inputs; it does not change the saved player setting. diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index f664f4945..c143db405 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: @@ -94,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 @@ -117,7 +174,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. @@ -143,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 @@ -180,7 +247,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, @@ -193,20 +260,128 @@ 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 -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 +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 +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 +407,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 +424,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 @@ -362,7 +535,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 @@ -383,7 +556,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 @@ -391,9 +566,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. @@ -407,24 +594,71 @@ 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. +### 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 @@ -491,39 +725,84 @@ 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. +- 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 + 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 +826,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 +944,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 +1037,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 +1049,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/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 75d49616a..9d41014be 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -42,36 +42,41 @@ 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 ```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' } ``` @@ -101,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 @@ -121,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 @@ -222,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 } ``` @@ -341,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 / @@ -363,7 +376,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. @@ -543,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 @@ -791,16 +805,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 24355ce34..19468c508 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -225,9 +225,29 @@ It owns only transient presentation behavior: - `ControlsVolume` — persisted/optimistic volume state reconciled from controller state; - `ControlsShortcuts` — document keyboard routing; -- `ControlsSurface` — pointer/click/double-click surface interactions; and +- `ControlsSurface` — pointer/click/double-click surface interactions; +- `ControlsTimeline` — scrub state and timeline projections; and - `controls-view-model.ts` — derived display state. +### Fullscreen media title + +The component accepts an optional `mediaTitle` input +(`PlayerMediaTitle { primary, secondary? }`) with display-ready strings — the +movie title, channel name, or series name, plus an optional second line such +as the `S01E03` episode label. The overlay renders at the top of the player +only in fullscreen while the controls are revealed, follows the same +auto-hide transition as the bottom bar, and is pointer-transparent. Outside +fullscreen the surrounding page chrome already names the content, so the +overlay stays hidden. + +Hosts supply the value: `WebPlayerViewComponent` derives a single-line title +from the resolved playback (skipping raw stream-URL fallbacks) unless its own +`mediaTitle` input was set, and `PortalInlinePlayerComponent` builds the +two-line series form from its `seriesTitle` input plus the episode metadata +label. The Xtream and Stalker series detail views pass the series name via +`seriesTitle`; movie and live hosts need no extra wiring because +`playback.title` already names the content. + ### Keyboard ownership Unmodified Space/K, F, arrow keys, and M are playback shortcuts. Playback keys @@ -506,8 +526,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 @@ -634,6 +655,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..0e2a3705b 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 @@ -241,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`. @@ -675,11 +697,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-epg.md b/docs/architecture/stalker-epg.md index 80bf997fe..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`. @@ -263,6 +273,29 @@ advantage of the richer bulk API when it is available. Row previews do not fallback to per-channel requests in this mode; they remain empty until bulk EPG is available. +## Manual EPG Mapping + +Stalker channels carry no XMLTV identifier, so when the portal's own EPG is +missing or wrong the only uploaded-EPG entry point is a **manual mapping**: +right-click a channel in the ITV sidebar (or in global favorites) and pick +"Map EPG channel" to attach it to a channel from an uploaded XMLTV guide. + +- Mappings are stored in the shared `epg_channel_mappings` table under the + playlist-scoped key `stalker:{playlistId}:{channelId}` + (`buildStalkerEpgMappingKey` in + `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`). +- `withStalkerEpg().applyMappedItvEpg(channelIds)` batch-resolves mappings + (one `getEpgMappingsBatch` IPC per new id set) and overlays the mapped + XMLTV programs onto `bulkItvEpgByChannel`, so both the active panel and + the row previews pick them up with no template changes. Overrides are + re-merged whenever `ensureBulkItvEpg` replaces the bulk record and are + re-checked after the mapping dialog closes with a change. +- The collection views (global favorites/recent) resolve the same keys in + `StreamResolverService` (`loadStalkerEpgItems` for the detail panel, + `loadStalkerEpgBatch` + `prefetchEpgMappings` for row previews). +- Everything is gated behind `supportsEpgMapping`, so the PWA never shows + the menu entry. + ## Future Enhancements - add cache refresh / invalidation for long-running live sessions 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 42b3f4b20..c75d97f4f 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -29,52 +29,56 @@ 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` +- `/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 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`. -5. Axios calls Stalker `load.php` API with required headers/cookies and returns normalized payloads to renderer. +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 the raw `response.data` to the renderer; normalization happens in the store feature slices. ## Main UI Components -- `/Users/4gray/Code/iptvnator/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` +- `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 -- `/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-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 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: - 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 @@ -140,6 +144,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. @@ -155,6 +162,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: @@ -185,6 +293,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 +305,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 +343,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-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: @@ -264,7 +394,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: @@ -279,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 @@ -299,9 +429,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 +443,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/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index cac28e6ba..5c8b322fb 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: @@ -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 @@ -101,7 +104,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 @@ -173,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. @@ -187,7 +213,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: @@ -215,8 +245,8 @@ 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 + lookup_key 'id:|v2' -- details payload 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 +259,15 @@ tmdb_metadata ( TTLs (enforced at read time in `TmdbCacheService.isFresh`): details and positive matches 30 days, negative matches 7 days. +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. + Electron IPC path (follows the standard DB worker contract, see [SQLite DB Worker](./sqlite-db-worker.md)): diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 913421a86..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 @@ -93,18 +108,23 @@ 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 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 @@ -126,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 @@ -158,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 @@ -185,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 @@ -206,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` 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/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/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/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/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-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..096e50531 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md @@ -0,0 +1,269 @@ +# 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. + +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` +- 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` 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 +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/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/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/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/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/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/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') { + } @if (contextMenuChannel()?.m3uChannel) { + } } - @if (!stalkerStore.selectedCategoryId()) { - + @if (!stalkerStore.selectedItem() && !stalkerStore.selectedCategoryId()) { + @if (showItvAllItems()) { + + + } @else { + + } } @else if (stalkerStore.selectedItem()) { @if (streamUrl() && (isEmbeddedPlayer || isRadioMode())) {
@@ -206,3 +256,19 @@ }
+ + + + + + 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..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; @@ -63,3 +93,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..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'; @@ -25,6 +32,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 +50,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({ @@ -134,7 +145,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { }); const selectedCategoryId = signal('1001'); const searchPhrase = signal(''); - const itvChannels = signal([ + const defaultItvChannels = () => [ { id: '10001', cmd: 'ffrt4://itv/10001', @@ -149,7 +160,8 @@ describe('StalkerLiveStreamLayoutComponent', () => { o_name: 'Beta TV', logo: 'beta.png', }, - ]); + ]; + const itvChannels = signal(defaultItvChannels()); const radioChannels = signal([ { id: 'radio-1', @@ -182,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'), @@ -189,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, @@ -215,6 +246,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); @@ -255,6 +287,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { resolveItvPlayback = stalkerStore.resolveItvPlayback; resolveRadioPlayback = stalkerStore.resolveRadioPlayback; + itvChannels.set(defaultItvChannels()); selectedCategoryId.set('1001'); searchPhrase.set(''); stalkerStore.selectedContentType.set('itv'); @@ -268,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); @@ -326,6 +366,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { get isElectron() { return Boolean(window.electron); }, + get supportsEpgMapping() { + return false; + }, }, }, { provide: PlaylistsService, useValue: playlistService }, @@ -335,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(), }, }, { @@ -343,6 +393,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, { @@ -621,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 @@ -635,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(); }); @@ -660,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 e50766d38..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, @@ -15,7 +16,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 +26,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { ChannelListItemComponent, ChannelListSkeletonComponent, + EpgMappingDialogComponent, ResizableDirective, } from '@iptvnator/ui/components'; import { @@ -31,6 +35,7 @@ import { SettingsStore, } from '@iptvnator/services'; import { + buildStalkerEpgMappingKey, Channel, EpgItem, EpgProgram, @@ -50,6 +55,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, @@ -68,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', @@ -86,11 +96,13 @@ type StalkerPlayableChannel = StalkerPortalItem & { EpgTimelineComponent, MatButtonModule, MatIconModule, + MatMenuModule, MatProgressSpinnerModule, MatTooltipModule, NgTemplateOutlet, PortalEmptyStateComponent, ResizableDirective, + StalkerItvAllItemsComponent, TranslatePipe, WebPlayerViewComponent, ], @@ -99,6 +111,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); @@ -122,33 +136,128 @@ 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; 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() ); @@ -276,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); @@ -311,6 +439,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(() => { @@ -326,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(); @@ -342,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({ @@ -412,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(); @@ -501,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); @@ -544,6 +744,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([]); @@ -815,7 +1103,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { return; } - const channels = this.visibleChannels(); + const channels = this.filteredChannels(); const nextItem = getAdjacentChannelItem( channels, activeItem.id, @@ -843,7 +1131,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } const channel = getChannelItemByNumber( - this.visibleChannels(), + this.filteredChannels(), command.number ); if (!channel) { 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-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.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 3b90c38b0..5f2f152cf 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 }} + }
} @@ -104,7 +135,10 @@
- {{ action.labelKey | translate }} + {{ + action.labelKey + | translate: action.labelParams + }} @if (action.episodeLabel) { @@ -122,6 +156,7 @@ detail-player [playback]="playback" [episodeMetadata]="inlineEpisodeMetadata()" + [seriesTitle]="serial.info.name" [seriesNavigation]="inlineSeriesNavigation()" (timeUpdate)="handleInlineTimeUpdate($event)" (closed)="closeInlinePlayer()" 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..d7ef8a0b5 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 @@ -53,6 +53,7 @@ class StubSeasonContainerComponent { class StubPortalInlinePlayerComponent { readonly playback = input(null); readonly episodeMetadata = input(null); + readonly seriesTitle = input(null); readonly seriesNavigation = input(null); readonly timeUpdate = output(); readonly closed = output(); @@ -79,6 +80,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 +108,7 @@ describe('StalkerSeriesViewComponent', () => { }, ]); vodSeriesSeasonsResource.set([]); + isSerialSeasonsLoading.set(false); fetchVodSeriesEpisodes.mockReset(); resolveVodPlayback.mockReset(); resolveVodPlayback.mockImplementation( @@ -156,7 +159,7 @@ describe('StalkerSeriesViewComponent', () => { getVodSeriesSeasonsResource: () => vodSeriesSeasonsResource(), isVodSeriesSeasonsLoading: signal(false), - isSerialSeasonsLoading: signal(false), + isSerialSeasonsLoading, fetchVodSeriesEpisodes, resolveVodPlayback, fetchLinkToPlay: jest.fn(), @@ -241,7 +244,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 +303,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 +425,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 +625,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', @@ -727,4 +819,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 5e7cb5da8..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, + } ) ); } @@ -687,15 +725,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/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 0b067b705..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 @@ -1,6 +1,9 @@ import { inject, Injectable, OnDestroy } from '@angular/core'; import { Subject } from 'rxjs'; -import { EpgItem } from '@iptvnator/shared/interfaces'; +import { + buildXtreamEpgMappingKey, + EpgItem, +} from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; import { XtreamApiService, XtreamCredentials } from './xtream-api.service'; import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service'; @@ -19,6 +22,8 @@ interface CacheEntry { export interface EpgQueueEntry { streamId: number; epgChannelId?: string | null; + /** Owning playlist — required to resolve manual EPG mappings. */ + playlistId?: string | null; } /** @@ -50,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(); @@ -75,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) { @@ -114,6 +144,17 @@ export class EpgQueueService implements OnDestroy { typeof entry === 'number' ? { streamId: entry } : { ...entry } ); + // Resolve manual EPG mappings before building the per-EPG-id index. + // When the user has right-clicked a channel and created a mapping, + // the stored key is the playlist-scoped Xtream key, not the + // provider's epg_channel_id. By resolving upfront we get the + // correct EPG channel ID for the XMLTV batch call that follows. + // Guarded so environments without the bridge (PWA) skip the await + // entirely and the enqueue keeps its original microtask timing. + if (typeof window.electron?.getEpgMappingsBatch === 'function') { + await this.resolveManualMappings(normalized); + } + const streamsByEpgId = new Map(); for (const entry of normalized) { const id = entry.epgChannelId?.trim(); @@ -192,6 +233,62 @@ export class EpgQueueService implements OnDestroy { return this.fallbackService.getCurrentProgramsBatch(epgChannelIds); } + /** + * Resolve manual EPG mappings for the queued entries. + * + * The user may have opened the mapping dialog (right-click → "Map EPG") + * from any channel list; the stored key is the playlist-scoped Xtream + * key, which does not match the provider's epg_channel_id, so the batch + * IPC handler's resolveChannelIds() would miss it. We resolve here, + * upfront, so the XMLTV batch call later uses the *mapped* epgChannelId + * — the actual EPG channel that carries the XMLTV data. A mapping also + * supplies an epgChannelId to entries whose provider did not send one. + */ + private async resolveManualMappings( + entries: EpgQueueEntry[] + ): Promise { + const getEpgMappingsBatch = + typeof window.electron?.getEpgMappingsBatch === 'function' + ? window.electron.getEpgMappingsBatch + : null; + if (!getEpgMappingsBatch) { + return; + } + + const keyByStreamId = new Map(); + for (const entry of entries) { + if (!entry.playlistId) continue; + keyByStreamId.set( + entry.streamId, + buildXtreamEpgMappingKey(entry.playlistId, entry.streamId) + ); + } + if (keyByStreamId.size === 0) { + return; + } + + try { + // One IPC round-trip for the whole viewport — a per-entry + // lookup would put O(N) IPC calls on every scroll event. + const mappings = await getEpgMappingsBatch([ + ...keyByStreamId.values(), + ]); + for (const entry of entries) { + const key = keyByStreamId.get(entry.streamId); + const mapped = key ? mappings[key]?.trim() : undefined; + if (mapped) { + this.logger.info( + `Mapped stream ${entry.streamId}: ${entry.epgChannelId ?? '(none)'} → ${mapped}` + ); + entry.epgChannelId = mapped; + } + } + } catch { + // Mapping lookup failure is non-fatal; keep the original + // epgChannelId values and proceed. + } + } + private pruneEphemeralMaps(visibleIds: Set): void { for (const id of [...this.epgChannelByStreamId.keys()]) { // getCached() honors TTL and lazily evicts expired entries; @@ -233,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, @@ -241,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; @@ -249,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/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/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index a2aee2ff1..529b327e1 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -6,7 +6,7 @@ import { withMethods, withState, } from '@ngrx/signals'; -import { EpgItem } from '@iptvnator/shared/interfaces'; +import { buildXtreamEpgMappingKey, EpgItem } from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { XtreamApiService, @@ -40,7 +40,7 @@ const initialEpgState: EpgState = { export function withEpg() { const logger = createLogger('withEpg'); type ParentSelectionStoreLike = { - currentPlaylist?: () => XtreamCredentials | null; + currentPlaylist?: () => (XtreamCredentials & { id?: string }) | null; selectedItem?: () => { xtream_id?: number | null; epg_channel_id?: string | null; @@ -146,10 +146,33 @@ export function withEpg() { patchState(store, { epgItems: [], isLoadingEpg: true }); + // Resolve manual EPG mapping before the provider waterfall. + // The mapping dialog saves under the playlist-scoped + // Xtream key, so look it up and use the mapped EPG + // channel ID for the XMLTV / provider lookup. + let epgChannelId = selectedItem?.epg_channel_id ?? null; + const playlistId = storeAny.currentPlaylist?.()?.id; + if (runtime.supportsEpgMapping && playlistId) { + try { + const mapping = + await window.electron?.getEpgMapping?.( + buildXtreamEpgMappingKey( + playlistId, + xtreamId + ) + ); + if (mapping?.epgChannelId?.trim()) { + epgChannelId = mapping.epgChannelId.trim(); + } + } catch { + // Non-fatal; keep original epgChannelId. + } + } + try { const epgItems = await fallbackService.resolveCurrentEpg({ - epgChannelId: selectedItem.epg_channel_id, + epgChannelId, preferUploaded: preferUploaded(), fetchProvider: () => fetchFullProvider(credentials, xtreamId), 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/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index 901ac8d28..6271ec3e0 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -43,12 +43,30 @@ " [showFavoriteButton]="true" [showProgramInfoButton]="false" + [showDetailsContextMenu]="supportsEpgMapping" [isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false" (clicked)="playClicked.emit(item)" (activated)="playbackRequested.emit(item)" (favoriteToggled)="toggleFavorite($event, item)" + (contextMenuRequested)="onChannelContextMenu(item, $event)" /> + + + + + + } @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..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 @@ -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(() => { @@ -122,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(); @@ -222,6 +240,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { range.start, range.end ); + this.lastVisibleChannels = visibleChannels; this.loadEpgForVisibleChannels(visibleChannels); }) ); @@ -246,6 +265,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { const uncachedEntries: { streamId: number; epgChannelId?: string | null; + playlistId?: string | null; }[] = []; // Apply cached results immediately @@ -266,6 +286,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 +480,99 @@ 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; + } + + 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, + 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; + } + } } 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..4ad4a1ae7 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 }} + }
} @@ -134,6 +165,7 @@ detail-player [playback]="playback" [episodeMetadata]="inlineEpisodeMetadata()" + [seriesTitle]="item.info.name" [seriesNavigation]="inlineSeriesNavigation()" (timeUpdate)="handleInlineTimeUpdate($event)" (closed)="closeInlinePlayer()" diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index b3997d8ea..c0decadb2 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -54,6 +54,7 @@ class StubSeasonContainerComponent { class StubPortalInlinePlayerComponent { readonly playback = input(null); readonly episodeMetadata = input(null); + readonly seriesTitle = input(null); readonly seriesNavigation = input(null); readonly timeUpdate = output(); readonly closed = output(); 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..dba1e722c 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) { @@ -182,6 +213,14 @@ play_circle {{ 'DOWNLOADS.PLAY_LOCAL' | translate }} + } @else if (isPausedDownload()) { + } @else if (isDownloading()) {