diff --git a/.changes/electron-deferred-startup-wiring.md b/.changes/electron-deferred-startup-wiring.md new file mode 100644 index 000000000..126d72aad --- /dev/null +++ b/.changes/electron-deferred-startup-wiring.md @@ -0,0 +1,8 @@ +--- +type: perf +area: electron +--- + +The desktop app now opens its window before it prepares the portal, program +guide, download, player and update machinery, and does that preparation while +the window is already loading, so the first screen appears sooner. diff --git a/.changes/settings-parental-lock.md b/.changes/settings-parental-lock.md new file mode 100644 index 000000000..90d9b41f4 --- /dev/null +++ b/.changes/settings-parental-lock.md @@ -0,0 +1,8 @@ +--- +type: feature +area: settings +issues: [285] +highlight: Parental lock +--- + +New parental lock: set a PIN in Settings โ†’ Parental lock, then mark Xtream categories, Stalker genres or M3U groups as locked. Locked categories and their channels disappear from the app until the PIN is entered; the app locks again on restart, after a chosen idle time or with "Lock now". diff --git a/.changes/settings-search.md b/.changes/settings-search.md new file mode 100644 index 000000000..137d7eff1 --- /dev/null +++ b/.changes/settings-search.md @@ -0,0 +1,10 @@ +--- +type: feature +area: settings +highlight: Search your settings +--- + +Settings are now searchable: type in the search box on the Settings page to +find any option by name, description or a common word like "dark" or "mpv", +then jump straight to it. The command palette (Ctrl/Cmd+K) finds settings +too, from anywhere in the app. diff --git a/.changes/web-initial-path-audit.md b/.changes/web-initial-path-audit.md new file mode 100644 index 000000000..f1017a28c --- /dev/null +++ b/.changes/web-initial-path-audit.md @@ -0,0 +1,8 @@ +--- +type: perf +area: web +--- + +The app now loads about 40% less code before it shows its first screen: +channel lists, program guide views, Stalker portal tools and a few dialogs +are loaded when they are first needed instead of at startup. diff --git a/.changes/xtream-category-selection-scroll.md b/.changes/xtream-category-selection-scroll.md new file mode 100644 index 000000000..19c2bed83 --- /dev/null +++ b/.changes/xtream-category-selection-scroll.md @@ -0,0 +1,6 @@ +--- +type: fix +area: xtream +--- + +Selecting an Xtream category no longer scrolls the category panel to an unrelated category, including when categories are hidden or sorted alphabetically. diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md deleted file mode 100644 index 6fdb44076..000000000 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: "\U0001F41E Bug report" -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - -- PWA or Electron/Tauri application -- OS: [e.g. Linux] -- Browser [e.g. chrome, safari] -- Version [e.g. 0.16.0] -- Feature: [e.g. M3U playlist, Xtream Code, Stalker Portal] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md deleted file mode 100644 index 24405ec4f..000000000 --- a/.github/ISSUE_TEMPLATE/---feature-request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: "\U0001F680 Feature request" -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..94e7ddad3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,126 @@ +name: ๐Ÿž Bug report +description: Something in IPTVnator doesn't work as expected. +title: '[Bug]: ' +labels: ['bug :bug:'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! The details below help us reproduce it on the first try. + + - **A channel, movie or episode doesn't play or stops?** The **Playback problem** form asks the right questions for that. + - **A question or an idea?** Please use [Discussions](https://github.com/4gray/iptvnator/discussions) or the **Feature request** form. + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I'm on the latest release (or a nightly build) and the problem still happens there. + required: true + - label: I searched the [existing issues](https://github.com/4gray/iptvnator/issues?q=is%3Aissue) and this hasn't been reported yet. + required: true + - label: I removed usernames, passwords, MAC addresses, tokens and private URLs from everything I paste below. + required: true + - type: input + id: version + attributes: + label: IPTVnator version + description: Shown in **Settings โ†’ About**. For a nightly build, add the commit shown there too. + placeholder: 0.24.0 + validations: + required: true + - type: dropdown + id: install + attributes: + label: How do you run IPTVnator? + options: + - Windows installer (.exe) + - macOS app (.dmg or .zip) + - macOS via Homebrew + - Linux AppImage + - Linux .deb, .rpm or pacman package + - Linux Snap + - Linux Flatpak + - Arch Linux AUR (iptvnator-bin) + - Self-hosted web app (Docker) + - Built from source + validations: + required: true + - type: input + id: os + attributes: + label: Operating system and version + description: For the self-hosted web app, add the browser you use as well. + placeholder: Windows 11 24H2 ยท macOS 26.6 on Apple Silicon ยท Ubuntu 24.04 ยท Docker on Unraid + Firefox + validations: + required: true + - type: dropdown + id: source + attributes: + label: Source type + description: Which kind of source is affected? Pick all that apply. + multiple: true + options: + - M3U playlist added by URL + - M3U playlist added from a file or text + - Xtream Codes + - Stalker portal + - Not related to a source + validations: + required: true + - type: dropdown + id: player + attributes: + label: Video player + description: The player selected in **Settings โ†’ Playback โ†’ Video player**. + options: + - HTML5 video player + - Video.js player + - ArtPlayer + - Embedded MPV (Experimental) + - Embedded MPV with the frame-copy engine + - MPV player (external) + - VLC (external) + - Not related to playback + validations: + required: true + - type: dropdown + id: regression + attributes: + label: Did this work in an earlier version? + options: + - Yes, it worked in an earlier version + - No, it never worked for me + - I don't know + validations: + required: true + - type: input + id: last-working-version + attributes: + label: Last version where it worked + description: Only if it worked before. + placeholder: 0.23.0 + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you see, and what did you expect to happen instead? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open an Xtream Codes playlist + 2. Go to Live TV and pick a category + 3. โ€ฆ + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Diagnostics, logs and screenshots + description: | + If an error screen appears during playback, click **Copy diagnostics** and paste the report here; it contains no stream URLs or credentials. + You can also drag screenshots or a short screen recording into this field. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..983d32582 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: ๐Ÿ’ฌ Questions and help + url: https://github.com/4gray/iptvnator/discussions + about: Ask how to set something up, or share ideas with other users. + - name: ๐Ÿณ Self-hosting with Docker + url: https://github.com/4gray/iptvnator/blob/master/docker/README.md + about: Setup, environment variables and reverse-proxy notes for the self-hosted web app. + - name: ๐ŸŒ Website and downloads + url: https://4gray.github.io/iptvnator/ + about: Download links for every platform, FAQ and release announcements. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..087cad573 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,72 @@ +name: ๐Ÿš€ Feature request +description: Suggest a new feature or an improvement. +title: '[Feature]: ' +labels: ['enhancement :magic_wand:'] +body: + - type: markdown + attributes: + value: | + Thanks for the idea! If a similar request already exists, a ๐Ÿ‘ and your use case there help more than a new issue. + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I searched the [existing issues](https://github.com/4gray/iptvnator/issues?q=is%3Aissue) and this hasn't been requested yet. + required: true + - label: I removed usernames, passwords, MAC addresses, tokens and private URLs from any examples or screenshots I share. + required: true + - type: textarea + id: problem + attributes: + label: What problem would this solve? + description: Describe what you're trying to do and what gets in the way today. + validations: + required: true + - type: textarea + id: solution + attributes: + label: What would you like to happen? + description: How should it work from your point of view? Mockups and examples from other apps are welcome. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + multiple: true + options: + - Live TV and programme guide (EPG) + - Movies and series + - Playback and player controls + - Playlists and sources + - Favorites, history and downloads + - Search + - Settings, backup and sync + - Installation, updates and packaging + - Self-hosted web app (Docker) + - Other + - type: dropdown + id: source + attributes: + label: Source types + description: Which sources should this work with? + multiple: true + options: + - M3U playlists + - Xtream Codes + - Stalker portals + - All sources + - type: textarea + id: alternatives + attributes: + label: Alternatives you've considered + description: Workarounds you use today, or other ways to solve the problem. + - type: dropdown + id: contribute + attributes: + label: Would you like to help? + options: + - Yes, I'd like to open a pull request + - I can help test it + - Not right now diff --git a/.github/ISSUE_TEMPLATE/playback_problem.yml b/.github/ISSUE_TEMPLATE/playback_problem.yml new file mode 100644 index 000000000..dec23851c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/playback_problem.yml @@ -0,0 +1,150 @@ +name: โ–ถ๏ธ Playback problem +description: A channel, movie or episode doesn't start, stops, stutters, or has no picture or sound. +title: '[Playback]: ' +labels: ['bug :bug:'] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a playback problem! + + **Quick check first:** the built-in HTML5, Video.js and ArtPlayer players can't decode some codecs, for example HEVC/4K, AC3 or MPEG-2 video. + - **Desktop app:** such streams usually play with **Embedded MPV** or an external **MPV** or **VLC** (**Settings โ†’ Playback โ†’ Video player**). Whether the stream plays there tells us a lot, so please mention it below. + - **Self-hosted web app:** only the browser players are available. HLS, DASH and MPEG-TS streams may fail when the provider doesn't send CORS headers, and the browser may block `http://` streams on an `https://` page. Trying the same stream in the desktop app or in VLC helps us narrow it down. + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I'm on the latest release (or a nightly build) and the problem still happens there. + required: true + - label: I searched the [existing issues](https://github.com/4gray/iptvnator/issues?q=is%3Aissue) and this hasn't been reported yet. + required: true + - label: I removed usernames, passwords, MAC addresses, tokens and private URLs from everything I paste below. + required: true + - type: input + id: version + attributes: + label: IPTVnator version + description: Shown in **Settings โ†’ About**. For a nightly build, add the commit shown there too. + placeholder: 0.24.0 + validations: + required: true + - type: dropdown + id: install + attributes: + label: How do you run IPTVnator? + options: + - Windows installer (.exe) + - macOS app (.dmg or .zip) + - macOS via Homebrew + - Linux AppImage + - Linux .deb, .rpm or pacman package + - Linux Snap + - Linux Flatpak + - Arch Linux AUR (iptvnator-bin) + - Self-hosted web app (Docker) + - Built from source + validations: + required: true + - type: input + id: os + attributes: + label: Operating system and version + description: For the self-hosted web app, add the browser you use as well. + placeholder: Windows 11 24H2 ยท macOS 26.6 on Apple Silicon ยท Ubuntu 24.04 ยท Docker on Unraid + Firefox + validations: + required: true + - type: dropdown + id: source + attributes: + label: Source type + options: + - M3U playlist + - Xtream Codes + - Stalker portal + validations: + required: true + - type: dropdown + id: content + attributes: + label: What were you playing? + options: + - Live TV channel + - Catch-up / archive programme + - Movie + - Series episode + - Radio station + validations: + required: true + - type: dropdown + id: player + attributes: + label: Video player + description: The player selected in **Settings โ†’ Playback โ†’ Video player**. + options: + - HTML5 video player + - Video.js player + - ArtPlayer + - Embedded MPV (Experimental) + - Embedded MPV with the frame-copy engine + - MPV player (external) + - VLC (external) + - Audio player (radio station) + validations: + required: true + - type: dropdown + id: symptoms + attributes: + label: What goes wrong? + multiple: true + options: + - It doesn't start or keeps loading + - An error message appears + - It stops after a while + - It stutters or keeps buffering + - No picture, only sound + - No sound, or the wrong audio track + - Subtitles are missing or wrong + - Seeking or resuming goes to the wrong position + - Something else + validations: + required: true + - type: dropdown + id: elsewhere + attributes: + label: Does the same stream play elsewhere? + options: + - Yes, with another player in IPTVnator + - Yes, in VLC or another app outside IPTVnator + - No, it fails everywhere I tried + - I haven't tried + validations: + required: true + - type: dropdown + id: regression + attributes: + label: Did this work in an earlier version? + description: If it did, please name the last version that worked under **What happened?**. + options: + - Yes, it worked in an earlier version + - No, it never worked for me + - I don't know + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: | + Describe what you see and when. If it stops, roughly after how long? If it worked before, which version was the last one that worked? + Mention the stream format if you know it (HLS .m3u8, MPEG-TS .ts, DASH .mpd, MKVโ€ฆ). Please don't paste full stream URLs that contain credentials or tokens. + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Diagnostics, logs and screenshots + description: | + If an error screen appears, click **Copy diagnostics** and paste the report here; it contains no stream URLs or credentials. + If playback starts, the values from **Stream info** in the player overlay help too. Screenshots and short recordings are welcome. diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index d901a20ee..e31a370fc 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -1099,14 +1099,33 @@ jobs: sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22 snap connections iptvnator | awk \ '$2 == "iptvnator:graphics-core22" && $3 == "-" { found=1 } END { exit !found }' + probe_timed_out() { + # GNU timeout exits 124 when SIGTERM ends the app and 137 when -k + # escalates to SIGKILL. --verbose announces that KILL, so an app + # killed by anything else is not misreported as a timeout. + local status="$1" output="$2" + [ "${status}" -eq 124 ] && return 0 + [ "${status}" -eq 137 ] && + grep -Fq 'sending signal KILL to command' <<<"${output}" + } + # GNU timeout kills a packaged app that never exits, for example when the + # main process throws before app ready and Electron's uncaught-exception + # dialog blocks under xvfb. ELECTRON_ENABLE_LOGGING routes Chromium and + # main-process stderr into the captured output; the probe's JSON verdict is + # still a single stdout line that grep -Fx matches. set +e disconnected_probe="$( - xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 ELECTRON_ENABLE_LOGGING=1 \ + timeout --verbose -k 10 300 \ snap run iptvnator --embedded-mpv-runtime-probe 2>&1 )" disconnected_status=$? set -e printf '%s\n' "${disconnected_probe}" + if probe_timed_out "${disconnected_status}" "${disconnected_probe}"; then + echo "::error::The packaged Snap app did not exit within 300 seconds during the disconnected graphics runtime probe and was killed; inspect the probe output above for main-process startup errors." + exit 1 + fi test "${disconnected_status}" -eq 1 printf '%s\n' "${disconnected_probe}" | \ grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}' @@ -1117,27 +1136,43 @@ jobs: '$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 + set +e + hostile_probe="$( + xvfb-run -a env \ + LIBGL_ALWAYS_SOFTWARE=1 \ + ELECTRON_ENABLE_LOGGING=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 \ + timeout --verbose -k 10 300 \ + snap run iptvnator --embedded-mpv-runtime-probe 2>&1 + )" + hostile_status=$? + set -e + printf '%s\n' "${hostile_probe}" + if probe_timed_out "${hostile_status}" "${hostile_probe}"; then + echo "::error::The packaged Snap app did not exit within 300 seconds during the hostile-environment runtime probe and was killed; inspect the probe output above for main-process startup errors." + exit 1 + fi + if [ "${hostile_status}" -ne 0 ]; then + echo "::error::Snap hostile-environment runtime probe failed with status ${hostile_status}." + exit "${hostile_status}" + fi - name: Run packaged x64 frame-copy and fallback smoke if: matrix.os == 'linux' && matrix.linux_profile == 'portable' @@ -1223,26 +1258,49 @@ jobs: ELF_MAGIC="$(od -An -tx1 -N4 "${LAUNCHER_PATH}" | tr -d "[:space:]")" test "${ELF_MAGIC}" = "7f454c46" ' + probe_timed_out() { + # GNU timeout exits 124 when SIGTERM ends the app and 137 when -k + # escalates to SIGKILL. --verbose announces that KILL, so an app + # killed by anything else is not misreported as a timeout. + local status="$1" output="$2" + [ "${status}" -eq 124 ] && return 0 + [ "${status}" -eq 137 ] && + grep -Fq 'sending signal KILL to command' <<<"${output}" + } + # GNU timeout kills a packaged app that never exits, for example when the + # main process throws before app ready and Electron's uncaught-exception + # dialog blocks under xvfb. ELECTRON_ENABLE_LOGGING routes Chromium and + # main-process stderr into the captured output. set +e PROBE_OUTPUT="$( - xvfb-run -a dbus-run-session -- flatpak run \ + xvfb-run -a dbus-run-session -- timeout --verbose -k 10 300 flatpak run \ --env=LIBGL_ALWAYS_SOFTWARE=1 \ + --env=ELECTRON_ENABLE_LOGGING=1 \ com.fourgray.iptvnator \ --embedded-mpv-runtime-probe 2>&1 )" PROBE_STATUS=$? set -e + # Keep both ends of oversized output: logging can push a startup + # exception or the timeout notice past the first window. 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." + if [ "${#PROBE_OUTPUT}" -le $((PROBE_OUTPUT_LIMIT * 2)) ]; then + printf '%s\n' "${PROBE_OUTPUT}" + else + printf '%s\n' "${PROBE_OUTPUT:0:PROBE_OUTPUT_LIMIT}" + echo "::warning::Flatpak runtime probe output was truncated to its first and last ${PROBE_OUTPUT_LIMIT} characters." + printf '%s\n' "${PROBE_OUTPUT: -PROBE_OUTPUT_LIMIT}" 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_timed_out "${PROBE_STATUS}" "${PROBE_OUTPUT}"; then + echo "::error::The packaged Flatpak app did not exit within 300 seconds during the application runtime probe and was killed; inspect the probe output above for main-process startup errors." + exit 1 + fi if [ "${PROBE_STATUS}" -ne 0 ]; then echo "::error::Flatpak application runtime probe failed with status ${PROBE_STATUS}." exit "${PROBE_STATUS}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72be0299b..747ec5e10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,10 +205,152 @@ jobs: path: dist/performance/ retention-days: 14 + # Decides whether a pull request can move the performance journeys, so + # the journeys job below does not spend a runner on docs-only changes. + # Pushes to master and manual dispatches always run them. + performance-journeys-scope: + name: Performance journeys scope + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.scope.outputs.run }} + + steps: + # The PR checkout is the merge commit: its first parent is the + # target branch, so two commits are enough to diff the PR. + - name: Checkout code + if: github.event_name == 'pull_request' + uses: actions/checkout@v7 + with: + fetch-depth: 2 + + # Anything can move a journey (application code, the harness, root + # build inputs such as .nvmrc, nx.json or tsconfig.base.json), so + # the filter lists what cannot: the same paths the E2E workflow + # ignores, plus release notes. + - name: Detect journey-relevant changes + id: scope + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + relevant="$(git diff --name-only HEAD^1 HEAD | + grep -vE '\.md$|^docs/|^\.plans/|^\.codex/|^\.claude/|^\.changes/|^apps/website/' || true)" + if [ -n "$relevant" ]; then + echo "Journey-relevant changes:" + echo "$relevant" + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "No journey-relevant changes; skipping the performance journeys." + echo "run=false" >> "$GITHUB_OUTPUT" + fi + + # Runs the journey benchmarks (docs/architecture/performance-journeys.md) + # on the canonical Linux runner and uploads the summary as evidence. + performance-journeys: + name: Performance journeys + needs: performance-journeys-scope + if: needs.performance-journeys-scope.outputs.run == 'true' + runs-on: ubuntu-latest + # The electron-performance build is the bulk of the time; the launch + # journey itself is six fresh Electron processes plus one seeding run. + timeout-minutes: 30 + # Warn-only for the first two weeks of plan item B3: a failure is + # visible on the run but does not fail the workflow. + continue-on-error: true + env: + NX_SKIP_NX_CACHE: true + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Install pnpm + uses: pnpm/action-setup@v6.0.10 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The journeys drive Electron through Playwright's _electron API + # and never launch a Playwright browser, so no `playwright + # install`. The runner image ships Electron's shared libraries and + # xvfb; fail fast with a clear message if an image update drops one. + - name: Check Electron runtime dependencies + run: | + command -v xvfb-run || { echo "::error::xvfb-run is missing on the runner"; exit 1; } + missing="$(ldd node_modules/electron/dist/electron | grep 'not found' || true)" + if [ -n "$missing" ]; then + echo "::error::Electron is missing shared libraries:" + echo "$missing" + exit 1 + fi + + # The Nx target builds electron-backend:build-performance first + # and playwright.journeys.config.ts starts the Xtream mock server. + - name: Run the performance journeys + run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm run perf:journeys + env: + CI: true + NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + + # Every run writes a fresh timestamped directory, so a clean + # checkout must hold exactly one summary. + - name: Locate the journey summary + id: summary + run: | + set -euo pipefail + mapfile -t summaries < <(find dist/performance/journeys -mindepth 2 -maxdepth 2 -name summary.json) + if [ "${#summaries[@]}" -ne 1 ]; then + echo "::error::Expected one journey summary, found ${#summaries[@]}" + exit 1 + fi + echo "path=${summaries[0]}" >> "$GITHUB_OUTPUT" + + - name: Report journey measurements + env: + SUMMARY: ${{ steps.summary.outputs.path }} + run: | + set -euo pipefail + jq -r ' + "## Performance journeys (\(.harness.platform), \(.harness.measuredIterations) measured iterations)", "", + (.journeys | to_entries[] | .key as $journey | .value as $j | + "### `\($journey)`", "", + "| Measurement | Value | Iterations |", + "| --- | ---: | --- |", + (($j.counters // {}) | to_entries[] | + ($j.counterStability[.key] // {}) as $s | + "| `\(.key)` | \(.value) | \(($s.values // []) | map(tostring) | join(", "))\(if $s.stable == false then " (unstable)" else "" end) |"), + (($j.wallClock // {}) | to_entries[] | "| `\(.key)` | \(.value) | |"), + "") + ' "$SUMMARY" | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload journey summaries + if: always() + uses: actions/upload-artifact@v7 + with: + name: performance-journeys + path: dist/performance/journeys/ + if-no-files-found: warn + retention-days: 14 + unit-and-typecheck: name: Unit Tests and Typechecks runs-on: ubuntu-latest timeout-minutes: 45 + permissions: + contents: read + # The scope step lists the PR's changed files through the API. + pull-requests: read steps: - name: Checkout code @@ -244,14 +386,81 @@ jobs: - name: Typecheck web and Electron entry points run: pnpm run typecheck:ci + - name: Typecheck Jest spec programs + run: pnpm run typecheck:spec:test && pnpm run typecheck:spec + - name: Check i18n drift run: pnpm run i18n:check + # A pull request whose changes cannot reach any Tier A test (docs, + # notes, other workflows, website, E2E and mock-server apps, release + # and packaging tooling, a scripts-only package.json edit) skips the + # suite. The allowlist lives in a unit-tested script; anything it + # does not know runs everything, and master always runs everything. + - name: Decide unit coverage scope + id: scope + env: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + echo "Not a pull request; running the full suite." + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # The list-files endpoint stops at 3,000 files; a truncated + # list could hide a file that needs the suite. + if [ "${CHANGED_FILES:-0}" -ge 3000 ]; then + echo "PR changes ${CHANGED_FILES} files, beyond the API listing limit; running the full suite." + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + git fetch --no-tags --depth=1 origin "$BASE_SHA" + gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ + --paginate --jq '.[] | .filename, (.previous_filename // empty)' | + node tools/coverage/unit-coverage-scope.mjs --base FETCH_HEAD --github-output + + # Jest's transform cache (TypeScript/Angular transpilation plus + # coverage instrumentation, keyed by file content) is persisted + # between runs. Only master pushes and maintainer dispatches save + # it; pull requests restore it and never write, so a PR cannot plant + # an entry that a later master run would read. + - name: Restore Jest transform cache + if: steps.scope.outputs.run == 'true' && github.event_name != 'push' + uses: actions/cache/restore@v6 + with: + path: ${{ runner.temp }}/jest-cache + key: jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}-${{ github.run_id }} + restore-keys: | + jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}- + - name: Run Tier A unit coverage suite + if: steps.scope.outputs.run == 'true' run: pnpm run coverage:ci env: CI: true NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + JEST_CACHE_DIRECTORY: ${{ runner.temp }}/jest-cache + + - name: Validate coverage tooling (suite skipped) + if: steps.scope.outputs.run != 'true' + run: pnpm run coverage:tools:test && pnpm run coverage:policy:check + + # Master pushes start from an empty cache, so the saved cache holds + # exactly the current tree and does not grow run over run. + - name: Save Jest transform cache + if: >- + steps.scope.outputs.run == 'true' && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/master')) + uses: actions/cache/save@v6 + with: + path: ${{ runner.temp }}/jest-cache + key: jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}-${{ github.run_id }} - name: Run Tier B/C validation commands run: node tools/coverage/check-coverage-policy.mjs --run-non-tier-a @@ -260,7 +469,7 @@ jobs: NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false - name: Upload unit coverage artifact - if: always() + if: always() && steps.scope.outputs.run == 'true' uses: actions/upload-artifact@v7 with: name: unit-coverage @@ -269,7 +478,7 @@ jobs: retention-days: 14 - name: Upload unit coverage to Codecov - if: always() + if: always() && steps.scope.outputs.run == 'true' uses: codecov/codecov-action@v7 with: files: ./coverage/merged/lcov.info,./coverage/merged/cobertura-coverage.xml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 93b8e120f..74e397a8f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,6 +15,15 @@ on: schedule: - cron: '0 20 * * 3' +# A newer push to a pull request cancels that PR's still-running analysis: +# only the latest commit's result matters, and superseded runs otherwise hold +# runners the rest of the pipeline is queued for. Pushes to master, the weekly +# schedule and manual dispatches get a unique group (run_id), so they are never +# cancelled or replaced, the same pattern as ci.yml. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + # Required so `codeql-action/analyze` can upload its SARIF results. Without an # explicit grant the default token is read-only and the upload fails with # "Resource not accessible by integration". diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 74ff1e198..d3fb6874b 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -37,18 +37,31 @@ permissions: jobs: electron-e2e-tests: - name: Electron E2E on ${{ matrix.os }} + name: Electron E2E on ${{ matrix.os }} (${{ matrix.shard }}/${{ matrix.shard-total }}) runs-on: ${{ matrix.os }} - # macOS's sequential Electron suite exceeded 45m while still passing - # tests; retain the full suite with room for setup and retries. - timeout-minutes: ${{ matrix.os == 'macos-latest' && 60 || 45 }} + # The sequential Electron suite is split into Playwright shards (one + # runner each, split by spec file). Measured on 2026-09-26, a third of + # the suite is 6-12 minutes of test time on top of 3-7 minutes of + # setup. macOS uses two shards because its runners are the scarcest: + # three macOS shards per run queued for 45-57 minutes on master. Half + # of the suite is about 15 minutes of test time on macOS, so it gets + # a longer timeout to leave room for retries. + timeout-minutes: ${{ matrix.os == 'macos-latest' && 40 || 30 }} env: IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1' NX_SKIP_NX_CACHE: true strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + include: + - { os: ubuntu-latest, shard: 1, shard-total: 3 } + - { os: ubuntu-latest, shard: 2, shard-total: 3 } + - { os: ubuntu-latest, shard: 3, shard-total: 3 } + - { os: macos-latest, shard: 1, shard-total: 2 } + - { os: macos-latest, shard: 2, shard-total: 2 } + - { os: windows-latest, shard: 1, shard-total: 3 } + - { os: windows-latest, shard: 2, shard-total: 3 } + - { os: windows-latest, shard: 3, shard-total: 3 } steps: - uses: actions/checkout@v7 @@ -68,37 +81,114 @@ jobs: - name: Build Backend run: pnpm nx build electron-backend + # Process-lifecycle checks are independent of the shard split; + # run them once per OS. - name: Verify Electron process cleanup + if: matrix.shard == 1 run: pnpm exec tsx --test apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts - - name: Install Playwright Browsers - run: pnpm exec playwright install --with-deps + # The suite drives Electron through Playwright's _electron API and + # never launches a Playwright browser (or records video, which + # would need Playwright's ffmpeg), so no `playwright install`. + # The Ubuntu runner image already ships Electron's shared + # libraries and xvfb; fail fast with a clear message if an image + # update ever drops one. Electron 42+ downloads its binary on the + # first `require('electron')` (previously inside + # `_electron.launch()`), so resolve it the same way first. + - name: Check Electron runtime dependencies + if: runner.os == 'Linux' + run: | + command -v xvfb-run || { echo "::error::xvfb-run is missing on the runner"; exit 1; } + node -e "require('electron')" || { echo "::error::Electron binary download failed"; exit 1; } + electron_bin="$(node -p "require('electron')")" + [ -x "$electron_bin" ] || { echo "::error::Electron binary not found at $electron_bin"; exit 1; } + ldd_status=0 + libs="$(ldd "$electron_bin")" || ldd_status=$? + missing="$(grep 'not found' <<< "$libs" || true)" + if [ -n "$missing" ]; then + echo "::error::Electron is missing shared libraries:" + echo "$missing" + exit 1 + fi + [ "$ldd_status" -eq 0 ] || { echo "::error::ldd failed on $electron_bin (exit $ldd_status)"; exit 1; } - name: Run Electron E2E Tests (Linux) if: runner.os == 'Linux' - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e + run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e -- --shard=${{ matrix.shard }}/${{ matrix.shard-total }} env: CI: true - name: Run Electron E2E Tests (Windows/Mac) if: runner.os != 'Linux' - run: pnpm nx run electron-backend-e2e:e2e + run: pnpm nx run electron-backend-e2e:e2e -- --shard=${{ matrix.shard }}/${{ matrix.shard-total }} env: CI: true - - name: Summarize Electron E2E semantic coverage - if: always() - run: pnpm run coverage:e2e:summary -- --project=electron-backend-e2e - + # Each shard's results.json carries config.shard; the summary job + # below merges the shards of one OS into a single semantic summary. - name: Upload Electron Test Results if: always() uses: actions/upload-artifact@v7 with: - name: playwright-report-electron-${{ matrix.os }} + name: playwright-report-electron-${{ matrix.os }}-${{ matrix.shard }} path: | dist/playwright-report/electron-backend-e2e/ dist/test-results/electron-backend-e2e/ - coverage/e2e/ + retention-days: 7 + + electron-e2e-summary: + name: Electron E2E summary + runs-on: ubuntu-latest + needs: electron-e2e-tests + # Summarize failed shards too, but not a cancelled (superseded) run. + if: ${{ !cancelled() }} + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + + # One download per OS keeps each OS's shards in their own directory + # (the artifact paths inside the shards are identical). + - name: Download shard reports (Ubuntu) + uses: actions/download-artifact@v8 + with: + pattern: playwright-report-electron-ubuntu-latest-* + path: dist/e2e-shards/ubuntu-latest + + - name: Download shard reports (macOS) + uses: actions/download-artifact@v8 + with: + pattern: playwright-report-electron-macos-latest-* + path: dist/e2e-shards/macos-latest + + - name: Download shard reports (Windows) + uses: actions/download-artifact@v8 + with: + pattern: playwright-report-electron-windows-latest-* + path: dist/e2e-shards/windows-latest + + # The script fails when a shard's results.json is missing or + # duplicated, so a partial run is never summarized as complete. + - name: Summarize Electron E2E semantic coverage per OS + run: | + status=0 + for os in ubuntu-latest macos-latest windows-latest; do + echo "## Electron E2E on $os" >> "$GITHUB_STEP_SUMMARY" + node tools/coverage/e2e-semantic-summary.mjs --project=electron-backend-e2e --input="dist/e2e-shards/$os" --output-dir="coverage/e2e/$os" || status=1 + done + exit "$status" + + - name: Upload Electron semantic summaries + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-semantic-summary-electron + path: coverage/e2e/ retention-days: 7 web-e2e-tests: diff --git a/README.md b/README.md index fbec01873..25bcbb9d8 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ The application is a cross-platform, open-source project built with Electron and - Per-playlist and global favorites, aggregated across all playlists โญ - Recently viewed / watch history -- Command palette (`Ctrl/Cmd+K`) +- Command palette (`Ctrl/Cmd+K`) that also finds and opens individual settings +- Settings search from the header search box on the Settings page **Platform** @@ -76,9 +77,9 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list. | Area | Shortcut | Action | | ----------------- | --------------------------- | ---------------------------------------------------------- | | Global | `Ctrl/Cmd+K` | Open command palette | -| Global | `Ctrl/Cmd+F` | Open global search in the desktop app | +| Global | `Ctrl/Cmd+F` | Open global search (desktop); on Settings, search settings | | Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app | -| Global | `Enter` in workspace search | Submit the current search | +| Global | `Enter` in workspace search | Submit the search; on Settings, open the best match | | Global | `F11` | Toggle app window fullscreen in the desktop app | | Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar | | Navigation | `0-9` | Select an M3U channel by number | diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 181bc1079..af7ae8137 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -1,4 +1,6 @@ import { Locator, Page } from '@playwright/test'; +import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; +import { ok as assert } from 'node:assert'; import { addXtreamPortal, closeElectronApp, @@ -14,8 +16,96 @@ import { waitForSourceRowIdle, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; +import { applyTheme } from './theme-contrast'; test.describe('Electron Xtream Category Management', () => { + test('keeps the selected category in view with 800 categories, 600 hidden and A-Z sorting', async ({ + dataDir, + request, + }) => { + test.slow(); + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + try { + await addXtreamPortal(app.mainWindow, { + username: 'category-scroll', + password: 'category-scroll', + }); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await waitForXtreamWorkspaceReady(app.mainWindow); + const panel = app.mainWindow.locator('app-workspace-context-panel'); + const rows = panel.locator('.category-item'); + await expect(rows).toHaveCount(800); + const dialog = await openManageCategoriesDialog(app.mainWindow); + await dialog + .getByRole('button', { name: 'Deselect All', exact: true }) + .click(); + await dialog.locator('input[type="search"]').fill('Visible'); + await dialog + .getByRole('button', { name: 'Select Filtered', exact: true }) + .click(); + await expect(dialog.locator('.selection-info')).toHaveText( + 'Total selected: 200 / 800' + ); + await dialog + .getByRole('button', { name: 'Save', exact: true }) + .click(); + await expect(dialog).toBeHidden(); + await expect(rows).toHaveCount(200); + await panel + .getByRole('button', { name: 'Sort categories', exact: true }) + .click(); + await app.mainWindow + .getByRole('menuitem', { name: 'Name A-Z' }) + .click(); + + const categories = await app.mainWindow.evaluate(async () => { + const playlistId = location.pathname.match( + /\/workspace\/xtreams\/([^/]+)/ + )?.[1]; + if (!playlistId) + throw new Error('Xtream playlist route is missing'); + const api = ( + window as unknown as { electron: ElectronBridgeApi } + ).electron; + return api.dbGetCategories(playlistId, 'live'); + }); + categories.sort((left, right) => + left.name.localeCompare(right.name) + ); + await expect(rows.locator('.nav-item-label')).toHaveText( + categories.map((category) => category.name) + ); + + // Exercise collisions above and below the clicked row. Reading the + // imported IDs avoids relying on SQLite allocation/import order. + for (const [theme, direction] of [ + ['dark', -1], + ['light', 1], + ] as const) { + await applyTheme(app.mainWindow, theme); + const category = categories.find((candidate, index) => { + const wrongIndex = categories.findIndex( + (other) => other.xtream_id === candidate.id + ); + return ( + wrongIndex >= 0 && (wrongIndex - index) * direction > 15 + ); + }); + assert( + category, + `Missing fixture collision in direction ${direction}` + ); + const row = rows.filter({ hasText: category.name }); + await row.click(); + await expect(row).toHaveAttribute('aria-current', 'true'); + await expectCategoryCentered(row); + } + } finally { + await closeElectronApp(app); + } + }); + for (const section of ['Live TV', 'Movies', 'Series']) { test(`bulk edits only filtered ${section} categories and saves or discards the draft`, async ({ dataDir, @@ -407,6 +497,44 @@ async function openManageCategoriesDialog(page: Page) { return dialog; } +/** Wait for the real smooth scroll to finish with the selected row centered. */ +async function expectCategoryCentered(row: Locator): Promise { + let previousTop = -1; + let stableSamples = 0; + await expect + .poll( + async () => { + const position = await row.evaluate((element) => { + const container = element.closest( + 'app-workspace-context-category-view' + ) as HTMLElement; + const bounds = container.getBoundingClientRect(); + const rowBounds = element.getBoundingClientRect(); + const target = + container.scrollTop + + rowBounds.top - + bounds.top - + container.clientHeight / 2 + + rowBounds.height / 2; + const clamped = Math.min( + container.scrollHeight - container.clientHeight, + Math.max(0, target) + ); + return { + top: container.scrollTop, + centered: Math.abs(container.scrollTop - clamped) < 2, + }; + }); + stableSamples = + position.top === previousTop ? stableSamples + 1 : 0; + previousTop = position.top; + return position.centered && stableSamples >= 3; + }, + { intervals: [100] } + ) + .toBe(true); +} + async function refreshFromWorkspaceHeader(page: Page): Promise { await page .getByRole('button', { name: 'Refresh playlist', exact: true }) diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts index 9f5fd9870..0ab7b7c23 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts @@ -62,6 +62,16 @@ export const BUILD_IDENTITY: XtreamBenchmarkBuildIdentity = { 'database-worker-source-map' ), }, + deferredEvents: { + javascript: buildFile( + 'dist/apps/electron-backend/deferred-events.js', + 'deferred-events-javascript' + ), + sourceMap: buildFile( + 'dist/apps/electron-backend/deferred-events.js.map', + 'deferred-events-source-map' + ), + }, launcher: { javascript: buildFile( 'dist/apps/electron-backend/main.js', diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts index f2db2a571..cb7ae28fc 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts @@ -11,6 +11,7 @@ const RENDERER_FILE = const BUILD_KEYS = ['electron', 'renderer'] as const; const ELECTRON_KEYS = [ 'databaseWorker', + 'deferredEvents', 'launcher', 'main', 'playlistRefreshWorker', @@ -27,6 +28,7 @@ const RENDERER_KEYS = [ ] as const; const ELECTRON_PATHS = { databaseWorker: 'dist/apps/electron-backend/workers/database.worker.js', + deferredEvents: 'dist/apps/electron-backend/deferred-events.js', launcher: 'dist/apps/electron-backend/main.js', main: 'dist/apps/electron-backend/main.app.js', playlistRefreshWorker: @@ -46,6 +48,10 @@ export function parseXtreamBenchmarkBuildIdentity( electronInput['databaseWorker'], ELECTRON_PATHS.databaseWorker ), + deferredEvents: pair( + electronInput['deferredEvents'], + ELECTRON_PATHS.deferredEvents + ), launcher: pair(electronInput['launcher'], ELECTRON_PATHS.launcher), main: pair(electronInput['main'], ELECTRON_PATHS.main), playlistRefreshWorker: pair( diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts index 1e3ca2cdc..7f1afba19 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts @@ -46,6 +46,11 @@ describe('Xtream benchmark build identity', () => { path: 'dist/apps/electron-backend/main.js', sha256: sha256('launcher'), }); + assert.deepEqual(identity.electron.deferredEvents.javascript, { + bytes: 8, + path: 'dist/apps/electron-backend/deferred-events.js', + sha256: sha256('deferred'), + }); assert.deepEqual(identity.electron.preload.sourceMap, { bytes: 11, path: 'dist/apps/electron-backend/main.preload.js.map', @@ -224,6 +229,8 @@ async function buildFixture(): Promise { mkdir(join(renderer, 'assets'), { recursive: true }), ]); await Promise.all([ + writeFile(join(backend, 'deferred-events.js'), 'deferred'), + writeFile(join(backend, 'deferred-events.js.map'), 'deferred-map'), writeFile(join(backend, 'main.js'), 'launcher'), writeFile(join(backend, 'main.js.map'), 'launcher-map'), writeFile(join(backend, 'main.app.js'), 'main'), diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts index fac4cabe3..7e9fe1747 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts @@ -18,6 +18,7 @@ export interface XtreamBuildPairIdentity { export interface XtreamBenchmarkBuildIdentity { readonly electron: { readonly databaseWorker: XtreamBuildPairIdentity; + readonly deferredEvents: XtreamBuildPairIdentity; readonly launcher: XtreamBuildPairIdentity; readonly main: XtreamBuildPairIdentity; readonly playlistRefreshWorker: XtreamBuildPairIdentity; @@ -36,6 +37,9 @@ const BACKEND_ROOT = 'dist/apps/electron-backend'; const RENDERER_ROOT = 'dist/apps/web'; const ELECTRON_PATHS = { databaseWorker: `${BACKEND_ROOT}/workers/database.worker.js`, + // main.app.js loads this chunk once the window starts loading; most IPC + // handlers and the database wiring live there. + deferredEvents: `${BACKEND_ROOT}/deferred-events.js`, // main.js only enables the compile cache and requires main.app.js, but // it decides startup behavior, so both belong to the identity. launcher: `${BACKEND_ROOT}/main.js`, @@ -51,6 +55,7 @@ export async function captureXtreamBuildIdentity( if (!isAbsolute(workspaceRoot)) invalid(); const [ databaseWorker, + deferredEvents, launcher, main, playlistRefreshWorker, @@ -58,6 +63,7 @@ export async function captureXtreamBuildIdentity( renderer, ] = await Promise.all([ readPair(workspaceRoot, ELECTRON_PATHS.databaseWorker), + readPair(workspaceRoot, ELECTRON_PATHS.deferredEvents), readPair(workspaceRoot, ELECTRON_PATHS.launcher), readPair(workspaceRoot, ELECTRON_PATHS.main), readPair(workspaceRoot, ELECTRON_PATHS.playlistRefreshWorker), @@ -67,6 +73,7 @@ export async function captureXtreamBuildIdentity( return Object.freeze({ electron: Object.freeze({ databaseWorker, + deferredEvents, launcher, main, playlistRefreshWorker, diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index c3fcb9046..13189747d 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -59,6 +59,7 @@ } ], "tsConfig": "apps/electron-backend/tsconfig.app.json", + "webpackConfig": "apps/electron-backend/webpack.config.cjs", "assets": [ "apps/electron-backend/src/assets", { @@ -157,6 +158,7 @@ } ], "tsConfig": "apps/electron-backend/tsconfig.app.json", + "webpackConfig": "apps/electron-backend/webpack.config.cjs", "assets": [ "apps/electron-backend/src/assets", { @@ -213,7 +215,7 @@ { "from": "electron-backend", "to": "electron-backend", - "filter": ["main.app.js"] + "filter": ["main.app.js", "deferred-events.js"] } ], "sourcePath": "dist/apps", @@ -234,7 +236,7 @@ { "from": "electron-backend", "to": "electron-backend", - "filter": ["main.app.js"] + "filter": ["main.app.js", "deferred-events.js"] } ], "sourcePath": "dist/apps", diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index c9c597bb3..97b7a8fb2 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -176,9 +176,15 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ }, { method: 'dbSaveCategories', - args: [playlistId, categories, 'live', categoryIds], + args: [playlistId, categories, 'live', categoryIds, categoryIds], channel: 'DB_SAVE_CATEGORIES', - forwardedArgs: [playlistId, categories, 'live', categoryIds], + forwardedArgs: [ + playlistId, + categories, + 'live', + categoryIds, + categoryIds, + ], }, { method: 'dbGetAllCategories', @@ -192,6 +198,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_UPDATE_CATEGORY_VISIBILITY', forwardedArgs: [categoryIds, true], }, + { + method: 'dbSetCategoryLocks', + args: [playlistId, 'live', categoryIds], + channel: 'DB_SET_CATEGORY_LOCKS', + forwardedArgs: [playlistId, 'live', categoryIds], + }, { method: 'dbHasContent', args: [playlistId, 'movie'], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index adb8fefb9..8c4557ebd 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -99,6 +99,7 @@ const WINDOW_CONFIRM_CLOSE = 'WINDOW:CONFIRM_CLOSE'; const WINDOW_CANCEL_CLOSE = 'WINDOW:CANCEL_CLOSE'; const WINDOW_CLOSE_REQUESTED = 'WINDOW:CLOSE_REQUESTED'; const PLAYBACK_SET_KEEP_AWAKE = 'PLAYBACK:SET_KEEP_AWAKE'; +const PARENTAL_LOCK_SET_STATE = 'PARENTAL_LOCK:SET_STATE'; const dbSaveContentProgressListeners = new Set< ( @@ -461,6 +462,8 @@ const electronApi: ElectronBridgeApi = { }, setPlaybackKeepAwake: (active: boolean) => ipcRenderer.invoke(PLAYBACK_SET_KEEP_AWAKE, active === true), + setParentalLockState: (active: boolean) => + ipcRenderer.invoke(PARENTAL_LOCK_SET_STATE, active === true), fetchPlaylistByUrl: ( url: string, title?: string, @@ -827,14 +830,16 @@ const electronApi: ElectronBridgeApi = { playlistId: string, categories: XtreamCategory[], type: string, - hiddenCategoryXtreamIds?: number[] + hiddenCategoryXtreamIds?: number[], + lockedCategoryXtreamIds?: number[] ) => ipcRenderer.invoke( 'DB_SAVE_CATEGORIES', playlistId, categories, type, - hiddenCategoryXtreamIds + hiddenCategoryXtreamIds, + lockedCategoryXtreamIds ), dbGetAllCategories: (playlistId: string, type: string) => ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type), @@ -844,6 +849,17 @@ const electronApi: ElectronBridgeApi = { categoryIds, hidden ), + dbSetCategoryLocks: ( + playlistId: string, + type: string, + lockedXtreamIds: number[] + ) => + ipcRenderer.invoke( + 'DB_SET_CATEGORY_LOCKS', + playlistId, + type, + lockedXtreamIds + ), dbHasContent: (playlistId: string, type: string) => ipcRenderer.invoke('DB_HAS_CONTENT', playlistId, type), dbGetContent: (playlistId: string, type: string) => diff --git a/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts index 4f3855676..f8ed846ce 100644 --- a/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts @@ -91,6 +91,9 @@ export const XTREAM_PRELOAD_TARGET_CASES: TargetCase[] = [ categoryItems, 'live', [91], + // Parental-lock ids: the preload forwards the optional fifth + // argument as-is, so an omitted one travels as undefined. + undefined, ], expectedMetadata: { ...EMPTY_METADATA, diff --git a/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts index f03a60895..020919ab9 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts @@ -74,6 +74,7 @@ describe('category operation performance phases', () => { ], 'live', [102], + undefined, recording.capture ) ).resolves.toEqual({ success: true }); diff --git a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts index 2c38e5ab6..582efb537 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts @@ -4,7 +4,15 @@ import { getAllCategories, getCategories, saveCategories, + setCategoryLocks, } from './category.operations'; +import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core'; +import type { SQL } from 'drizzle-orm'; +import { setParentalLockActive } from '../parental-lock-state'; + +function renderSql(query: SQL): string { + return new SQLiteSyncDialect().sqlToQuery(query).sql; +} // Renderer consumers (XCategoryFromDb/XtreamCategoryFromDb) expect category // rows in this snake_case wire shape. A bare select() would return Drizzle's @@ -17,6 +25,7 @@ const categoryWireShape = { type: schema.categories.type, xtream_id: schema.categories.xtreamId, hidden: schema.categories.hidden, + locked: schema.categories.locked, }; function createDbMock(existingCount = 0) { @@ -93,6 +102,7 @@ describe('category.operations', () => { type: 'live', xtreamId: 101, hidden: false, + locked: false, }, { playlistId: 'playlist-1', @@ -100,6 +110,7 @@ describe('category.operations', () => { type: 'live', xtreamId: 102, hidden: true, + locked: false, }, ]); }); @@ -125,6 +136,7 @@ describe('category.operations', () => { type: 'movies', xtreamId: 201, hidden: true, + locked: false, }, ]); }); @@ -143,3 +155,120 @@ describe('category.operations', () => { expect(insert).not.toHaveBeenCalled(); }); }); + +describe('category.operations parental lock', () => { + afterEach(() => { + setParentalLockActive(false); + }); + + function createReadDb() { + const orderBy = jest.fn().mockResolvedValue([]); + const where = jest.fn().mockReturnValue({ orderBy }); + const from = jest.fn().mockReturnValue({ where }); + const select = jest.fn().mockReturnValue({ from }); + return { db: { select } as unknown as AppDatabase, where }; + } + + it('adds the locked filter to visible-category reads only while active', async () => { + const unlocked = createReadDb(); + await getCategories(unlocked.db, 'playlist-1', 'live'); + expect(renderSql(unlocked.where.mock.calls[0][0])).not.toContain( + '"locked"' + ); + + setParentalLockActive(true); + const locked = createReadDb(); + await getCategories(locked.db, 'playlist-1', 'live'); + expect(renderSql(locked.where.mock.calls[0][0])).toContain( + '"categories"."locked" = ?' + ); + }); + + it('never filters the management read, which lists locked rows by design', async () => { + setParentalLockActive(true); + const { db, where } = createReadDb(); + await getAllCategories(db, 'playlist-1', 'movies'); + expect(renderSql(where.mock.calls[0][0])).not.toContain('"locked"'); + }); + + it('stamps locked from the caller-supplied provider ids on insert', async () => { + const { db, values } = createDbMock(0); + + await saveCategories( + db, + 'playlist-1', + [ + { category_id: '1', category_name: 'Kids' }, + { category_id: '2', category_name: 'Adult' }, + ], + 'live', + undefined, + [2] + ); + + expect(values).toHaveBeenCalledWith([ + expect.objectContaining({ xtreamId: 1, locked: false }), + expect.objectContaining({ xtreamId: 2, locked: true }), + ]); + }); + + function lockIndexDb() { + const run = jest.fn(); + const where = jest.fn().mockReturnValue({ run }); + const set = jest.fn().mockReturnValue({ where }); + const update = jest.fn().mockReturnValue({ set }); + const transaction = jest.fn((callback: () => void) => callback()); + return { + db: { update, transaction } as unknown as AppDatabase, + run, + set, + transaction, + where, + }; + } + + it('re-stamps one playlist/type in one transaction: clears everything, then locks the listed ids', async () => { + const { db, run, set, transaction, where } = lockIndexDb(); + + await setCategoryLocks(db, 'playlist-1', 'live', [5, 5, 7, 1.5]); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); + expect(set).toHaveBeenNthCalledWith(1, { locked: false }); + expect(set).toHaveBeenNthCalledWith(2, { locked: true }); + const lockScope = new SQLiteSyncDialect().sqlToQuery( + where.mock.calls[1][0] + ); + expect(lockScope.sql).toContain('"categories"."xtream_id" in (?, ?)'); + expect(lockScope.params).toEqual(['playlist-1', 'live', 5, 7]); + }); + + it('only clears when no id is locked', async () => { + const { db, set } = lockIndexDb(); + + await setCategoryLocks(db, 'playlist-1', 'series', []); + + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith({ locked: false }); + }); +}); + +describe('setCategoryLocks atomicity', () => { + it('runs both statements inside the transaction callback', async () => { + const order: string[] = []; + const run = jest.fn(() => order.push('run')); + const where = jest.fn().mockReturnValue({ run }); + const set = jest.fn().mockReturnValue({ where }); + const update = jest.fn().mockReturnValue({ set }); + const transaction = jest.fn((callback: () => void) => { + order.push('begin'); + callback(); + order.push('commit'); + }); + const db = { update, transaction } as unknown as AppDatabase; + + await setCategoryLocks(db, 'playlist-1', 'live', [5]); + + expect(order).toEqual(['begin', 'run', 'run', 'commit']); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/category.operations.ts b/apps/electron-backend/src/app/database/operations/category.operations.ts index 8bfd5b6e8..f1d3e2415 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.ts @@ -2,6 +2,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; +import { unlockedCategoryCondition } from '../parental-lock-state'; import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; type XtreamCategoryInput = { @@ -23,6 +24,7 @@ const categoryWireShape = { type: schema.categories.type, xtream_id: schema.categories.xtreamId, hidden: schema.categories.hidden, + locked: schema.categories.locked, }; function normalizeXtreamCategoryId( @@ -37,9 +39,11 @@ function normalizeXtreamCategories( playlistId: string, categories: XtreamCategoryInput[], type: 'live' | 'movies' | 'series', - hiddenCategoryXtreamIds?: number[] + hiddenCategoryXtreamIds?: number[], + lockedCategoryXtreamIds?: number[] ): XtreamCategoryValue[] { const hiddenSet = new Set(hiddenCategoryXtreamIds || []); + const lockedSet = new Set(lockedCategoryXtreamIds || []); return categories.flatMap((category) => { const xtreamId = normalizeXtreamCategoryId(category.category_id); @@ -55,6 +59,7 @@ function normalizeXtreamCategories( type, xtreamId, hidden: hiddenSet.has(xtreamId), + locked: lockedSet.has(xtreamId), }, ]; }); @@ -111,7 +116,8 @@ export async function getCategories( and( eq(schema.categories.playlistId, playlistId), eq(schema.categories.type, type), - eq(schema.categories.hidden, false) + eq(schema.categories.hidden, false), + unlockedCategoryCondition() ) ) .orderBy(schema.categories.id); @@ -131,6 +137,7 @@ export async function saveCategories( categories: XtreamCategoryInput[], type: 'live' | 'movies' | 'series', hiddenCategoryXtreamIds?: number[], + lockedCategoryXtreamIds?: number[], capturePhase?: DatabaseOperationPerformancePhaseCapture ): Promise<{ success: boolean }> { if (!categories || categories.length === 0) { @@ -159,7 +166,8 @@ export async function saveCategories( playlistId, categories, type, - hiddenCategoryXtreamIds + hiddenCategoryXtreamIds, + lockedCategoryXtreamIds ), (result) => ({ itemCount: result.length }) ) @@ -167,7 +175,8 @@ export async function saveCategories( playlistId, categories, type, - hiddenCategoryXtreamIds + hiddenCategoryXtreamIds, + lockedCategoryXtreamIds ); if (values.length === 0) { @@ -220,3 +229,43 @@ export async function updateCategoryVisibility( return { success: true }; } + +/** + * Re-stamps the parental lock index for one playlist and category type from + * the renderer's lock store: listed provider ids become locked, every other + * row of that playlist/type is unlocked. Idempotent by construction, so the + * renderer can replay the store after a refresh or a backup restore. + */ +export async function setCategoryLocks( + db: AppDatabase, + playlistId: string, + type: 'live' | 'movies' | 'series', + lockedXtreamIds: number[] +): Promise<{ success: boolean }> { + const scope = and( + eq(schema.categories.playlistId, playlistId), + eq(schema.categories.type, type) + ); + const lockedIds = [ + ...new Set(lockedXtreamIds.filter((id) => Number.isInteger(id))), + ]; + + // One transaction: a re-stamp that fails after the clear would otherwise + // leave every category of this playlist/type unlocked while the lock + // store still lists the intended locks. `.run()` (synchronous), not + // `.execute()`: see playback-position.operations.ts. + await db.transaction(() => { + db.update(schema.categories).set({ locked: false }).where(scope).run(); + + if (lockedIds.length > 0) { + db.update(schema.categories) + .set({ locked: true }) + .where( + and(scope, inArray(schema.categories.xtreamId, lockedIds)) + ) + .run(); + } + }); + + return { success: true }; +} 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 6a0a0b45c..6f6770ffd 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -15,6 +15,10 @@ import { XtreamGlobalSearchResult, } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; +import { + unlockedCategoryCondition, + unlockedCategorySql, +} from '../parental-lock-state'; import { countContentRowsByCategory, sumCategoryRowCounts, @@ -311,6 +315,7 @@ async function selectXtreamGlobalSearchCandidatesWithTitleIndex( )}) AND ${sql.join(titleConditions, sql` AND `)} ${excludeHidden ? sql`AND cat.hidden = 0` : sql``} + ${unlockedCategorySql()} ORDER BY c.title LIMIT ${candidateLimit} `)) as XtreamGlobalSearchCandidate[]; @@ -359,6 +364,7 @@ async function selectXtreamGlobalSearchCandidatesWithFts( : sql`` } ${excludeHidden ? sql`AND cat.hidden = 0` : sql``} + ${unlockedCategorySql()} ORDER BY rank, c.title LIMIT ${candidateLimit} `)) as XtreamGlobalSearchCandidate[]; @@ -382,6 +388,10 @@ async function selectXtreamGlobalSearchCandidatesWithContentScan( if (excludeHidden) { conditions.push(eq(schema.categories.hidden, false)); } + const unlockedCondition = unlockedCategoryCondition(); + if (unlockedCondition) { + conditions.push(unlockedCondition); + } return db .select({ @@ -694,7 +704,8 @@ export async function getContent( .where( and( eq(schema.categories.playlistId, playlistId), - eq(schema.content.type, type) + eq(schema.content.type, type), + unlockedCategoryCondition() ) ); @@ -775,6 +786,7 @@ function getGlobalRecentlyAddedByType( const whereConditions = [ eq(schema.content.type, type), eq(schema.categories.hidden, false), + unlockedCategoryCondition(), sql`${schema.content.added} <> ''`, sql`${schema.content.added} <= ${getXtreamRecentlyAddedMaxEpochSeconds()}`, ]; @@ -1074,6 +1086,10 @@ export async function searchContent( if (excludeHidden) { conditions.push(eq(schema.categories.hidden, false)); } + const unlockedCondition = unlockedCategoryCondition(); + if (unlockedCondition) { + conditions.push(unlockedCondition); + } const query = db .select(selectContentFields()) diff --git a/apps/electron-backend/src/app/database/operations/title-match.operations.ts b/apps/electron-backend/src/app/database/operations/title-match.operations.ts index f3f5e4c75..d4a6b9ee5 100644 --- a/apps/electron-backend/src/app/database/operations/title-match.operations.ts +++ b/apps/electron-backend/src/app/database/operations/title-match.operations.ts @@ -4,6 +4,7 @@ import { normalizeTitleKeys, } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; +import { unlockedCategorySql } from '../parental-lock-state'; /** * Batched cross-playlist title matching for the actor page's "All portals" @@ -75,6 +76,7 @@ export async function matchTitles( WHERE content_title_fts MATCH ${matchQuery} AND c.type IN ('movie', 'series') AND cat.hidden = 0 + ${unlockedCategorySql()} ORDER BY rank, c.title LIMIT ${PER_TITLE_CANDIDATE_LIMIT} `)) as TitleMatchRow[]; diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts index 1fbc7ca55..16d198456 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts @@ -5,6 +5,7 @@ import { type VodSourceCandidateRow, } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; +import { unlockedCategorySql } from '../parental-lock-state'; import { caseInsensitiveGlobBody, caseInsensitiveGlobPattern, @@ -258,6 +259,7 @@ function scanCandidateQuery( WHERE c.type = 'movie' AND cat.hidden = 0 AND p.type = 'xtream' + ${unlockedCategorySql()} AND ${wordMatches} ${excludePlaylist} ORDER BY LENGTH(c.title), c.title @@ -292,6 +294,7 @@ function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) { AND c.type = 'movie' AND cat.hidden = 0 AND p.type = 'xtream' + ${unlockedCategorySql()} ${excludePlaylist} GROUP BY cat.playlist_id, c.xtream_id ORDER BY rank, c.title diff --git a/apps/electron-backend/src/app/database/parental-lock-state.spec.ts b/apps/electron-backend/src/app/database/parental-lock-state.spec.ts new file mode 100644 index 000000000..5c68db44f --- /dev/null +++ b/apps/electron-backend/src/app/database/parental-lock-state.spec.ts @@ -0,0 +1,47 @@ +import { and, eq, type SQL } from 'drizzle-orm'; +import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core'; +import * as schema from '@iptvnator/shared/database/schema'; + +function renderSql(query: SQL): string { + return new SQLiteSyncDialect().sqlToQuery(query).sql; +} +import { + isParentalLockActive, + setParentalLockActive, + unlockedCategoryCondition, + unlockedCategorySql, +} from './parental-lock-state'; + +describe('parental-lock-state', () => { + afterEach(() => { + setParentalLockActive(false); + }); + + it('starts unlocked and contributes nothing to queries', () => { + expect(isParentalLockActive()).toBe(false); + expect(unlockedCategoryCondition()).toBeUndefined(); + expect(renderSql(unlockedCategorySql())).toBe(''); + // `and()` must tolerate the undefined condition so callers can add + // it unconditionally. + expect( + and(eq(schema.categories.type, 'live'), undefined) + ).toBeDefined(); + }); + + it('filters on categories.locked while active', () => { + setParentalLockActive(true); + + expect(isParentalLockActive()).toBe(true); + expect(unlockedCategoryCondition()).toEqual( + eq(schema.categories.locked, false) + ); + expect(renderSql(unlockedCategorySql())).toContain( + 'AND cat.locked = 0' + ); + }); + + it('coerces anything but true to inactive', () => { + setParentalLockActive('yes' as unknown as boolean); + expect(isParentalLockActive()).toBe(false); + }); +}); diff --git a/apps/electron-backend/src/app/database/parental-lock-state.ts b/apps/electron-backend/src/app/database/parental-lock-state.ts new file mode 100644 index 000000000..3648fcd8b --- /dev/null +++ b/apps/electron-backend/src/app/database/parental-lock-state.ts @@ -0,0 +1,39 @@ +import { eq, sql, type SQL } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; + +/** + * Process-wide parental lock enforcement flag for the SQLite worker. + * + * While active, every content-returning read appends "category is not + * locked", so a surface added later is withheld by default instead of + * leaking. The value arrives from the main process: seeded through + * `workerData` when the worker is (re)started and updated by the + * `DB_SET_PARENTAL_LOCK_STATE` request. It is deliberately not read from + * settings inside the worker, which has no access to the renderer's + * IndexedDB or to electron-conf. + */ +let parentalLockActive = false; + +export function setParentalLockActive(active: boolean): void { + parentalLockActive = active === true; +} + +export function isParentalLockActive(): boolean { + return parentalLockActive; +} + +/** + * Drizzle condition for query-builder reads joined on `categories`; + * `undefined` while unlocked so `and(...)` ignores it. + */ +export function unlockedCategoryCondition(): SQL | undefined { + return parentalLockActive ? eq(schema.categories.locked, false) : undefined; +} + +/** + * Raw fragment for `sql` template reads that alias the categories table as + * `cat`; empty while unlocked. + */ +export function unlockedCategorySql(): SQL { + return parentalLockActive ? sql`AND cat.locked = 0` : sql``; +} diff --git a/apps/electron-backend/src/app/events/database/category.events.ts b/apps/electron-backend/src/app/events/database/category.events.ts index e25d97bce..1e466db79 100644 --- a/apps/electron-backend/src/app/events/database/category.events.ts +++ b/apps/electron-backend/src/app/events/database/category.events.ts @@ -30,12 +30,27 @@ handleWorkerRequest( category_id: string | number; }>, type: 'live' | 'movies' | 'series', - hiddenCategoryXtreamIds?: number[] + hiddenCategoryXtreamIds?: number[], + lockedCategoryXtreamIds?: number[] ) => ({ playlistId, categories, type, hiddenCategoryXtreamIds, + lockedCategoryXtreamIds, + }) +); + +handleWorkerRequest( + 'DB_SET_CATEGORY_LOCKS', + ( + playlistId: string, + type: 'live' | 'movies' | 'series', + lockedXtreamIds: number[] + ) => ({ + playlistId, + type, + lockedXtreamIds: Array.isArray(lockedXtreamIds) ? lockedXtreamIds : [], }) ); diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index 5487705de..f50a2a7db 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -154,12 +154,13 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ }, { operation: 'DB_SAVE_CATEGORIES', - args: [playlistId, categories, 'live', categoryIds], + args: [playlistId, categories, 'live', categoryIds, categoryIds], payload: { playlistId, categories, type: 'live', hiddenCategoryXtreamIds: categoryIds, + lockedCategoryXtreamIds: categoryIds, }, }, { @@ -172,6 +173,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [categoryIds, true], payload: { categoryIds, hidden: true }, }, + { + operation: 'DB_SET_CATEGORY_LOCKS', + args: [playlistId, 'live', categoryIds], + payload: { playlistId, type: 'live', lockedXtreamIds: categoryIds }, + }, { operation: 'DB_HAS_CONTENT', args: [playlistId, 'movie'], diff --git a/apps/electron-backend/src/app/events/parental-lock.events.spec.ts b/apps/electron-backend/src/app/events/parental-lock.events.spec.ts new file mode 100644 index 000000000..0bad7e44c --- /dev/null +++ b/apps/electron-backend/src/app/events/parental-lock.events.spec.ts @@ -0,0 +1,96 @@ +type IpcHandler = (event: unknown, ...args: unknown[]) => Promise; + +const mockRegisteredHandlers = new Map(); +const mockSetParentalLockState = jest.fn(); +const mockStoreGet = jest.fn(); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn((channel: string, handler: IpcHandler) => { + mockRegisteredHandlers.set(channel, handler); + }), + }, +})); + +jest.mock('../services/database-worker-client', () => ({ + databaseWorkerClient: { + setParentalLockState: (...args: unknown[]) => + mockSetParentalLockState(...args), + }, +})); + +jest.mock('../services/store.service', () => ({ + PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED', + store: { get: (...args: unknown[]) => mockStoreGet(...args) }, +})); + +type Listener = (...args: unknown[]) => void; + +function createSender() { + const listeners = new Map(); + return { + id: 1, + on: jest.fn((event: string, listener: Listener) => { + listeners.set(event, listener); + }), + off: jest.fn(), + emit(event: string, ...args: unknown[]) { + listeners.get(event)?.(...args); + }, + }; +} + +describe('parental-lock.events', () => { + beforeEach(async () => { + jest.resetModules(); + mockRegisteredHandlers.clear(); + mockSetParentalLockState.mockReset().mockResolvedValue(undefined); + mockStoreGet.mockReset().mockReturnValue(true); + const module = await import('./parental-lock.events'); + module.default.bootstrapParentalLockEvents(); + }); + + it('forwards the renderer state to the worker and remembers it', async () => { + const { getParentalLockActive } = + await import('../services/parental-lock-state'); + const handler = mockRegisteredHandlers.get('PARENTAL_LOCK:SET_STATE'); + expect(handler).toBeDefined(); + + await handler?.({ sender: createSender() }, false); + expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false); + expect(getParentalLockActive()).toBe(false); + + await handler?.({ sender: createSender() }, 'yes'); + expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false); + }); + + it('locks again from the mirrored setting when the renderer reloads or dies', async () => { + const handler = mockRegisteredHandlers.get('PARENTAL_LOCK:SET_STATE'); + const sender = createSender(); + + await handler?.({ sender }, false); + mockSetParentalLockState.mockClear(); + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: true, + }); + expect(mockSetParentalLockState).not.toHaveBeenCalled(); + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + }); + expect(mockSetParentalLockState).toHaveBeenLastCalledWith(true); + + mockStoreGet.mockReturnValue(false); + sender.emit('render-process-gone'); + expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false); + }); + + it('seeds the main-process copy from the mirrored setting at bootstrap', async () => { + const { getParentalLockActive } = + await import('../services/parental-lock-state'); + expect(getParentalLockActive()).toBe(true); + }); +}); diff --git a/apps/electron-backend/src/app/events/parental-lock.events.ts b/apps/electron-backend/src/app/events/parental-lock.events.ts new file mode 100644 index 000000000..eed938196 --- /dev/null +++ b/apps/electron-backend/src/app/events/parental-lock.events.ts @@ -0,0 +1,85 @@ +import { ipcMain, WebContents } from 'electron'; +import { PARENTAL_LOCK_SET_STATE } from '@iptvnator/shared/interfaces'; +import { databaseWorkerClient } from '../services/database-worker-client'; +import { + getParentalLockActive, + setParentalLockActiveState, +} from '../services/parental-lock-state'; +import { PARENTAL_LOCK_ENABLED, store } from '../services/store.service'; + +/** + * Parental lock IPC. + * + * The renderer reports whether locked categories must be withheld (feature + * enabled and no PIN entered). The value is kept in the main process and + * pushed into the SQLite worker, which filters every content read on it. + * + * Like the playback keep-awake vote, the renderer's word must not outlive the + * page that gave it: on reload, navigation or a dead render process the flag + * falls back to the mirrored `parentalLockEnabled` setting, i.e. locked + * while the feature is on. A crashed page can therefore never leave the + * library unlocked. + */ + +const senderCleanups = new Map void>(); + +export async function applyParentalLockState(active: boolean): Promise { + setParentalLockActiveState(active); + try { + await databaseWorkerClient.setParentalLockState( + getParentalLockActive() + ); + } catch (error) { + console.error('Failed to update parental lock state:', error); + throw error; + } +} + +/** The state a renderer that has not announced itself yet must get. */ +export function defaultParentalLockState(): boolean { + return store.get(PARENTAL_LOCK_ENABLED, false) === true; +} + +function watchSenderLifetime(sender: WebContents): void { + const senderId = sender.id; + if (senderCleanups.has(senderId)) { + return; + } + const relock = () => { + void applyParentalLockState(defaultParentalLockState()).catch( + () => undefined + ); + }; + const onNavigation = ( + event: Electron.Event + ) => { + if (event.isMainFrame && !event.isSameDocument) { + relock(); + } + }; + const onDestroyed = () => { + relock(); + senderCleanups.get(senderId)?.(); + senderCleanups.delete(senderId); + }; + sender.on('destroyed', onDestroyed); + sender.on('render-process-gone', relock); + sender.on('did-start-navigation', onNavigation); + senderCleanups.set(senderId, () => { + sender.off('destroyed', onDestroyed); + sender.off('render-process-gone', relock); + sender.off('did-start-navigation', onNavigation); + }); +} + +export default class ParentalLockEvents { + static bootstrapParentalLockEvents(): Electron.IpcMain { + setParentalLockActiveState(defaultParentalLockState()); + return ipcMain; + } +} + +ipcMain.handle(PARENTAL_LOCK_SET_STATE, async (event, active: boolean) => { + watchSenderLifetime(event.sender); + await applyParentalLockState(active === true); +}); diff --git a/apps/electron-backend/src/app/events/settings.events.spec.ts b/apps/electron-backend/src/app/events/settings.events.spec.ts index a5cd5c03a..ebfbb2a74 100644 --- a/apps/electron-backend/src/app/events/settings.events.spec.ts +++ b/apps/electron-backend/src/app/events/settings.events.spec.ts @@ -38,6 +38,7 @@ const handlers = new Map(); const mockStoreGet = jest.fn(); const mockStoreSet = jest.fn(); const mockUpdateSettings = jest.fn(); +const mockApplyParentalLockState = jest.fn(); const mockIpcHandle = jest.fn( (channel: string, handler: SettingsUpdateHandler): void => { handlers.set(channel, handler); @@ -61,12 +62,18 @@ jest.mock('../services/store.service', () => ({ PORTAL_CONNECTIVITY_GUARD: STORE_KEYS.PORTAL_CONNECTIVITY_GUARD, VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS, VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE, + PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED', store: { get: mockStoreGet, set: mockStoreSet, }, })); +jest.mock('./parental-lock.events', () => ({ + applyParentalLockState: (...args: unknown[]) => + mockApplyParentalLockState(...args), +})); + jest.mock('../server/http-server', () => ({ httpServer: { updateSettings: mockUpdateSettings, @@ -84,6 +91,7 @@ describe('SETTINGS_UPDATE', () => { mockStoreGet.mockReset(); mockStoreSet.mockReset(); mockUpdateSettings.mockReset(); + mockApplyParentalLockState.mockReset().mockResolvedValue(undefined); mockStoreGet.mockImplementation( (_key: string, fallbackValue: unknown): unknown => fallbackValue ); @@ -136,6 +144,35 @@ describe('SETTINGS_UPDATE', () => { ); }); + it('mirrors the parental lock switch and releases the worker only on switch-off', () => { + // Off โ†’ on: persist; the renderer announces its own live state. + settingsUpdateHandler({}, { parentalLockEnabled: true }); + expect(mockStoreSet).toHaveBeenCalledWith( + 'PARENTAL_LOCK_ENABLED', + true + ); + expect(mockApplyParentalLockState).not.toHaveBeenCalled(); + + // An ordinary save carrying the unchanged flag must not re-lock a + // worker the renderer believes is unlocked. + mockStoreGet.mockImplementation((key: string, fallback: unknown) => + key === 'PARENTAL_LOCK_ENABLED' ? true : fallback + ); + settingsUpdateHandler( + {}, + { parentalLockEnabled: true, showCaptions: true } + ); + expect(mockApplyParentalLockState).not.toHaveBeenCalled(); + + // On โ†’ off: release at once. + settingsUpdateHandler({}, { parentalLockEnabled: false }); + expect(mockStoreSet).toHaveBeenCalledWith( + 'PARENTAL_LOCK_ENABLED', + false + ); + expect(mockApplyParentalLockState).toHaveBeenCalledWith(false); + }); + it('normalizes external-player arguments and preserves explicit false reuse settings', () => { settingsUpdateHandler( {}, diff --git a/apps/electron-backend/src/app/events/settings.events.ts b/apps/electron-backend/src/app/events/settings.events.ts index c289739c2..083cb4a93 100644 --- a/apps/electron-backend/src/app/events/settings.events.ts +++ b/apps/electron-backend/src/app/events/settings.events.ts @@ -11,6 +11,7 @@ import { EMBEDDED_MPV_FRAME_COPY, MPV_PLAYER_ARGUMENTS, MPV_REUSE_INSTANCE, + PARENTAL_LOCK_ENABLED, STARTUP_WINDOW_MODE, PORTAL_CONNECTIVITY_GUARD, store, @@ -19,6 +20,7 @@ import { } from '../services/store.service'; import { httpServer } from '../server/http-server'; import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard'; +import { applyParentalLockState } from './parental-lock.events'; import { persistAppUpdateChannel } from '../services/app-update-channel'; export default class SettingsEvents { @@ -42,6 +44,22 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { setHostConnectivityGuardEnabled(enabled); } + // Mirrored so the database worker and a reloaded renderer start locked + // whenever the feature is on. The LIVE enforcement state is the + // renderer's to announce through PARENTAL_LOCK_SET_STATE: every full + // settings save carries this flag unchanged, so applying it here would + // silently re-lock the worker under a renderer that still shows + // "unlocked". Only a switch-off releases the worker at once โ€” nothing + // may stay withheld once the feature is gone. + if (arg.parentalLockEnabled !== undefined) { + const enabled = arg.parentalLockEnabled === true; + const wasEnabled = store.get(PARENTAL_LOCK_ENABLED, false) === true; + store.set(PARENTAL_LOCK_ENABLED, enabled); + if (wasEnabled && !enabled) { + void applyParentalLockState(false).catch(() => undefined); + } + } + if (arg.mpvPlayerArguments !== undefined) { store.set( MPV_PLAYER_ARGUMENTS, diff --git a/apps/electron-backend/src/app/services/database-worker-client.spec.ts b/apps/electron-backend/src/app/services/database-worker-client.spec.ts index cfffa699a..5b711f2ba 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.spec.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.spec.ts @@ -86,6 +86,7 @@ describe('DatabaseWorkerClient', () => { nativeModuleSearchPaths: [ '/mock/resources/app.asar.unpacked/node_modules', ], + parentalLockActive: false, }, }); diff --git a/apps/electron-backend/src/app/services/database-worker-client.ts b/apps/electron-backend/src/app/services/database-worker-client.ts index 033aa99b0..898f09368 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.ts @@ -2,6 +2,7 @@ import { app } from 'electron'; import { randomUUID } from 'crypto'; import * as path from 'path'; import { pathToFileURL } from 'url'; +import { getParentalLockActive } from './parental-lock-state'; import { Worker } from 'worker_threads'; import type { DbOperationEvent, @@ -83,6 +84,19 @@ export class DatabaseWorkerClient { }); } + /** + * Flips the worker's parental lock filter. Awaits readiness so the + * message is never lost to a worker that has not started, and is posted + * on the request port so later reads observe it in order. + */ + async setParentalLockState(active: boolean): Promise { + await this.ensureWorker(); + this.worker?.postMessage({ + type: 'parental-lock', + active: active === true, + }); + } + async cancel(operationId: string): Promise<{ success: boolean }> { if (!operationId) { return { success: false }; @@ -157,6 +171,10 @@ export class DatabaseWorkerClient { this.worker = new Worker(workerURL, { workerData: { nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths, + // Seeded here rather than requested afterwards, so a + // restarted worker never answers a read unfiltered while + // the lock state is still in flight. + parentalLockActive: getParentalLockActive(), }, }); } catch (error) { diff --git a/apps/electron-backend/src/app/services/parental-lock-state.ts b/apps/electron-backend/src/app/services/parental-lock-state.ts new file mode 100644 index 000000000..a9bbb8617 --- /dev/null +++ b/apps/electron-backend/src/app/services/parental-lock-state.ts @@ -0,0 +1,18 @@ +/** + * Main-process copy of the parental lock enforcement flag. + * + * The renderer owns the unlock decision and reports it over + * `PARENTAL_LOCK_SET_STATE`; this module remembers the latest value so the + * database worker can be seeded with it whenever it is (re)started, and so a + * renderer reload or crash can fall back to "locked while the feature is on" + * without waiting for a page that may never come back. + */ +let parentalLockActive = false; + +export function getParentalLockActive(): boolean { + return parentalLockActive; +} + +export function setParentalLockActiveState(active: boolean): void { + parentalLockActive = active === true; +} diff --git a/apps/electron-backend/src/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index 9b1203ed3..c8b6ffbee 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -33,6 +33,14 @@ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE'; /** Desktop portal request cooldown; absent means enabled. */ export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD'; +/** + * Parental lock feature switch, mirrored from the renderer's settings by the + * SETTINGS_UPDATE handler. Read when the database worker starts and when a + * renderer reloads or dies, so the SQLite reads are locked before any page + * has announced its lock state. Absent means off. + */ +export const PARENTAL_LOCK_ENABLED = 'PARENTAL_LOCK_ENABLED'; + /** * Update channel (`stable` / `nightly`). Mirrored here from the renderer's * settings by the SETTINGS_UPDATE handler because the startup update check @@ -78,6 +86,7 @@ export type StoreType = { [EMBEDDED_MPV_AUTO_RECONNECT]: boolean; [STARTUP_WINDOW_MODE]: StartupWindowMode; [PORTAL_CONNECTIVITY_GUARD]: boolean; + [PARENTAL_LOCK_ENABLED]: boolean; [APP_UPDATE_CHANNEL]: AppUpdateChannel; [TRUSTED_LOCAL_EPG_SOURCES]: string[]; }; diff --git a/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts b/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts new file mode 100644 index 000000000..c1f54988e --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts @@ -0,0 +1,145 @@ +import { EventEmitter } from 'node:events'; + +import { createDeferredBootstrap } from './deferred-bootstrap'; + +interface FakeModule { + readonly name: string; +} + +const fakeModule: FakeModule = { name: 'deferred' }; + +/** Mirrors webpack's node chunk loading: a synchronous require behind a resolved promise. */ +const loadResolved = () => Promise.resolve(fakeModule); + +function macrotask(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +describe('deferred main-process bootstrap', () => { + it('registers handlers before the next macrotask once the window starts loading', async () => { + const order: string[] = []; + const webContents = new EventEmitter(); + const bootstrap = createDeferredBootstrap({ + load: loadResolved, + run: (module) => { + order.push(`run:${module.name}`); + return 'registered'; + }, + }); + bootstrap.armOn(webContents); + + // An IPC message that the renderer sends right after it starts + // loading arrives as a macrotask; it must queue behind registration. + setImmediate(() => order.push('renderer-ipc')); + webContents.emit('did-start-loading'); + await macrotask(); + + expect(order).toEqual(['run:deferred', 'renderer-ipc']); + expect(bootstrap.module).toBe(fakeModule); + }); + + it('runs the deferred work exactly once across both triggers', async () => { + const run = jest.fn(() => 'once'); + const load = jest.fn(loadResolved); + const webContents = new EventEmitter(); + const bootstrap = createDeferredBootstrap({ load, run }); + bootstrap.armOn(webContents); + + webContents.emit('did-start-loading'); + webContents.emit('did-start-loading'); + const explicit = bootstrap.trigger(); + const outcome = await explicit; + + expect(load).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(1); + expect(outcome).toEqual({ module: fakeModule, result: 'once' }); + await expect(bootstrap.trigger()).resolves.toBe(outcome); + }); + + it('falls back to the explicit trigger when no window is available', async () => { + const sources: string[] = []; + const bootstrap = createDeferredBootstrap({ + load: loadResolved, + run: () => undefined, + onTrigger: (source) => sources.push(source), + }); + + bootstrap.armOn(null); + bootstrap.armOn(undefined); + await bootstrap.trigger(); + + expect(sources).toEqual(['explicit']); + }); + + it('reports the trigger source and the duration', async () => { + const onTrigger = jest.fn(); + const onDone = jest.fn(); + const webContents = new EventEmitter(); + const bootstrap = createDeferredBootstrap({ + load: loadResolved, + run: () => undefined, + onTrigger, + onDone, + }); + bootstrap.armOn(webContents); + + webContents.emit('did-start-loading'); + await bootstrap.trigger(); + + expect(onTrigger).toHaveBeenCalledTimes(1); + expect(onTrigger).toHaveBeenCalledWith('did-start-loading'); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onDone.mock.calls[0][0]).toBeGreaterThanOrEqual(0); + }); + + it('surfaces a failed load to every awaiting caller without running handlers', async () => { + const run = jest.fn(); + const onError = jest.fn(); + const bootstrap = createDeferredBootstrap({ + load: () => Promise.reject(new Error('chunk missing')), + run, + onError, + }); + + const first = bootstrap.trigger(); + const second = bootstrap.trigger(); + + await expect(first).rejects.toThrow('chunk missing'); + await expect(second).rejects.toThrow('chunk missing'); + expect(run).not.toHaveBeenCalled(); + expect(bootstrap.module).toBeNull(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('reports a failure fired by the window event instead of leaving it unhandled', async () => { + const unhandled = jest.fn(); + process.on('unhandledRejection', unhandled); + try { + const onError = jest.fn(); + const webContents = new EventEmitter(); + const bootstrap = createDeferredBootstrap({ + load: loadResolved, + run: () => { + throw new Error('handler registration failed'); + }, + onError, + }); + bootstrap.armOn(webContents); + + webContents.emit('did-start-loading'); + await macrotask(); + await macrotask(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(unhandled).not.toHaveBeenCalled(); + // A later awaiting caller still sees the failure. + await expect(bootstrap.trigger()).rejects.toThrow( + 'handler registration failed' + ); + expect(onError).toHaveBeenCalledTimes(1); + } finally { + process.off('unhandledRejection', unhandled); + } + }); +}); diff --git a/apps/electron-backend/src/app/startup/deferred-bootstrap.ts b/apps/electron-backend/src/app/startup/deferred-bootstrap.ts new file mode 100644 index 000000000..a3394f951 --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-bootstrap.ts @@ -0,0 +1,88 @@ +/** + * Runs a deferred piece of main-process startup exactly once, triggered by + * the main window's `did-start-loading` event or, as a fallback, explicitly. + * + * Ordering guarantee relied on by main.ts: `load()` is a webpack dynamic + * import of a sibling chunk, which on the Electron main target is a + * synchronous `require` wrapped in an already-resolved promise, and `run()` + * registers IPC handlers synchronously. Both therefore finish within the + * microtask checkpoint of the task that fired the trigger. A renderer IPC + * message is delivered as a separate macrotask, so no `invoke` can arrive + * between the renderer starting to load and the handlers existing. + */ +export interface DeferredBootstrapOptions { + readonly load: () => Promise; + readonly run: (module: TModule) => TResult; + readonly onTrigger?: (source: DeferredBootstrapTrigger) => void; + readonly onDone?: (durationMs: number) => void; + /** + * Called once when the load or the registration fails. The event + * listener has no caller to report to, so without this a failure would + * only surface as an unhandled rejection; the promise returned by + * `trigger()` still rejects for callers that await it. + */ + readonly onError?: (error: unknown) => void; +} + +export type DeferredBootstrapTrigger = 'did-start-loading' | 'explicit'; + +export interface DeferredBootstrapOutcome { + readonly module: TModule; + readonly result: TResult; +} + +export interface DeferredBootstrapWebContents { + once(event: 'did-start-loading', listener: () => void): unknown; +} + +export interface DeferredBootstrap { + /** The loaded module, or null until the trigger has fired. */ + readonly module: TModule | null; + /** Arms the `did-start-loading` trigger; a missing webContents is a no-op. */ + armOn(webContents: DeferredBootstrapWebContents | null | undefined): void; + /** Starts load + run if not started yet; always returns the same promise. */ + trigger( + source?: DeferredBootstrapTrigger + ): Promise>; +} + +export function createDeferredBootstrap( + options: DeferredBootstrapOptions +): DeferredBootstrap { + let started: Promise> | null = + null; + let loadedModule: TModule | null = null; + + const trigger = ( + source: DeferredBootstrapTrigger = 'explicit' + ): Promise> => { + if (started) { + return started; + } + + options.onTrigger?.(source); + const startedAt = performance.now(); + started = options.load().then((module) => { + loadedModule = module; + const result = options.run(module); + options.onDone?.(performance.now() - startedAt); + return { module, result }; + }); + started.catch((error: unknown) => options.onError?.(error)); + return started; + }; + + return { + get module() { + return loadedModule; + }, + armOn(webContents) { + webContents?.once('did-start-loading', () => { + // Rejections are reported through onError and re-surface to + // whoever awaits trigger(); nothing to handle here. + trigger('did-start-loading').catch(() => undefined); + }); + }, + trigger, + }; +} diff --git a/apps/electron-backend/src/app/startup/deferred-events.ts b/apps/electron-backend/src/app/startup/deferred-events.ts new file mode 100644 index 000000000..03e41d8f9 --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-events.ts @@ -0,0 +1,180 @@ +/** + * Main-process work that only has to exist once the renderer has started + * loading: portal, EPG, download, player, probe, remote-control and update + * IPC, the database, and the recovery passes that follow the first load. + * + * main.ts loads this module through a dynamic import inside the main + * window's `did-start-loading` listener (see deferred-bootstrap.ts), so the + * heavy dependencies it pulls in (axios, drizzle-orm, better-sqlite3, + * electron-updater, fix-path) are evaluated while the renderer parses and + * runs its own bundle instead of before the window can load at all. + * + * Keep `bootstrapDeferredEvents()` synchronous: the guarantee that no + * renderer `invoke` finds a missing handler depends on it. + */ +import { app } from 'electron'; +import { autoUpdater } from 'electron-updater'; +import { registerM3uSourceProbe } from '../events/m3u-source-probe'; +import { registerSourceProbeCancellation } from '../events/source-probe-control'; +import App from '../app'; +import { initDatabase } from '../database/connection'; +import DatabaseEvents from '../events/database.events'; +import { + resetStaleDownloads, + setMainWindow as setDownloadsMainWindow, +} from '../events/database/downloads.events'; +import { setRecordingsMainWindow } from '../events/database/recording-broadcast'; +import { reconcileStaleRecordings } from '../events/database/recording-recovery'; +import ElectronEvents from '../events/electron.events'; +import EmbeddedMpvEvents, { + shutdownEmbeddedMpv, +} from '../events/embedded-mpv.events'; +import EpgEvents from '../events/epg.events'; +import AppUpdateEvents from '../events/app-update.events'; +import { shutdownMpvSession } from '../events/mpv-session.service'; +import PlayerEvents from '../events/player.events'; +import { shutdownVlcSession } from '../events/vlc-session.service'; +import PlaylistEvents from '../events/playlist.events'; +import ParentalLockEvents from '../events/parental-lock.events'; +import RemoteControlEvents from '../events/remote-control.events'; +import SettingsEvents from '../events/settings.events'; +import SharedEvents from '../events/shared.events'; +import StalkerEvents from '../events/stalker.events'; +import XtreamEvents from '../events/xtream.events'; +import { registerStreamProbeHandlers } from '../events/stream-probe'; +import { registerConnectivityGuardHandlers } from '../events/connectivity-guard.events'; +import { isStartupTraceEnabled, trace } from '../services/debug-trace'; +import { AppUpdateService } from '../services/app-update.service'; +import { + onAppUpdateChannelChange, + readStoredAppUpdateChannel, +} from '../services/app-update-channel'; +import { databaseWorkerClient } from '../services/database-worker-client'; +import type { bootstrapWindowCloseGuard } from '../services/window-close-guard.service'; + +export interface DeferredEventsContext { + readonly appVersion: string; + readonly windowCloseGuard: ReturnType; +} + +export interface DeferredEventsHandles { + readonly appUpdateService: AppUpdateService; +} + +export function bootstrapDeferredEvents( + context: DeferredEventsContext +): DeferredEventsHandles { + const { windowCloseGuard } = context; + const appUpdateService = new AppUpdateService({ + app, + appVersion: context.appVersion, + channel: readStoredAppUpdateChannel(), + getMainWindow: () => App.mainWindow, + updater: () => autoUpdater, + // quitAndInstall() closes the windows before 'before-quit' fires + // (macOS), so without this an armed close guard would intercept + // the install's window close and strand the update. + prepareQuit: () => windowCloseGuard.allowNextClose(), + cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(), + }); + AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService); + onAppUpdateChannelChange((channel) => appUpdateService.setChannel(channel)); + + ElectronEvents.bootstrapElectronEvents(); + EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents(); + PlaylistEvents.bootstrapPlaylistEvents(); + SharedEvents.bootstrapSharedEvents(); + PlayerEvents.bootstrapPlayerEvents(); + SettingsEvents.bootstrapSettingsEvents(); + ParentalLockEvents.bootstrapParentalLockEvents(); + StalkerEvents.bootstrapStalkerEvents(); + XtreamEvents.bootstrapXtreamEvents(); + registerStreamProbeHandlers(); + registerM3uSourceProbe(); + registerSourceProbeCancellation(); + registerConnectivityGuardHandlers(); + DatabaseEvents.bootstrapDatabaseEvents(); + EpgEvents.bootstrapEpgEvents(); + RemoteControlEvents.bootstrapRemoteControlEvents(); + + // Keep the downloads broadcaster bound to the live window. macOS can + // rebuild the window while the process runs, and a stale reference + // silently swallows every DOWNLOADS_UPDATE_EVENT. + App.onMainWindowCreated(setDownloadsMainWindow); + App.onMainWindowCreated(setRecordingsMainWindow); + + return { appUpdateService }; +} + +/** + * Database initialization and recovery, after the first renderer load is + * underway so Linux Electron E2E can observe a BrowserWindow even when + * SQLite startup or download recovery is slow. IPC handlers call + * getDatabase() lazily and share the same initialization promise. + */ +export async function finishStartupAfterFirstLoad(): Promise { + await initDatabase(); + + if (isStartupTraceEnabled()) { + trace('startup', 'init-database:done'); + } + + await resetStaleDownloads(); + + if (isStartupTraceEnabled()) { + trace('startup', 'reset-stale-downloads:done'); + } + + await reconcileStaleRecordings(); + + if (isStartupTraceEnabled()) { + trace('startup', 'reconcile-stale-recordings:done'); + } +} + +let fixPathScheduled = false; + +/** + * Update process.env.PATH from the user's interactive login shell so that + * spawned external players (MPV/VLC) can be resolved by binary name. + * + * Runs after window creation + IPC handler registration so the 50-300 ms + * shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent: + * subsequent calls are no-ops. fix-path itself is imported here, on demand, + * so its module evaluation stays off the launch path as well. + */ +export function scheduleDeferredFixPath(): void { + if (fixPathScheduled || process.platform === 'win32') { + return; + } + + fixPathScheduled = true; + setImmediate(() => { + import('fix-path') + .then(({ default: fixPath }) => { + fixPath(); + if (isStartupTraceEnabled()) { + trace('startup', 'fix-path:done'); + } + }) + .catch((error) => { + console.warn('fix-path failed:', error); + }); + }); +} + +/** Tears down sessions and the DB worker; safe when nothing was started. */ +export function shutdownDeferredServices(): void { + shutdownEmbeddedMpv(); + shutdownMpvSession(); + shutdownVlcSession(); + void databaseWorkerClient.shutdown(); +} + +/** The module shape main.ts receives from its dynamic import. */ +export type DeferredEventsModule = { + readonly bootstrapDeferredEvents: typeof bootstrapDeferredEvents; + readonly finishStartupAfterFirstLoad: typeof finishStartupAfterFirstLoad; + readonly scheduleDeferredFixPath: typeof scheduleDeferredFixPath; + readonly shutdownDeferredServices: typeof shutdownDeferredServices; +}; diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 36db8a5d4..2aa403a03 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -6,6 +6,7 @@ export const DB_WORKER_OPERATIONS = [ 'DB_SAVE_CATEGORIES', 'DB_GET_ALL_CATEGORIES', 'DB_UPDATE_CATEGORY_VISIBILITY', + 'DB_SET_CATEGORY_LOCKS', 'DB_HAS_CONTENT', 'DB_GET_CONTENT', 'DB_GET_GLOBAL_RECENTLY_ADDED', @@ -131,6 +132,16 @@ export interface DbWorkerCancelMessage { operationId: string; } +/** + * Main-process control message, not a renderer request: flips the worker's + * parental lock filter. Ordered with the requests on the same port, so a + * read posted after it observes the new state. + */ +export interface DbWorkerParentalLockMessage { + type: 'parental-lock'; + active: boolean; +} + export interface DbWorkerReadyMessage { type: 'ready'; } @@ -158,7 +169,9 @@ export interface DbWorkerResponseMessage { } export type DbWorkerIncomingMessage = - DbWorkerRequestMessage | DbWorkerCancelMessage; + | DbWorkerRequestMessage + | DbWorkerCancelMessage + | DbWorkerParentalLockMessage; export type DbWorkerMessage = | DbWorkerReadyMessage diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 042c11744..73b780635 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -3,7 +3,7 @@ import { closeWorkerDatabase, getWorkerDatabase, } from './database.worker-connection'; -import { parentPort } from 'worker_threads'; +import { parentPort, workerData } from 'worker_threads'; import type { ContentMetadataPatch, VodSourcePin, @@ -25,8 +25,10 @@ import { getCategories, hasCategories, saveCategories, + setCategoryLocks, updateCategoryVisibility, } from '../database/operations/category.operations'; +import { setParentalLockActive } from '../database/parental-lock-state'; import { addFavorite, getAllGlobalFavorites, @@ -133,6 +135,12 @@ import { } from './operation-progress-throttle'; const loggerLabel = '[DB Worker]'; +// Seeded by the main process so a (re)started worker is locked before its +// first read; DB_SET_PARENTAL_LOCK_STATE updates it afterwards. +setParentalLockActive( + (workerData as { parentalLockActive?: unknown } | undefined) + ?.parentalLockActive === true +); const batchDelayMs = Number.parseInt( process.env['IPTVNATOR_DB_WORKER_BATCH_DELAY_MS'] ?? '0', 10 @@ -440,6 +448,7 @@ async function executeRequest( }>; type: 'live' | 'movies' | 'series'; hiddenCategoryXtreamIds?: number[]; + lockedCategoryXtreamIds?: number[]; }; const capturePhase = createWorkerPerformancePhaseAdapter(performanceCapture); @@ -449,6 +458,7 @@ async function executeRequest( payload.categories, payload.type, payload.hiddenCategoryXtreamIds, + payload.lockedCategoryXtreamIds, capturePhase ); } @@ -473,6 +483,20 @@ async function executeRequest( ); } + case 'DB_SET_CATEGORY_LOCKS': { + const payload = message.payload as { + playlistId: string; + type: 'live' | 'movies' | 'series'; + lockedXtreamIds: number[]; + }; + return setCategoryLocks( + db, + payload.playlistId, + payload.type, + payload.lockedXtreamIds + ); + } + case 'DB_HAS_CONTENT': { const payload = message.payload as { playlistId: string; @@ -1246,6 +1270,11 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { return; } + if (message.type === 'parental-lock') { + setParentalLockActive(message.active === true); + return; + } + if (message.type === 'cancel') { const activeOperation = activeOperations.get(message.operationId); if (activeOperation) { diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 1bc492d8f..0961a0f9b 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -1,51 +1,24 @@ -import { registerM3uSourceProbe } from './app/events/m3u-source-probe'; -import { registerSourceProbeCancellation } from './app/events/source-probe-control'; // Select persistence before eager imports (notably electron-conf) cache userData. import './app/services/electron-profile-bootstrap'; import { app, BrowserWindow } from 'electron'; -import { autoUpdater } from 'electron-updater'; -import fixPath from 'fix-path'; import App from './app/app'; -import { initDatabase } from './app/database/connection'; -import DatabaseEvents from './app/events/database.events'; -import { - resetStaleDownloads, - setMainWindow as setDownloadsMainWindow, -} from './app/events/database/downloads.events'; -import { setRecordingsMainWindow } from './app/events/database/recording-broadcast'; -import { reconcileStaleRecordings } from './app/events/database/recording-recovery'; -import ElectronEvents from './app/events/electron.events'; -import EmbeddedMpvEvents, { - shutdownEmbeddedMpv, -} from './app/events/embedded-mpv.events'; -import EpgEvents from './app/events/epg.events'; -import AppUpdateEvents from './app/events/app-update.events'; -import { shutdownMpvSession } from './app/events/mpv-session.service'; -import PlayerEvents from './app/events/player.events'; -import { shutdownVlcSession } from './app/events/vlc-session.service'; -import PlaylistEvents from './app/events/playlist.events'; import PlaylistOpenEvents from './app/events/playlist-open.events'; -import RemoteControlEvents from './app/events/remote-control.events'; -import SettingsEvents from './app/events/settings.events'; -import SharedEvents from './app/events/shared.events'; import SquirrelEvents from './app/events/squirrel.events'; -import StalkerEvents from './app/events/stalker.events'; import { isStartupTraceEnabled, trace } from './app/services/debug-trace'; import { readCompileCacheOutcome } from './app/services/compile-cache'; import { applyElectronNetworkDefaults } from './app/util/network-defaults'; import { registerStaticHeaderShims } from './app/services/request-header-overrides.service'; -import { AppUpdateService } from './app/services/app-update.service'; -import { - onAppUpdateChannelChange, - readStoredAppUpdateChannel, -} from './app/services/app-update-channel'; -import { databaseWorkerClient } from './app/services/database-worker-client'; import WindowEvents from './app/events/window.events'; import { bootstrapWindowCloseGuard } from './app/services/window-close-guard.service'; -import { registerStreamProbeHandlers } from './app/events/stream-probe'; -import { registerConnectivityGuardHandlers } from './app/events/connectivity-guard.events'; -import XtreamEvents from './app/events/xtream.events'; import { environment } from './environments/environment'; +import { + createDeferredBootstrap, + type DeferredBootstrap, +} from './app/startup/deferred-bootstrap'; +import type { + DeferredEventsHandles, + DeferredEventsModule, +} from './app/startup/deferred-events'; import { isFrameCopyRuntimeUsable, shouldPromotePersistedFrameCopyOptIn, @@ -104,33 +77,11 @@ if ( process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; } -let fixPathScheduled = false; - -/** - * Update process.env.PATH from the user's interactive login shell so that - * spawned external players (MPV/VLC) can be resolved by binary name. - * - * Runs after window creation + IPC handler registration so the 50-300 ms - * shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent: - * subsequent calls are no-ops. - */ -function scheduleDeferredFixPath(): void { - if (fixPathScheduled || process.platform === 'win32') { - return; - } - - fixPathScheduled = true; - setImmediate(() => { - try { - fixPath(); - if (isStartupTraceEnabled()) { - trace('startup', 'fix-path:done'); - } - } catch (error) { - console.warn('fix-path failed:', error); - } - }); -} +/** Set once bootstrapAppEvents() arms the deferred group; read at quit. */ +let deferredEvents: DeferredBootstrap< + DeferredEventsModule, + DeferredEventsHandles +> | null = null; export default class Main { static initialize() { @@ -147,6 +98,13 @@ export default class Main { App.main(app, BrowserWindow); } + /** + * Everything the renderer may call before its first paint registers + * here, synchronously, before the window loads. The rest lives in + * app/startup/deferred-events.ts and is loaded inside the window's + * `did-start-loading` listener (see deferred-bootstrap.ts for why that + * still guarantees the handlers exist before any renderer invoke). + */ static async bootstrapAppEvents() { if (isStartupTraceEnabled()) { trace('startup', 'bootstrap-events:start'); @@ -155,75 +113,62 @@ export default class Main { const windowCloseGuard = bootstrapWindowCloseGuard((listener) => App.onMainWindowCreated(listener) ); - const appUpdateService = new AppUpdateService({ - app, - appVersion: environment.version, - channel: readStoredAppUpdateChannel(), - getMainWindow: () => App.mainWindow, - updater: () => autoUpdater, - // quitAndInstall() closes the windows before 'before-quit' fires - // (macOS), so without this an armed close guard would intercept - // the install's window close and strand the update. - prepareQuit: () => windowCloseGuard.allowNextClose(), - cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(), - }); - AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService); - onAppUpdateChannelChange((channel) => - appUpdateService.setChannel(channel) - ); - registerStaticHeaderShims(); - ElectronEvents.bootstrapElectronEvents(); WindowEvents.bootstrapWindowEvents(); - EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents(); - PlaylistEvents.bootstrapPlaylistEvents(); PlaylistOpenEvents.bootstrapPlaylistOpenEvents(); - SharedEvents.bootstrapSharedEvents(); - PlayerEvents.bootstrapPlayerEvents(); - SettingsEvents.bootstrapSettingsEvents(); - StalkerEvents.bootstrapStalkerEvents(); - XtreamEvents.bootstrapXtreamEvents(); - registerStreamProbeHandlers(); - registerM3uSourceProbe(); - registerSourceProbeCancellation(); - registerConnectivityGuardHandlers(); - DatabaseEvents.bootstrapDatabaseEvents(); - EpgEvents.bootstrapEpgEvents(); - RemoteControlEvents.bootstrapRemoteControlEvents(); - // Keep the downloads broadcaster bound to the live window. macOS can - // rebuild the window while the process runs, and a stale reference - // silently swallows every DOWNLOADS_UPDATE_EVENT. - App.onMainWindowCreated(setDownloadsMainWindow); - App.onMainWindowCreated(setRecordingsMainWindow); + const deferred = createDeferredBootstrap< + DeferredEventsModule, + DeferredEventsHandles + >({ + load: () => + import( + /* webpackChunkName: "deferred-events" */ './app/startup/deferred-events.js' + ), + run: (module) => + module.bootstrapDeferredEvents({ + appVersion: environment.version, + windowCloseGuard, + }), + onTrigger: (source) => { + if (isStartupTraceEnabled()) { + trace('startup', 'deferred-events:start', { source }); + } + }, + onDone: (durationMs) => { + if (isStartupTraceEnabled()) { + trace('startup', 'deferred-events:done', { durationMs }); + } + }, + // The window is open by now; without this a missing chunk would + // only show up as an unhandled rejection with no context. + onError: (error) => { + console.error( + 'Deferred main-process startup failed; portal, EPG, database and download handlers are unavailable:', + error + ); + if (isStartupTraceEnabled()) { + trace('startup', 'deferred-events:failed', error); + } + }, + }); + deferredEvents = deferred; + deferred.armOn(App.mainWindow?.webContents); - // Load the renderer only after IPC handlers are registered. On slower - // Linux CI hosts the renderer can otherwise invoke Electron bridge IPC - // before the main process has installed handlers. - await App.loadMainWindow(); - void appUpdateService.checkForUpdatesOnStartup(); + // Load the renderer only after the pre-paint handlers are registered. + // The deferred group registers as soon as the navigation starts; the + // fallback below covers a load that never gets that far. Its errors + // surface through the awaited trigger(), so they are swallowed here. + const loadingMainWindow = App.loadMainWindow(); + void loadingMainWindow + .catch(() => undefined) + .then(() => deferred.trigger()) + .catch(() => undefined); + await loadingMainWindow; + const { module, result } = await deferred.trigger(); + void result.appUpdateService.checkForUpdatesOnStartup(); - // Initialize the database after the first renderer load is underway so - // Linux Electron E2E can observe a BrowserWindow even when SQLite - // startup or download recovery is slow. IPC handlers call getDatabase() - // lazily and share the same initialization promise. - await initDatabase(); - - if (isStartupTraceEnabled()) { - trace('startup', 'init-database:done'); - } - - await resetStaleDownloads(); - - if (isStartupTraceEnabled()) { - trace('startup', 'reset-stale-downloads:done'); - } - - await reconcileStaleRecordings(); - - if (isStartupTraceEnabled()) { - trace('startup', 'reconcile-stale-recordings:done'); - } + await module.finishStartupAfterFirstLoad(); if (isStartupTraceEnabled()) { trace('startup', 'bootstrap-events:done'); @@ -236,7 +181,7 @@ export default class Main { // takes to complete; the spawn would still find MPV/VLC at any of // the well-known paths checked by getDefault*Path before falling // back to bare-name PATH lookup. - scheduleDeferredFixPath(); + module.scheduleDeferredFixPath(); } } @@ -307,9 +252,7 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { // playback and database work destroyed. 'will-quit' only fires once // every window close was allowed through. app.on('will-quit', () => { - shutdownEmbeddedMpv(); - shutdownMpvSession(); - shutdownVlcSession(); - void databaseWorkerClient.shutdown(); + // Nothing to tear down when the deferred group never loaded. + deferredEvents?.module?.shutdownDeferredServices(); }); }); diff --git a/apps/electron-backend/tsconfig.spec.json b/apps/electron-backend/tsconfig.spec.json index ae084b520..be2310d6e 100644 --- a/apps/electron-backend/tsconfig.spec.json +++ b/apps/electron-backend/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../dist/out-tsc", "esModuleInterop": true, "allowJs": true, diff --git a/apps/electron-backend/webpack.config.cjs b/apps/electron-backend/webpack.config.cjs new file mode 100644 index 000000000..0923ee17a --- /dev/null +++ b/apps/electron-backend/webpack.config.cjs @@ -0,0 +1,21 @@ +/** + * nx-electron build hook (project.json `webpackConfig`). + * + * The backend compiles with TypeScript's NodeNext resolution, which spells a + * relative dynamic import with a `.js` extension (main.ts loads + * `./app/startup/deferred-events.js`). webpack must map that back onto the + * `.ts` source, which is what `resolve.extensionAlias` does. + */ +module.exports = (config) => { + // Async chunks keep their webpackChunkName instead of a numeric id, so + // packaging and the layout check can list them by name. + config.output = { ...config.output, chunkFilename: '[name].js' }; + config.resolve = { + ...config.resolve, + extensionAlias: { + ...config.resolve?.extensionAlias, + '.js': ['.ts', '.js'], + }, + }; + return config; +}; diff --git a/apps/remote-control-web/tsconfig.spec.json b/apps/remote-control-web/tsconfig.spec.json index cc2617958..19497995e 100644 --- a/apps/remote-control-web/tsconfig.spec.json +++ b/apps/remote-control-web/tsconfig.spec.json @@ -2,12 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/apps/stalker-mock-server/tsconfig.spec.json b/apps/stalker-mock-server/tsconfig.spec.json index 09849f85f..83f6021cc 100644 --- a/apps/stalker-mock-server/tsconfig.spec.json +++ b/apps/stalker-mock-server/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "rootDir": ".", "outDir": "../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", diff --git a/apps/web-backend/tsconfig.spec.json b/apps/web-backend/tsconfig.spec.json index 09849f85f..58b060efd 100644 --- a/apps/web-backend/tsconfig.spec.json +++ b/apps/web-backend/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Opt out of shared web player controls.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Opt out of shared web player controls.png new file mode 100644 index 000000000..ef815e67e Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Opt out of shared web player controls.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Parental lock โ€” set a PIN, lock, survive a reload, unlock.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Parental lock โ€” set a PIN, lock, survive a reload, unlock.png new file mode 100644 index 000000000..19cf5b8a6 Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Parental lock โ€” set a PIN, lock, survive a reload, unlock.png differ diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index 1ed31f3f8..e12bb9d7b 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -12,7 +12,9 @@ async function openSettings(page: Page) { /** Settings render one section page at a time โ€” open it via the rail. */ async function openSettingsSection(page: Page, sectionId: string) { - await page.locator(`[data-test-id="settings-section-${sectionId}"]`).click(); + await page + .locator(`[data-test-id="settings-section-${sectionId}"]`) + .click(); await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`)); } @@ -41,11 +43,11 @@ test.describe('Settings', () => { await openSettings(page); await openSettingsSection(page, 'playback'); - const playerSelect = page.locator('[data-test-id="select-video-player"]'); - - await expect(playerSelect).toContainText( - /Video\.js/i + const playerSelect = page.locator( + '[data-test-id="select-video-player"]' ); + + await expect(playerSelect).toContainText(/Video\.js/i); await playerSelect.click(); await page.locator('mat-option[data-test-id="html5"]').click(); @@ -54,9 +56,7 @@ test.describe('Settings', () => { await openSettings(page); await openSettingsSection(page, 'playback'); - await expect(playerSelect).toContainText( - /HTML5/i - ); + await expect(playerSelect).toContainText(/HTML5/i); }); test('@settings @web Opt out of shared web player controls', async ({ @@ -235,13 +235,71 @@ test.describe('Settings', () => { ).toHaveAttribute('aria-checked', 'true'); }); + test('@settings @web Parental lock โ€” set a PIN, lock, survive a reload, unlock', async ({ + page, + }) => { + await openSettings(page); + await openSettingsSection(page, 'parental'); + + const enableToggle = page.locator( + '[data-test-id="parental-lock-enabled"] button[role="switch"]' + ); + const pinInput = page.locator('[data-test-id="parental-lock-pin"]'); + const pinConfirm = page.locator( + '[data-test-id="parental-lock-pin-confirm"]' + ); + const pinSubmit = page.locator( + '[data-test-id="parental-lock-pin-submit"]' + ); + const lockNow = page.locator('[data-test-id="parental-lock-lock-now"]'); + const unlock = page.locator('[data-test-id="parental-lock-unlock"]'); + const headerLock = page.locator( + '[data-test-id="header-parental-lock"]' + ); + + // Enabling asks for a new PIN twice; the parent stays unlocked. + await expect(enableToggle).toHaveAttribute('aria-checked', 'false'); + await enableToggle.click(); + await expect(pinInput).toBeVisible(); + await pinInput.fill('2468'); + await pinConfirm.fill('2468'); + await pinSubmit.click(); + await expect(enableToggle).toHaveAttribute('aria-checked', 'true'); + await expect(lockNow).toBeVisible(); + await expect(headerLock).toBeVisible(); + + // Lock now flips the state; a reload keeps the lock (never persisted + // as unlocked). + await lockNow.click(); + await expect(unlock).toBeVisible(); + await page.reload(); + await openSettings(page); + await openSettingsSection(page, 'parental'); + await expect(enableToggle).toHaveAttribute('aria-checked', 'true'); + await expect(unlock).toBeVisible(); + + // A wrong PIN is refused, the right one unlocks. + await unlock.click(); + await expect(pinInput).toBeVisible(); + await pinInput.fill('0000'); + await pinSubmit.click(); + await expect( + page.locator('[data-test-id="parental-lock-pin-error"]') + ).toBeVisible(); + await pinInput.fill('2468'); + await pinSubmit.click(); + await expect(lockNow).toBeVisible(); + + // The header button locks from anywhere. + await headerLock.click(); + await expect(unlock).toBeVisible(); + }); + test('@settings @web Change app language', async ({ page }) => { await openSettings(page); const languageSelect = page.locator('[data-test-id="select-language"]'); - await expect(languageSelect).toContainText( - 'English' - ); + await expect(languageSelect).toContainText('English'); await languageSelect.click(); await page.locator('mat-option[data-test-id="de"]').click(); @@ -249,9 +307,85 @@ test.describe('Settings', () => { await page.reload(); await openSettings(page); - await expect(languageSelect).toContainText( - 'Deutsch' + await expect(languageSelect).toContainText('Deutsch'); + }); + + test('@settings @search @web Search settings from the header and open a result', async ({ + page, + }) => { + await openSettings(page); + // The fresh browser context has no playlists; settings search must + // still be offered. + const search = page.locator( + 'app-workspace-shell-header input[type="search"]' ); + await expect(search).toBeEnabled(); + + // "subtitles" is a keyword of the "Show captions" row. + await search.fill('subtitles'); + await expect(page).toHaveURL(/\/workspace\/settings\/general\?q=subtitles$/); + await expect( + page.locator('[data-test-id="settings-search-results"]') + ).toBeVisible(); + await expect(page.locator('app-settings-general-section')).toHaveCount(0); + await expect( + page.locator('[data-test-id="settings-section-matches-general"]') + ).toHaveText('1'); + + await page + .locator('[data-test-id="settings-search-result-show-captions"]') + .click(); + + await expect(page).toHaveURL(/\/workspace\/settings\/general$/); + await expect(search).toHaveValue(''); + const row = page.locator('[data-setting-id="show-captions"]'); + await expect(row).toBeFocused(); + await expect(row).toHaveClass(/setting-item--revealed/); + await expect(row).not.toHaveClass(/setting-item--revealed/, { + timeout: 5000, + }); + }); + + test('@settings @search @web Enter opens the best settings match', async ({ + page, + }) => { + await openSettings(page); + const search = page.locator( + 'app-workspace-shell-header input[type="search"]' + ); + + await search.fill('stream format'); + await search.press('Enter'); + + await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); + await expect( + page.locator('[data-setting-id="stream-format"]') + ).toBeFocused(); + }); + + test('@settings @search @web Command palette opens a setting from anywhere', async ({ + page, + }) => { + // The shell registers Ctrl/Cmd+K once its lazy chunk has rendered. + await expect( + page.locator('a[href$="/workspace/settings"]') + ).toBeVisible(); + await expect(page).not.toHaveURL(/\/workspace\/settings/); + await page.keyboard.press('Control+k'); + + const palette = page.locator('.workspace-command-palette-overlay'); + await expect(palette).toBeVisible(); + await palette.locator('input[type="search"]').fill('ambient'); + await expect(palette).toContainText('Settings'); + await palette + .locator('.palette-command', { hasText: /ambient/i }) + .first() + .click(); + + await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); + await expect( + page.locator('[data-setting-id="player-ambient-mode"]') + ).toBeFocused(); }); test.afterEach(async ({ page }, testInfo) => { diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index 6fb90a9a1..3186de1c7 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -21,6 +21,10 @@ const collectCoverageFrom = [ export default { ...nxPreset, ...angularEsmPreset, + // See jest.preset.js: CI persists the transform cache from this directory. + ...(process.env.JEST_CACHE_DIRECTORY + ? { cacheDirectory: process.env.JEST_CACHE_DIRECTORY } + : {}), displayName: 'web', setupFilesAfterEnv: ['/src/test-setup.ts'], coverageDirectory: '../../coverage/apps/web', diff --git a/apps/web/project.json b/apps/web/project.json index 302ea2e37..72b30b14e 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -57,8 +57,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "4.5mb", - "maximumError": "5mb" + "maximumWarning": "1.8mb", + "maximumError": "2mb" }, { "type": "anyComponentStyle", @@ -88,8 +88,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "4.5mb", - "maximumError": "5mb" + "maximumWarning": "1.8mb", + "maximumError": "2mb" }, { "type": "anyComponentStyle", diff --git a/apps/web/src/app/app-date-locales.ts b/apps/web/src/app/app-date-locales.ts index df69e39cb..54c58d7df 100644 --- a/apps/web/src/app/app-date-locales.ts +++ b/apps/web/src/app/app-date-locales.ts @@ -2,7 +2,7 @@ import { registerLocaleData } from '@angular/common'; import localeEn from '@angular/common/locales/en'; import { inject, Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { normalizeDateLocale } from '@iptvnator/pipes'; +import { normalizeDateLocale } from '@iptvnator/pipes/date-format'; import { createDevLogger } from '@iptvnator/shared/interfaces'; type LocaleDataModule = { default: unknown }; diff --git a/apps/web/src/app/app-update-notification-panel.component.spec.ts b/apps/web/src/app/app-update-notification-panel.component.spec.ts index f567a2573..d92cc47d9 100644 --- a/apps/web/src/app/app-update-notification-panel.component.spec.ts +++ b/apps/web/src/app/app-update-notification-panel.component.spec.ts @@ -73,7 +73,7 @@ describe('AppUpdateNotificationPanelComponent', () => { ).not.toBeNull(); }); - it('opens release notes without dismissing the notification', () => { + it('opens release notes without dismissing the notification', async () => { statusHandler?.(availableStatus); fixture.detectChanges(); @@ -82,6 +82,9 @@ describe('AppUpdateNotificationPanelComponent', () => { '[data-test-id="app-update-notification-release-notes"]' ) as HTMLButtonElement ).click(); + // The dialog component is imported on demand (it pulls in `marked`). + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); expect(TestBed.inject(MatDialog).open).toHaveBeenCalledWith( AppUpdateReleaseNotesDialogComponent, @@ -96,6 +99,29 @@ describe('AppUpdateNotificationPanelComponent', () => { ).not.toBeNull(); }); + it('falls back to the releases page when the release notes dialog fails to load', async () => { + const open = jest.spyOn(window, 'open').mockReturnValue(null); + const error = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + fixture.componentInstance.loadReleaseNotesDialog = () => + Promise.reject(new Error('chunk failed')); + statusHandler?.(availableStatus); + fixture.detectChanges(); + + await fixture.componentInstance.openReleaseNotes(); + + expect(TestBed.inject(MatDialog).open).not.toHaveBeenCalled(); + expect(open).toHaveBeenCalledWith( + availableStatus.manualDownloadUrl, + '_blank', + 'noreferrer' + ); + expect(error).toHaveBeenCalledTimes(1); + open.mockRestore(); + error.mockRestore(); + }); + it('starts downloading the update from the notification action', async () => { statusHandler?.(availableStatus); fixture.detectChanges(); diff --git a/apps/web/src/app/app-update-notification-panel.component.ts b/apps/web/src/app/app-update-notification-panel.component.ts index d10b02117..a99e814ad 100644 --- a/apps/web/src/app/app-update-notification-panel.component.ts +++ b/apps/web/src/app/app-update-notification-panel.component.ts @@ -16,7 +16,6 @@ import { ElectronBridgeAppUpdateStatus, } from '@iptvnator/shared/interfaces'; import { AppUpdateInstallService } from './services/app-update-install.service'; -import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component'; @Component({ selector: 'app-update-notification-panel', @@ -239,8 +238,31 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy { this.unsubscribeStatus = null; } - openReleaseNotes(): void { - const latestVersion = this.status()?.latestVersion; + /** + * Loaded on demand: the dialog renders Markdown with `marked`, which this + * always-mounted panel would otherwise put on the initial path. A field so + * specs can substitute it. + */ + loadReleaseNotesDialog = () => + import('./settings/app-update-release-notes-dialog.component'); + + async openReleaseNotes(): Promise { + const status = this.status(); + const latestVersion = status?.latestVersion; + let dialogModule: Awaited< + ReturnType + >; + try { + dialogModule = await this.loadReleaseNotesDialog(); + } catch (error) { + // The same notes are on the releases page; the next click retries. + console.error('Could not load the release notes dialog:', error); + if (status?.manualDownloadUrl) { + window.open(status.manualDownloadUrl, '_blank', 'noreferrer'); + } + return; + } + const { AppUpdateReleaseNotesDialogComponent } = dialogModule; this.dialog.open(AppUpdateReleaseNotesDialogComponent, { autoFocus: false, diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 99557c15b..b66f3c6f9 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -17,9 +17,11 @@ import { EMPTY, of } from 'rxjs'; import { DataService, EpgSourceSettingsService, + ParentalLockService, SettingsStore, RuntimeCapabilitiesService, } from '@iptvnator/services'; +import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service'; import { Language, Settings, @@ -35,6 +37,11 @@ import { AppDateLocaleService } from './app-date-locales'; import { ElectronServiceStub } from './services/electron.service.stub'; import { SettingsService } from './services/settings.service'; +/** Writable double for the bridge's read-only capability getters. */ +type EpgBridgeStub = { + -readonly [K in keyof EpgRuntimeBridgeService]?: EpgRuntimeBridgeService[K]; +}; + jest.spyOn(global.console, 'error').mockImplementation(() => { // suppress console.error output during tests }); @@ -77,7 +84,7 @@ describe('AppComponent', () => { let store: MockStore; let translateService: TranslateService; let runtimeCapabilities: Partial; - let epgBridge: Partial; + let epgBridge: EpgBridgeStub; beforeEach(waitForAsync(() => { runtimeCapabilities = { @@ -97,6 +104,14 @@ describe('AppComponent', () => { imports: [AppComponent], providers: [ provideMockStore(), + // The parental lock boots from AppComponent; its collaborators + // (Xtream/Stalker stores, SQLite bridge) are out of scope here. + MockProvider(ParentalLockService, { + initialize: jest.fn().mockResolvedValue(undefined), + }), + MockProvider(ParentalLockEnforcementService, { + start: jest.fn(), + }), { provide: Actions, useValue: new Actions(EMPTY), diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 3e4dc7ad0..8c61fb626 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -21,11 +21,14 @@ import { WORKSPACE_SHELL_ACTIONS, } from '@iptvnator/workspace/shell/util'; import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel'; -import { WindowControlsComponent } from '@iptvnator/ui/components'; +// File-level entry: the @iptvnator/ui/components barrel would put the whole +// library (channel lists, EPG, forms, date-fns) on the initial path. +import { WindowControlsComponent } from '@iptvnator/ui/components/window-controls'; import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { filter, take } from 'rxjs'; import { DataService, + ParentalLockService, RuntimeCapabilitiesService, SettingsStore, EpgSourceSettingsService, @@ -40,6 +43,7 @@ import { } from '@iptvnator/shared/interfaces'; import { AppDateLocaleService } from './app-date-locales'; import { SettingsService } from './services/settings.service'; +import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service'; import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service'; import { PlaylistOpenRequestService } from './services/playlist-open-request.service'; import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component'; @@ -81,6 +85,10 @@ export class AppComponent implements OnInit { private settingsStore = inject(SettingsStore); private readonly epgSources = inject(EpgSourceSettingsService); private playbackKeepAwake = inject(PlaybackKeepAwakeService); + private readonly parentalLock = inject(ParentalLockService); + private readonly parentalLockEnforcement = inject( + ParentalLockEnforcementService + ); private playlistOpenRequests = inject(PlaylistOpenRequestService); private runtime = inject(RuntimeCapabilitiesService); private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS); @@ -106,6 +114,11 @@ export class AppComponent implements OnInit { // (Electron powerSaveBlocker / PWA Screen Wake Lock, issue #1095). this.playbackKeepAwake.start(); + // Parental lock: load the PIN hash and lock store, then keep the + // in-memory catalogs in step with lock/unlock (issue #285). + void this.parentalLock.initialize(); + this.parentalLockEnforcement.start(); + effect(() => { const size = this.settingsStore.coverSize?.() ?? 'medium'; document.documentElement.dataset.coverSize = size; diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 0f08a66bf..1fc0d8950 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -30,20 +30,24 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYER, -} from '@iptvnator/portal/shared/util'; -import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui'; +} from '@iptvnator/portal/shared/util/tokens'; +import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access'; -import { DataService } from '@iptvnator/services'; +import { + provideParentalLockPlaylistCleanup, + DataService, +} from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; import { AppConfig } from '../environments/environment'; import { routes } from './app.routes'; import { ElectronService } from './services/electron.service'; import { ExternalPlaybackService } from './services/external-playback.service'; import { PlayerService } from './services/player.service'; +import { provideParentalLockPrompt } from './services/parental-lock-prompt.service'; import { providePortalPlaybackPositions } from './services/portal-playback-positions.service'; import { PwaService } from './services/pwa.service'; import { shouldEnableServiceWorker } from './services/runtime-config'; -import { AppStalkerPlaylistConnectionEditorService } from './services/stalker-playlist-connection-editor.service'; +import { LazyStalkerPlaylistConnectionEditor } from './services/lazy-stalker-playlist-connection-editor'; import { provideWorkspaceShellActions } from './services/workspace-shell-actions.service'; // AoT requires an exported function for factories @@ -157,9 +161,11 @@ export const appConfig: ApplicationConfig = { ...providePortalPlaybackPositions(), { provide: STALKER_PLAYLIST_CONNECTION_EDITOR, - useExisting: AppStalkerPlaylistConnectionEditorService, + useExisting: LazyStalkerPlaylistConnectionEditor, }, ...provideWorkspaceShellActions(), + ...provideParentalLockPrompt(), + provideParentalLockPlaylistCleanup(), ...provideXtreamDataSource(), { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 691f79699..edd939b1c 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -1,5 +1,7 @@ import { TestBed } from '@angular/core/testing'; -import { SettingsStore } from '@iptvnator/services'; +import { ParentalLockService, SettingsStore } from '@iptvnator/services'; + +type AppRoutesModule = typeof import('./app.routes'); describe('app routes', () => { let workspaceRoute: import('@angular/router').Route | undefined; @@ -11,14 +13,8 @@ describe('app routes', () => { path?: string; redirectTo?: unknown; }> = []; - let resolveElectronOnlyGlobalSearchRoute: ( - runtime: { isElectron: boolean }, - router: { parseUrl: (url: string) => unknown } - ) => unknown; - let resolveRecordingsCapabilityRoute: ( - runtime: { supportsRecordings: boolean }, - router: { parseUrl: (url: string) => unknown } - ) => unknown; + let resolveElectronOnlyGlobalSearchRoute: AppRoutesModule['resolveElectronOnlyGlobalSearchRoute']; + let resolveRecordingsCapabilityRoute: AppRoutesModule['resolveRecordingsCapabilityRoute']; beforeAll(async () => { jest.unstable_mockModule( @@ -67,6 +63,52 @@ describe('app routes', () => { TestBed.resetTestingModule(); }); + it('waits for the parental lock state before activating workspace children', async () => { + let releaseLock!: () => void; + const lockPending = new Promise((resolve) => { + releaseLock = resolve; + }); + const initialize = jest.fn(() => lockPending); + TestBed.configureTestingModule({ + providers: [ + { + provide: SettingsStore, + useValue: { + loadSettings: jest.fn().mockResolvedValue(undefined), + }, + }, + { provide: ParentalLockService, useValue: { initialize } }, + ], + }); + const settingsReadyResolver = + workspaceRoute?.resolve?.['settingsReady']; + if (typeof settingsReadyResolver !== 'function') { + throw new Error('resolver missing'); + } + let resolved = false; + const resolution = TestBed.runInInjectionContext(() => + Promise.resolve( + ( + settingsReadyResolver as import('@angular/router').ResolveFn + )( + {} as import('@angular/router').ActivatedRouteSnapshot, + {} as import('@angular/router').RouterStateSnapshot + ) + ) + ).then(() => { + resolved = true; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(initialize).toHaveBeenCalled(); + expect(resolved).toBe(false); + + releaseLock(); + await resolution; + expect(resolved).toBe(true); + }); + it('waits for settings before activating workspace children', async () => { let releaseSettings!: () => void; const settingsPending = new Promise((resolve) => { @@ -79,6 +121,12 @@ describe('app routes', () => { provide: SettingsStore, useValue: { loadSettings }, }, + { + provide: ParentalLockService, + useValue: { + initialize: jest.fn().mockResolvedValue(undefined), + }, + }, ], }); const settingsReadyResolver = @@ -120,8 +168,7 @@ describe('app routes', () => { (route) => route.path === 'playlists/:id' ); const loadChildren = playlistRoute?.loadChildren as - | (() => Promise) - | undefined; + (() => Promise) | undefined; const m3uRoutes = (await loadChildren?.()) ?? []; const defaultRoute = m3uRoutes.find((route) => route.path === ''); const favoritesRoute = m3uRoutes.find( diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index c02ee01da..123f51372 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -1,10 +1,24 @@ import { inject } from '@angular/core'; import { Router, Routes } from '@angular/router'; -import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { + ParentalLockService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard'; -const settingsReadyResolver = () => inject(SettingsStore).loadSettings(); +// The workspace activates only once settings AND the parental lock state +// (PIN, lock store) are known: before that the lock reads as off and a +// slower IndexedDB read would let the catalogs admit protected rows. +const settingsReadyResolver = async () => { + const settingsStore = inject(SettingsStore); + const parentalLock = inject(ParentalLockService); + await Promise.all([ + settingsStore.loadSettings(), + parentalLock.initialize(), + ]); +}; const workspaceEntryRedirect = async () => inject(WorkspaceStartupPreferencesService).resolveInitialWorkspacePath(); diff --git a/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts index 5aac52ff3..7d2cd145b 100644 --- a/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts +++ b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts @@ -125,7 +125,7 @@ describe('EmbeddedMpvPlayerComponent recording status message', () => { afterEach(() => { jest.useRealTimers(); fixture.destroy(); - delete window.electron; + delete (window as { electron?: typeof window.electron }).electron; }); it('clears the saved recording path after a short delay', async () => { diff --git a/apps/web/src/app/services/app-update-install.service.spec.ts b/apps/web/src/app/services/app-update-install.service.spec.ts index 658efc39c..842f2cf7d 100644 --- a/apps/web/src/app/services/app-update-install.service.spec.ts +++ b/apps/web/src/app/services/app-update-install.service.spec.ts @@ -67,7 +67,7 @@ describe('AppUpdateInstallService', () => { it('returns null and touches no guard without the desktop bridge', async () => { const service = createService(); service.registerUnloadGuard(guard); - window.electron = undefined; + (window as { electron?: typeof window.electron }).electron = undefined; expect(await service.installAppUpdate()).toBeNull(); expect(guard.suspendForAppQuit).not.toHaveBeenCalled(); diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 525ff5645..ec2c7e85f 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -3,7 +3,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { DialogService } from '@iptvnator/ui/components'; +import { DialogService } from '@iptvnator/ui/components/confirm-dialog'; import { DataService, SettingsStore, @@ -36,7 +36,7 @@ import { createLogger, createPortalDebugRequestContext, logPortalDebugEvent, -} from '@iptvnator/portal/shared/util'; +} from '@iptvnator/portal/shared/util/logger'; interface PlayerLaunchPayload { readonly headers?: Record; diff --git a/apps/web/src/app/services/external-player-info-dialog.lazy.ts b/apps/web/src/app/services/external-player-info-dialog.lazy.ts new file mode 100644 index 000000000..b5158073e --- /dev/null +++ b/apps/web/src/app/services/external-player-info-dialog.lazy.ts @@ -0,0 +1,9 @@ +/** + * Lazy boundary for PlayerService: the service imports this file dynamically, + * so the dialog and the @angular/forms it brings (via the Material checkbox) + * stay off the initial path. The boundary is a local file rather than the + * library path because other web files, such as specs, import ui-playback + * statically, which @nx/enforce-module-boundaries forbids for a library the + * project also loads dynamically. + */ +export { ExternalPlayerInfoDialogComponent } from '@iptvnator/ui/playback/external-player-info-dialog'; diff --git a/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.spec.ts b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.spec.ts new file mode 100644 index 000000000..4a35313b4 --- /dev/null +++ b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.spec.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { + StalkerPlaylistConnectionEditor, + StalkerPlaylistConnectionResult, +} from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; +import type { + PlaylistMeta, + PlaylistMetaUpdate, +} from '@iptvnator/shared/interfaces'; +import { LazyStalkerPlaylistConnectionEditor } from './lazy-stalker-playlist-connection-editor'; + +const resolved: StalkerPlaylistConnectionResult = { + status: 'resolved', + playlist: { _id: 'p1' } as PlaylistMetaUpdate, +}; + +@Injectable({ providedIn: 'root' }) +class FakeEditor implements StalkerPlaylistConnectionEditor { + resolveConnection = jest.fn(async () => resolved); + applyResolvedConnection = jest.fn( + async (playlist: PlaylistMetaUpdate) => playlist + ); +} + +type EditorModule = Awaited< + ReturnType +>; + +function moduleWith(editorClass: typeof FakeEditor): EditorModule { + return { + AppStalkerPlaylistConnectionEditorService: editorClass, + } as unknown as EditorModule; +} + +describe('LazyStalkerPlaylistConnectionEditor', () => { + let lazy: LazyStalkerPlaylistConnectionEditor; + + beforeEach(() => { + TestBed.configureTestingModule({}); + lazy = TestBed.inject(LazyStalkerPlaylistConnectionEditor); + }); + + it('loads nothing until an editor method is called', () => { + const load = jest.fn(async () => moduleWith(FakeEditor)); + lazy.loadEditorModule = load; + + expect(load).not.toHaveBeenCalled(); + }); + + it('delegates both methods to the root-provided implementation, loading it once', async () => { + const load = jest.fn(async () => moduleWith(FakeEditor)); + lazy.loadEditorModule = load; + const playlist = { _id: 'p1' } as PlaylistMeta; + const source = { _id: 'p0' } as PlaylistMeta; + const update = { _id: 'p1', title: 'Renamed' } as PlaylistMetaUpdate; + + await expect(lazy.resolveConnection(playlist, source)).resolves.toBe( + resolved + ); + await expect( + lazy.applyResolvedConnection(update, { + preserveCurrentMetadata: true, + }) + ).resolves.toBe(update); + + const editor = TestBed.inject(FakeEditor); + expect(editor.resolveConnection).toHaveBeenCalledWith(playlist, source); + expect(editor.applyResolvedConnection).toHaveBeenCalledWith(update, { + preserveCurrentMetadata: true, + }); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('retries the import after a failed chunk load', async () => { + const load = jest + .fn, []>() + .mockRejectedValueOnce(new Error('chunk failed')) + .mockResolvedValueOnce(moduleWith(FakeEditor)); + lazy.loadEditorModule = load; + const playlist = { _id: 'p1' } as PlaylistMeta; + + await expect(lazy.resolveConnection(playlist)).rejects.toThrow( + 'chunk failed' + ); + await expect(lazy.resolveConnection(playlist)).resolves.toBe(resolved); + expect(load).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.ts b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.ts new file mode 100644 index 000000000..187a59e43 --- /dev/null +++ b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.ts @@ -0,0 +1,56 @@ +import { EnvironmentInjector, inject, Injectable } from '@angular/core'; +import type { + StalkerPlaylistConnectionEditor, + StalkerPlaylistConnectionResult, + StalkerResolvedConnectionApplyOptions, +} from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; +import type { + PlaylistMeta, + PlaylistMetaUpdate, +} from '@iptvnator/shared/interfaces'; + +type EditorModule = + typeof import('./stalker-playlist-connection-editor.service'); + +/** + * Registered as STALKER_PLAYLIST_CONNECTION_EDITOR in app.config. The real + * editor depends on the whole Stalker portal data layer, which would + * otherwise be evaluated before the first paint although it is only needed + * when a user edits or re-checks a Stalker source. Every editor method is + * asynchronous, so the implementation is imported on first use and resolved + * from the root injector (it is providedIn: 'root'). + */ +@Injectable({ providedIn: 'root' }) +export class LazyStalkerPlaylistConnectionEditor implements StalkerPlaylistConnectionEditor { + private readonly injector = inject(EnvironmentInjector); + private editor: Promise | null = null; + + /** The dynamic import; a field so specs can substitute it. */ + loadEditorModule: () => Promise = () => + import('./stalker-playlist-connection-editor.service'); + + async resolveConnection( + playlist: PlaylistMeta, + sourcePlaylist?: PlaylistMeta + ): Promise { + const editor = await this.resolveEditor(); + return editor.resolveConnection(playlist, sourcePlaylist); + } + + async applyResolvedConnection( + playlist: PlaylistMetaUpdate, + options?: StalkerResolvedConnectionApplyOptions + ): Promise { + const editor = await this.resolveEditor(); + return editor.applyResolvedConnection(playlist, options); + } + + private resolveEditor(): Promise { + this.editor ??= this.loadEditorModule().then((module) => + this.injector.get(module.AppStalkerPlaylistConnectionEditorService) + ); + // A failed chunk load must not poison later attempts. + this.editor.catch(() => (this.editor = null)); + return this.editor; + } +} diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts new file mode 100644 index 000000000..8549df0fe --- /dev/null +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -0,0 +1,589 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { ParentalLockService } from '@iptvnator/services'; +import { ParentalLockEnforcementService } from './parental-lock-enforcement.service'; +import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; + +interface Applier { + apply(): Promise; + failClosedNow(): void; + applyXtream(version: number): Promise; + applyStalker(): Promise; +} + +describe('ParentalLockEnforcementService', () => { + const router = { url: '/', navigate: jest.fn() }; + const activeChannel = signal<{ group?: { title: string } } | null>(null); + const dispatch = jest.fn(); + const lockedStalkerIds = new Set(); + const parentalLock = { + version: signal(0), + active: signal(false), + registerBusyProbe: jest.fn(), + isXtreamCategoryLocked: jest.fn( + (_playlistId: string, _type: string, _xtreamId: number) => false + ), + isStalkerCategoryLocked: jest.fn( + (_playlistId: string, _type: string, id: unknown) => + id !== null && + id !== undefined && + lockedStalkerIds.has(String(id)) + ), + isM3uGroupLocked: jest.fn( + (_playlistId: string, _groupTitle: string) => false + ), + }; + const stalkerStore = { + currentPlaylist: signal<{ _id: string } | null>({ _id: 'stalker-1' }), + selectedContentType: signal('vod'), + selectedCategoryId: signal('*'), + selectedItem: signal<{ + category_id?: string; + tv_genre_id?: string; + } | null>(null), + clearSelectedItem: jest.fn(), + setSelectedCategory: jest.fn(), + }; + const xtreamStore = { + playlistId: signal('xtream-1'), + selectedCategoryId: signal(null), + selectedItem: signal<{ category_id?: number } | null>(null), + reloadCategories: jest.fn( + async (_shouldPublish?: () => boolean): Promise => undefined + ), + reloadCachedContent: jest.fn(async () => undefined), + refreshSearchResults: jest.fn(async () => undefined), + withholdCatalog: jest.fn(), + clearSearchResults: jest.fn(), + getCategoriesBySelectedType: jest.fn(() => [ + { id: 7, xtream_id: 70 }, + { id: 8, xtream_id: 80 }, + ]), + setSelectedItem: jest.fn(), + setSelectedCategory: jest.fn(), + }; + const xtreamDataSource = { + getAllCategories: jest.fn(async () => [ + { id: 7, xtream_id: 70 }, + { id: 8, xtream_id: 80 }, + { id: 55, xtream_id: 550 }, + { id: 99, xtream_id: 990 }, + ]), + }; + let service: Applier; + + beforeEach(() => { + jest.clearAllMocks(); + lockedStalkerIds.clear(); + router.url = '/'; + parentalLock.active.set(false); + parentalLock.isXtreamCategoryLocked.mockReturnValue(false); + stalkerStore.selectedCategoryId.set('*'); + stalkerStore.selectedItem.set(null); + xtreamStore.selectedCategoryId.set(null); + xtreamStore.selectedItem.set(null); + TestBed.configureTestingModule({ + providers: [ + { provide: ParentalLockService, useValue: parentalLock }, + { provide: XtreamStore, useValue: xtreamStore }, + { provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource }, + { provide: StalkerStore, useValue: stalkerStore }, + { provide: Router, useValue: router }, + { + provide: Store, + useValue: { selectSignal: () => activeChannel, dispatch }, + }, + { + provide: PlaybackKeepAwakeService, + useValue: { hasPlayingVideo: () => false }, + }, + ], + }); + activeChannel.set(null); + service = TestBed.inject( + ParentalLockEnforcementService + ) as unknown as Applier; + }); + + describe('M3U', () => { + it('resets a locked playing channel before awaiting the portal reloads', async () => { + router.url = '/workspace/playlists/m3u-1'; + activeChannel.set({ group: { title: 'Adult' } }); + parentalLock.isM3uGroupLocked.mockReturnValue(true); + let releaseReload: () => void = () => undefined; + xtreamStore.reloadCategories.mockImplementationOnce( + () => new Promise((resolve) => (releaseReload = resolve)) + ); + + const applying = service.apply(); + await Promise.resolve(); + + expect(parentalLock.isM3uGroupLocked).toHaveBeenCalledWith( + 'm3u-1', + 'Adult' + ); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1); + + releaseReload(); + await applying; + parentalLock.isM3uGroupLocked.mockReturnValue(false); + }); + }); + + it('resets a channel of a locked group as soon as it becomes active while locked', () => { + // Numeric zapping, next/previous or a remote command can select a + // channel without any lock-version change following it. + router.url = '/workspace/playlists/m3u-1'; + parentalLock.active.set(true); + parentalLock.isM3uGroupLocked.mockImplementation( + (_playlistId: string, group: string) => group === 'Adult' + ); + TestBed.runInInjectionContext(() => + (service as unknown as { start(): void }).start() + ); + TestBed.flushEffects(); + dispatch.mockClear(); + + activeChannel.set({ group: { title: 'News' } }); + TestBed.flushEffects(); + expect(dispatch).not.toHaveBeenCalled(); + + activeChannel.set({ group: { title: 'Adult' } }); + TestBed.flushEffects(); + expect(dispatch).toHaveBeenCalledTimes(1); + parentalLock.isM3uGroupLocked.mockReset(); + parentalLock.isM3uGroupLocked.mockReturnValue(false); + }); + + describe('Stalker', () => { + it('leaves the Stalker route when the Stalker step cannot load', async () => { + router.url = '/workspace/stalker/stalker-1/itv'; + jest.spyOn(console, 'error').mockImplementation(() => undefined); + ( + service as unknown as { + loadStalkerEnforcement: () => Promise; + } + ).loadStalkerEnforcement = () => + Promise.reject(new Error('ChunkLoadError')); + + await expect(service.applyStalker()).resolves.toBeUndefined(); + + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'sources', + ]); + }); + + it('leaves the Stalker route synchronously on relock while the step is not loaded', () => { + router.url = '/workspace/stalker/stalker-1/itv'; + ( + service as unknown as { + loadStalkerEnforcement: () => Promise; + } + ).loadStalkerEnforcement = () => new Promise(() => undefined); + + service.failClosedNow(); + + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'sources', + ]); + }); + + it('runs a preloaded Stalker step synchronously on relock', async () => { + router.url = '/workspace/stalker/stalker-1/vod/42'; + await service.applyStalker(); // loads the step + lockedStalkerIds.add('9'); + stalkerStore.selectedItem.set({ category_id: '9' }); + stalkerStore.clearSelectedItem.mockClear(); + + service.failClosedNow(); + + expect(stalkerStore.clearSelectedItem).toHaveBeenCalled(); + }); + + it('does not load the Stalker step outside a Stalker route', async () => { + router.url = '/workspace/xtreams/xtream-1/live'; + const load = jest.spyOn( + service as unknown as { loadStalkerEnforcement: () => unknown }, + 'loadStalkerEnforcement' + ); + + await service.applyStalker(); + + expect(load).not.toHaveBeenCalled(); + expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled(); + }); + + it('clears a detail opened from All whose own genre is withheld', async () => { + router.url = '/workspace/stalker/stalker-1/vod/42'; + lockedStalkerIds.add('9'); + stalkerStore.selectedItem.set({ category_id: '9' }); + + await service.applyStalker(); + + expect(stalkerStore.clearSelectedItem).toHaveBeenCalled(); + // "All" itself is not locked, so the category stays selected. + expect(stalkerStore.setSelectedCategory).not.toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'stalker', + 'stalker-1', + 'vod', + ]); + }); + + it('leaves a detail from All alone when its genre is not locked', async () => { + router.url = '/workspace/stalker/stalker-1/vod/42'; + lockedStalkerIds.add('9'); + stalkerStore.selectedItem.set({ category_id: '3' }); + + await service.applyStalker(); + + expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('judges a live channel from All by its genre, not by category_id', async () => { + router.url = '/workspace/stalker/stalker-1/itv'; + stalkerStore.selectedContentType.set('itv'); + lockedStalkerIds.add('9'); + stalkerStore.selectedItem.set({ + tv_genre_id: '9', + category_id: '3', + }); + + await service.applyStalker(); + + expect(stalkerStore.clearSelectedItem).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'stalker', + 'stalker-1', + 'itv', + ]); + stalkerStore.selectedContentType.set('vod'); + }); + + it('steps off a locked selected category', async () => { + router.url = '/workspace/stalker/stalker-1/vod'; + lockedStalkerIds.add('9'); + stalkerStore.selectedCategoryId.set('9'); + + await service.applyStalker(); + + expect(stalkerStore.clearSelectedItem).toHaveBeenCalled(); + expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith(null); + }); + }); + + describe('Xtream', () => { + function lockProvider(providerId: number): void { + parentalLock.active.set(true); + parentalLock.isXtreamCategoryLocked.mockImplementation( + (_p: string, _t: string, id: number) => id === providerId + ); + } + + it('clears a selected item whose category the lock store withholds', async () => { + router.url = '/workspace/xtreams/xtream-1/vod/42'; + lockProvider(990); + xtreamStore.selectedItem.set({ category_id: 99 }); + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.reloadCategories).toHaveBeenCalled(); + expect(xtreamDataSource.getAllCategories).toHaveBeenCalledWith( + 'xtream-1', + 'movies' + ); + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + 'xtream-1', + 'vod', + ]); + }); + + it('keeps a detail from a category that is merely hidden, not locked', async () => { + router.url = '/workspace/xtreams/xtream-1/vod/42'; + lockProvider(990); + // Row 55 is absent from the (hidden-filtered) visible list but + // exists unlocked in the unfiltered rows. + xtreamStore.selectedItem.set({ category_id: 55 }); + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('steps off a selected locked category', async () => { + router.url = '/workspace/xtreams/xtream-1/live'; + lockProvider(990); + xtreamStore.selectedCategoryId.set(99); + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + 'xtream-1', + 'live', + ]); + }); + + it('fails closed when the category rows cannot be read', async () => { + router.url = '/workspace/xtreams/xtream-1/vod/42'; + parentalLock.active.set(true); + xtreamDataSource.getAllCategories.mockRejectedValueOnce( + new Error('db') + ); + xtreamStore.selectedItem.set({ category_id: 7 }); + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + }); + + it('skips the post-reload checks while unlocked and hands the reloads a publish guard', async () => { + router.url = '/workspace/xtreams/xtream-1/vod'; + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.withholdCatalog).not.toHaveBeenCalled(); + expect(xtreamDataSource.getAllCategories).not.toHaveBeenCalled(); + const guard = xtreamStore.reloadCategories.mock.calls[0][0] as + (() => boolean) | undefined; + expect(guard?.()).toBe(true); + parentalLock.version.set(parentalLock.version() + 1); + expect(guard?.()).toBe(false); + }); + + it('abandons the reloads and checks once the Xtream playlist is switched', async () => { + router.url = '/workspace/xtreams/xtream-1/vod/7'; + parentalLock.active.set(true); + xtreamStore.selectedCategoryId.set(7); + parentalLock.isXtreamCategoryLocked.mockReturnValue(true); + let guard: () => boolean = () => true; + (xtreamStore.reloadCategories as jest.Mock).mockImplementationOnce( + async (shouldPublish: () => boolean) => { + guard = shouldPublish; + xtreamStore.playlistId.set('xtream-2'); + router.url = '/workspace/xtreams/xtream-2/vod'; + } + ); + + try { + await service.applyXtream(parentalLock.version()); + expect(guard()).toBe(false); + expect(xtreamStore.refreshSearchResults).not.toHaveBeenCalled(); + expect( + xtreamDataSource.getAllCategories + ).not.toHaveBeenCalled(); + expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + } finally { + xtreamStore.playlistId.set('xtream-1'); + } + }); + + it('re-runs the stored in-portal search after the reload', async () => { + router.url = '/workspace/xtreams/xtream-1/search'; + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.refreshSearchResults).toHaveBeenCalled(); + }); + + it('clears on relock, synchronously, a detail whose category is not in the visible list', () => { + // Opened through search from a manually hidden category: the + // on-screen list cannot place it, and the unfiltered lookup is + // an awaited read that may hang. + router.url = '/workspace/xtreams/xtream-1/vod/55/900'; + xtreamStore.selectedItem.set({ category_id: 55 }); + + service.failClosedNow(); + + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + 'xtream-1', + 'vod', + ]); + }); + + it('keeps a detail on relock whose visible category is not locked', () => { + router.url = '/workspace/xtreams/xtream-1/vod/7/900'; + xtreamStore.selectedItem.set({ category_id: 7 }); + + service.failClosedNow(); + + expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled(); + }); + + it('fails closed synchronously on relock: detail, catalog and search', () => { + router.url = '/workspace/xtreams/xtream-1/vod/42'; + lockProvider(70); + xtreamStore.selectedItem.set({ category_id: 7 }); + + service.failClosedNow(); + + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + expect(xtreamStore.withholdCatalog).toHaveBeenCalled(); + expect(xtreamStore.clearSearchResults).toHaveBeenCalled(); + expect(xtreamStore.reloadCategories).not.toHaveBeenCalled(); + }); + }); +}); + +describe('ParentalLockEnforcementService apply serialization', () => { + it('runs applies one at a time and abandons a result superseded by a newer version', async () => { + const version = signal(0); + let releaseReload: () => void = () => undefined; + const xtreamStore = { + playlistId: signal('xtream-1'), + selectedCategoryId: signal(99), + selectedItem: signal(null), + reloadCategories: jest.fn( + () => new Promise((resolve) => (releaseReload = resolve)) + ), + reloadCachedContent: jest.fn(async () => undefined), + refreshSearchResults: jest.fn(async () => undefined), + withholdCatalog: jest.fn(), + clearSearchResults: jest.fn(), + getCategoriesBySelectedType: jest.fn(() => [] as unknown[]), + setSelectedItem: jest.fn(), + setSelectedCategory: jest.fn(), + }; + TestBed.configureTestingModule({ + providers: [ + { + provide: ParentalLockService, + useValue: { + version, + active: signal(true), + isXtreamCategoryLocked: jest.fn(() => false), + registerBusyProbe: jest.fn(), + isStalkerCategoryLocked: jest.fn(() => false), + isM3uGroupLocked: jest.fn(() => false), + }, + }, + { provide: XtreamStore, useValue: xtreamStore }, + { + // No rows: the selected category cannot be placed and + // fails closed once an apply gets to judge it. + provide: XTREAM_DATA_SOURCE, + useValue: { getAllCategories: jest.fn(async () => []) }, + }, + { + provide: StalkerStore, + useValue: { currentPlaylist: signal(null) }, + }, + { + provide: Router, + useValue: { + url: '/workspace/xtreams/xtream-1/live', + navigate: jest.fn(), + }, + }, + { + provide: Store, + useValue: { + selectSignal: () => signal(null), + dispatch: jest.fn(), + }, + }, + { + provide: PlaybackKeepAwakeService, + useValue: { hasPlayingVideo: () => false }, + }, + ], + }); + const service = TestBed.inject(ParentalLockEnforcementService); + TestBed.runInInjectionContext(() => service.start()); + TestBed.flushEffects(); + + // Unlock: the reload is held open... + version.set(1); + TestBed.flushEffects(); + await Promise.resolve(); + expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1); + + // ...and "Lock now" arrives meanwhile: no second reload starts yet, + // but the catalog is withheld at once rather than behind the hung + // read. + xtreamStore.withholdCatalog.mockClear(); + version.set(2); + TestBed.flushEffects(); + expect(xtreamStore.withholdCatalog).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1); + + // The superseded apply must not act on its (unlocked) rows. + releaseReload(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(2); + expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled(); + + releaseReload(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledTimes(1); + }); +}); + +describe('ParentalLockEnforcementService busy probe', () => { + it('counts playing audio (the radio player) as activity', () => { + let probe: (() => boolean) | undefined; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { + provide: ParentalLockService, + useValue: { + version: signal(0), + registerBusyProbe: (fn: () => boolean) => (probe = fn), + }, + }, + { provide: XtreamStore, useValue: {} }, + { provide: XTREAM_DATA_SOURCE, useValue: {} }, + { provide: StalkerStore, useValue: {} }, + { provide: Router, useValue: { url: '/' } }, + { + provide: Store, + useValue: { selectSignal: () => signal(null) }, + }, + { + provide: PlaybackKeepAwakeService, + useValue: { hasPlayingVideo: () => false }, + }, + ], + }); + const service = TestBed.inject(ParentalLockEnforcementService); + TestBed.runInInjectionContext(() => service.start()); + expect(probe?.()).toBe(false); + + const audio = document.createElement('audio'); + Object.defineProperty(audio, 'paused', { value: false }); + Object.defineProperty(audio, 'ended', { value: false }); + document.body.appendChild(audio); + try { + expect(probe?.()).toBe(true); + } finally { + audio.remove(); + } + }); +}); diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.ts b/apps/web/src/app/services/parental-lock-enforcement.service.ts new file mode 100644 index 000000000..16a092755 --- /dev/null +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -0,0 +1,394 @@ +import { + effect, + EnvironmentInjector, + inject, + Injectable, + untracked, +} from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { ChannelActions, selectActive } from '@iptvnator/m3u-state'; +import { + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { ParentalLockService } from '@iptvnator/services'; +import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces'; +import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; + +const XTREAM_ROUTE = + /^\/workspace\/xtreams\/([^/?#]+)\/(live|vod|series)(?:\/(\d+))?/; +export const STALKER_ROUTE = + /^\/workspace\/stalker\/([^/?#]+)\/(itv|vod|series|radio)(?:\/([^/?#]+))?/; + +/** + * Applies a parental lock change to the parts of the app that hold catalog + * data in memory. The stores and the SQLite worker filter what they READ; + * this service makes them read again and steps off anything that is now + * withheld โ€” a selected category, a playing channel โ€” so a locked category + * cannot stay on screen just because it was opened before the lock. + */ +@Injectable({ providedIn: 'root' }) +export class ParentalLockEnforcementService { + private readonly parentalLock = inject(ParentalLockService); + private readonly xtreamStore = inject(XtreamStore); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); + private readonly injector = inject(EnvironmentInjector); + private readonly router = inject(Router); + private readonly store = inject(Store); + private readonly keepAwake = inject(PlaybackKeepAwakeService); + private readonly activeChannel = this.store.selectSignal(selectActive); + private started = false; + private lastVersion = -1; + private applyChain: Promise = Promise.resolve(); + private stalkerModule: StalkerEnforcementModule | null = null; + private stalkerModuleLoad: Promise | null = null; + + start(): void { + if (this.started) { + return; + } + this.started = true; + // Playback counts as activity: video through the keep-awake + // tracker, and audio (the radio player) read directly, since the + // keep-awake service deliberately ignores