Merge remote-tracking branch 'origin/master' into claude/iptvnator-player-controls-2705f1

# Conflicts:
#	libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts
This commit is contained in:
4gray committed 2026-09-27 21:01:23 +02:00
commit 8661b6e467
494 files changed
+24827 -1724

No files matched your search

@@ -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.
+8
View File
@@ -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".
+10
View File
@@ -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.
+8
View File
@@ -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.
@@ -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.
-35
View File
@@ -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.
@@ -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.
+126
View File
@@ -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.
+11
View File
@@ -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.
@@ -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
+150
View File
@@ -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.
+84 -26
View File
@@ -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}"
+211 -2
View File
@@ -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
+9
View File
@@ -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".
+105 -15
View File
@@ -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:
+4 -3
View File
@@ -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 |
@@ -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<void> {
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<void> {
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
@@ -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',
@@ -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(
@@ -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<string> {
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'),
@@ -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,
+4 -2
View File
@@ -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",
@@ -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'],
@@ -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) =>
@@ -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,
@@ -74,6 +74,7 @@ describe('category operation performance phases', () => {
],
'live',
[102],
undefined,
recording.capture
)
).resolves.toEqual({ success: true });
@@ -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']);
});
});
@@ -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 };
}
@@ -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())
@@ -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[];
@@ -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
@@ -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);
});
});
@@ -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``;
}
@@ -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 : [],
})
);
@@ -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'],
@@ -0,0 +1,96 @@
type IpcHandler = (event: unknown, ...args: unknown[]) => Promise<unknown>;
const mockRegisteredHandlers = new Map<string, IpcHandler>();
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<string, Listener>();
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);
});
});
@@ -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<number, () => void>();
export async function applyParentalLockState(active: boolean): Promise<void> {
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<Electron.WebContentsDidStartNavigationEventParams>
) => {
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);
});
@@ -38,6 +38,7 @@ const handlers = new Map<string, SettingsUpdateHandler>();
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(
{},
@@ -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,
@@ -86,6 +86,7 @@ describe('DatabaseWorkerClient', () => {
nativeModuleSearchPaths: [
'/mock/resources/app.asar.unpacked/node_modules',
],
parentalLockActive: false,
},
});
@@ -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<void> {
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) {
@@ -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;
}
@@ -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[];
};
@@ -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<void> {
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);
}
});
});
@@ -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<TModule, TResult> {
readonly load: () => Promise<TModule>;
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<TModule, TResult> {
readonly module: TModule;
readonly result: TResult;
}
export interface DeferredBootstrapWebContents {
once(event: 'did-start-loading', listener: () => void): unknown;
}
export interface DeferredBootstrap<TModule, TResult> {
/** 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<DeferredBootstrapOutcome<TModule, TResult>>;
}
export function createDeferredBootstrap<TModule, TResult>(
options: DeferredBootstrapOptions<TModule, TResult>
): DeferredBootstrap<TModule, TResult> {
let started: Promise<DeferredBootstrapOutcome<TModule, TResult>> | null =
null;
let loadedModule: TModule | null = null;
const trigger = (
source: DeferredBootstrapTrigger = 'explicit'
): Promise<DeferredBootstrapOutcome<TModule, TResult>> => {
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,
};
}
@@ -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<typeof bootstrapWindowCloseGuard>;
}
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<void> {
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;
};
@@ -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<TResult = unknown> {
}
export type DbWorkerIncomingMessage =
DbWorkerRequestMessage | DbWorkerCancelMessage;
| DbWorkerRequestMessage
| DbWorkerCancelMessage
| DbWorkerParentalLockMessage;
export type DbWorkerMessage =
| DbWorkerReadyMessage
@@ -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) {
+73 -130
View File
@@ -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();
});
});
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../dist/out-tsc",
"esModuleInterop": true,
"allowJs": true,
+21
View File
@@ -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;
};
+3 -3
View File
@@ -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",
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+147 -13
View File
@@ -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) => {
+4
View File
@@ -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: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../coverage/apps/web',
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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 };
@@ -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();
@@ -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<void> {
const status = this.status();
const latestVersion = status?.latestVersion;
let dialogModule: Awaited<
ReturnType<typeof this.loadReleaseNotesDialog>
>;
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,
+16 -1
View File
@@ -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<RuntimeCapabilitiesService>;
let epgBridge: Partial<EpgRuntimeBridgeService>;
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),
+14 -1
View File
@@ -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;
+11 -5
View File
@@ -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,
+58 -11
View File
@@ -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<void>((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<void>
)(
{} 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<void>((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<typeof workspaceChildren>)
| undefined;
(() => Promise<typeof workspaceChildren>) | undefined;
const m3uRoutes = (await loadChildren?.()) ?? [];
const defaultRoute = m3uRoutes.find((route) => route.path === '');
const favoritesRoute = m3uRoutes.find(
+16 -2
View File
@@ -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();
@@ -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 () => {
@@ -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();
@@ -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<string, string>;
@@ -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';
@@ -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<LazyStalkerPlaylistConnectionEditor['loadEditorModule']>
>;
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<Promise<EditorModule>, []>()
.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);
});
});
@@ -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<StalkerPlaylistConnectionEditor> | null = null;
/** The dynamic import; a field so specs can substitute it. */
loadEditorModule: () => Promise<EditorModule> = () =>
import('./stalker-playlist-connection-editor.service');
async resolveConnection(
playlist: PlaylistMeta,
sourcePlaylist?: PlaylistMeta
): Promise<StalkerPlaylistConnectionResult> {
const editor = await this.resolveEditor();
return editor.resolveConnection(playlist, sourcePlaylist);
}
async applyResolvedConnection(
playlist: PlaylistMetaUpdate,
options?: StalkerResolvedConnectionApplyOptions
): Promise<PlaylistMetaUpdate> {
const editor = await this.resolveEditor();
return editor.applyResolvedConnection(playlist, options);
}
private resolveEditor(): Promise<StalkerPlaylistConnectionEditor> {
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;
}
}
@@ -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<void>;
failClosedNow(): void;
applyXtream(version: number): Promise<void>;
applyStalker(): Promise<void>;
}
describe('ParentalLockEnforcementService', () => {
const router = { url: '/', navigate: jest.fn() };
const activeChannel = signal<{ group?: { title: string } } | null>(null);
const dispatch = jest.fn();
const lockedStalkerIds = new Set<string>();
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<string>('vod'),
selectedCategoryId: signal<string | null>('*'),
selectedItem: signal<{
category_id?: string;
tv_genre_id?: string;
} | null>(null),
clearSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
const xtreamStore = {
playlistId: signal<string | null>('xtream-1'),
selectedCategoryId: signal<number | null>(null),
selectedItem: signal<{ category_id?: number } | null>(null),
reloadCategories: jest.fn(
async (_shouldPublish?: () => boolean): Promise<void> => 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<void>((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<unknown>;
}
).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<unknown>;
}
).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<number | null>(99),
selectedItem: signal(null),
reloadCategories: jest.fn(
() => new Promise<void>((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();
}
});
});
@@ -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<void> = Promise.resolve();
private stalkerModule: StalkerEnforcementModule | null = null;
private stalkerModuleLoad: Promise<StalkerEnforcementModule> | 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 <audio>.
this.parentalLock.registerBusyProbe(
() => this.keepAwake.hasPlayingVideo() || hasPlayingAudio()
);
// The Stalker step is loaded as soon as a Stalker route opens, so a
// relock there can run it synchronously instead of awaiting a chunk.
this.router.events?.subscribe((event) => {
if (
event instanceof NavigationEnd &&
STALKER_ROUTE.test(event.urlAfterRedirects)
) {
void this.loadStalker().catch(() => undefined);
}
});
// Any path that makes a channel active (numeric zapping, next /
// previous, remote commands, a stale list) is checked here: a
// channel of a locked group is reset as soon as it becomes active.
effect(() => {
this.activeChannel();
untracked(() => {
if (this.parentalLock.active()) {
this.applyM3u();
}
});
});
effect(() => {
const version = this.parentalLock.version();
untracked(() => {
if (version === this.lastVersion) {
return;
}
const first = this.lastVersion === -1;
this.lastVersion = version;
if (first) {
return;
}
// Fail closed NOW, ahead of the serialized queue: an earlier
// apply may still be waiting on a slow (or hung) read, and
// the catalog, details and playback read while unlocked must
// not stay usable until it settles.
if (this.parentalLock.active()) {
this.failClosedNow();
}
this.scheduleApply();
});
});
}
/**
* Applies run one at a time. `ElectronXtreamDataSource` shares in-flight
* reads per playlist and type, so an unlock refresh still running when
* "Lock now" arrives would hand the relock its unfiltered rows. Each
* apply also abandons its result once a newer version exists, leaving
* the queued apply to read the latest state.
*/
private scheduleApply(): void {
this.applyChain = this.applyChain
.then(() => this.apply())
.catch((error) => {
console.error(
'Failed to apply the parental lock change.',
error
);
});
}
/**
* The synchronous half of a relock: M3U channel, Stalker selection (its
* step is lazy, so it runs as soon as the chunk is there), the Xtream
* detail the lock store already places in a locked category, the
* catalog lists and the stored search. The queued apply then reloads
* the filtered rows and repeats the checks against them.
*/
private failClosedNow(): void {
this.applyM3u();
this.failClosedStalkerNow();
const playlistId = this.xtreamStore.playlistId?.();
if (!playlistId) {
return;
}
this.stepOffLockedXtreamSelection(
playlistId,
XTREAM_ROUTE.exec(this.router.url)
);
this.xtreamStore.withholdCatalog?.();
this.xtreamStore.clearSearchResults?.();
}
private async apply(): Promise<void> {
const version = this.parentalLock.version();
// The synchronous surfaces first: an M3U channel or a Stalker
// selection must not keep playing behind a slow Xtream database or
// provider reload — the Xtream store stays populated after leaving
// that portal, so its reload runs on every apply.
this.applyM3u();
await this.applyStalker();
await this.applyXtream(version);
}
private async applyXtream(version: number): Promise<void> {
const playlistId = this.xtreamStore.playlistId?.();
if (!playlistId) {
return;
}
// A playlist switch retires the apply as a newer lock version
// does: the store now holds another portal, whose own load reads
// under the current lock state.
const shouldPublish = (): boolean =>
this.parentalLock.version() === version &&
this.xtreamStore.playlistId?.() === playlistId;
await this.xtreamStore.reloadCategories(shouldPublish);
await this.xtreamStore.reloadCachedContent(shouldPublish);
if (!shouldPublish()) {
return;
}
// Stored in-portal search results are a separate array the search
// page renders directly; re-run the search so it reads filtered.
await this.xtreamStore.refreshSearchResults?.();
if (!shouldPublish() || !this.parentalLock.active()) {
return;
}
await this.stepOffWithheldXtreamSelection(playlistId, shouldPublish);
}
/**
* Post-reload check of the selected Xtream category and item, judged by
* the LOCK STORE through the unfiltered category rows (hidden and locked
* ones included): the reloaded list also omits categories the user
* merely hid, which are not parental-locked. The item is judged on its
* own category — opened from "All", recently added or search it has no
* selected category to vanish with. Rows that cannot be read fail
* closed.
*/
private async stepOffWithheldXtreamSelection(
playlistId: string,
shouldPublish: () => boolean
): Promise<void> {
const match = XTREAM_ROUTE.exec(this.router.url);
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
if (!categoryType) {
return;
}
const rows = await this.xtreamDataSource
.getAllCategories(playlistId, categoryType)
.catch(() => null);
if (!shouldPublish()) {
return;
}
const isWithheld = (categoryId: unknown): boolean => {
const id = Number(categoryId);
if (
categoryId === null ||
categoryId === undefined ||
!Number.isFinite(id)
) {
return false;
}
const row = rows?.find((candidate) => candidate.id === id);
return (
!row ||
this.parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
row.xtream_id
)
);
};
const selectedItem = this.xtreamStore.selectedItem?.() as {
category_id?: string | number;
} | null;
const itemWithheld = isWithheld(selectedItem?.category_id);
const categoryWithheld = isWithheld(
this.xtreamStore.selectedCategoryId()
);
if (!itemWithheld && !categoryWithheld) {
return;
}
this.xtreamStore.setSelectedItem(null);
if (categoryWithheld) {
this.xtreamStore.setSelectedCategory(null);
}
if (match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'xtreams',
match[1],
match[2],
]);
}
}
/**
* Clears a selected Xtream item whose category the lock store already
* says is locked. Electron rows carry the SQLite category row id; the
* category list still on screen maps it to the provider id the store is
* keyed by, the PWA carries the provider id directly. An item whose
* category is not in that list (a manually hidden category, opened
* through search) cannot be judged without an awaited read that may
* hang, so it is cleared too: fail closed.
*/
private stepOffLockedXtreamSelection(
playlistId: string,
match: RegExpExecArray | null
): void {
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
const selectedItem = this.xtreamStore.selectedItem?.() as {
category_id?: string | number;
} | null;
const categoryId = Number(selectedItem?.category_id);
if (!categoryType || !selectedItem || !Number.isFinite(categoryId)) {
return;
}
const category = this.xtreamStore
.getCategoriesBySelectedType()
.find(
(candidate) =>
Number((candidate as { id?: number }).id) === categoryId ||
Number(
(candidate as { category_id?: string }).category_id
) === categoryId
) as { xtream_id?: number; category_id?: string } | undefined;
const providerId = Number(category?.xtream_id ?? category?.category_id);
if (
category &&
Number.isFinite(providerId) &&
!this.parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
providerId
)
) {
return;
}
this.xtreamStore.setSelectedItem(null);
if (match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'xtreams',
match[1],
match[2],
]);
}
}
/** The dynamic import; a field so specs can substitute it. */
loadStalkerEnforcement = (): Promise<StalkerEnforcementModule> =>
import('./parental-lock-stalker-enforcement');
/** Loads the Stalker step once; a failed load may be retried. */
private loadStalker(): Promise<StalkerEnforcementModule> {
this.stalkerModuleLoad ??= this.loadStalkerEnforcement().then(
(module) => (this.stalkerModule = module),
(error: unknown) => {
this.stalkerModuleLoad = null;
throw error;
}
);
return this.stalkerModuleLoad;
}
/**
* The relock's Stalker step, synchronously: run it when the chunk is
* there, otherwise leave the Stalker route at once (its route session
* clears the selection and the live layout stops playback) rather than
* wait for a chunk that may be slow or never arrive.
*/
private failClosedStalkerNow(): void {
if (!STALKER_ROUTE.test(this.router.url)) {
return;
}
if (!this.stalkerModule) {
void this.router.navigate(['/workspace', 'sources']);
return;
}
this.stalkerModule.applyParentalLockToStalker(
this.injector,
this.parentalLock,
this.router
);
}
/**
* The Stalker data layer stays off the initial path: the step is loaded
* only while a Stalker route is open. Outside one there is nothing to
* step off — the Stalker route session clears the selection (and the
* live layout its playback) when the route is left.
*/
private async applyStalker(): Promise<void> {
if (!STALKER_ROUTE.test(this.router.url)) {
return;
}
let module: StalkerEnforcementModule;
try {
module = await this.loadStalker();
} catch (error) {
// Fail closed (e.g. a stale PWA page whose chunk is gone): leave
// the Stalker route. Its route session clears the selection and
// the live layout stops playback on the way out, so no locked
// channel keeps playing; the Xtream step still runs afterwards.
console.error(
'The parental lock Stalker step could not be loaded.',
error
);
void this.router.navigate(['/workspace', 'sources']);
return;
}
module.applyParentalLockToStalker(
this.injector,
this.parentalLock,
this.router
);
}
private applyM3u(): void {
const channel = this.activeChannel();
const playlistId = this.activeM3uPlaylistId();
if (!channel || !playlistId) {
return;
}
const groupTitle = channel.group?.title ?? '';
if (this.parentalLock.isM3uGroupLocked(playlistId, groupTitle)) {
this.store.dispatch(ChannelActions.resetActiveChannel());
}
}
private activeM3uPlaylistId(): string | null {
const match = /^\/workspace\/playlists\/([^/?#]+)/.exec(
this.router.url
);
return match ? decodeURIComponent(match[1]) : null;
}
}
type StalkerEnforcementModule =
typeof import('./parental-lock-stalker-enforcement');
function hasPlayingAudio(): boolean {
return Array.from(document.querySelectorAll('audio')).some(
(audio) => !audio.paused && !audio.ended
);
}
@@ -0,0 +1,8 @@
/**
* Lazy boundary for the parental-lock prompt: the prompt service imports
* this file dynamically, so the PIN dialog and the @angular/forms and
* Material form-field modules it brings stay off the initial path. The
* boundary is a local file backed by a file-level alias, like the other
* root-shell lazy dialogs (see docs/architecture/nx-workspace-boundaries.md).
*/
export { ParentalLockPinDialogComponent } from '@iptvnator/ui/components/parental-lock-pin-dialog';
@@ -0,0 +1,44 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { AppParentalLockPromptService } from './parental-lock-prompt.service';
describe('AppParentalLockPromptService', () => {
const dialog = { open: jest.fn() };
let service: AppParentalLockPromptService;
beforeEach(() => {
jest.clearAllMocks();
TestBed.configureTestingModule({
providers: [
AppParentalLockPromptService,
{ provide: MatDialog, useValue: dialog },
],
});
service = TestBed.inject(AppParentalLockPromptService);
});
it('loads the PIN dialog on demand and returns the entered PIN', async () => {
const open = jest.fn(() => ({ afterClosed: () => of('1234') }));
service.loadPinDialog = jest.fn(async () => ({
ParentalLockPinDialogComponent: { open },
})) as never;
await expect(service.requestPin({ mode: 'set' })).resolves.toBe('1234');
expect(open).toHaveBeenCalledWith(
dialog,
expect.objectContaining({ mode: 'set' })
);
});
it('treats a failed dialog chunk load as a cancelled prompt', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
service.loadPinDialog = jest.fn(async () => {
throw new Error('ChunkLoadError');
}) as never;
await expect(
service.requestPin({ mode: 'unlock' })
).resolves.toBeNull();
});
});
@@ -0,0 +1,62 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { firstValueFrom } from 'rxjs';
import {
PARENTAL_LOCK_PROMPT,
ParentalLockPrompt,
ParentalLockPromptRequest,
} from '@iptvnator/services';
/**
* Application implementation of the parental lock prompt: the shared PIN
* dialog from `@iptvnator/ui/components`, which the data-access lock
* service cannot import itself. The dialog is loaded on first use (the
* service is constructed at startup, the dialog is not needed until a PIN
* is asked for).
*/
@Injectable({ providedIn: 'root' })
export class AppParentalLockPromptService implements ParentalLockPrompt {
private readonly dialog = inject(MatDialog);
/** The dynamic import; a field so specs can substitute it. */
loadPinDialog = () => import('./parental-lock-pin-dialog.lazy');
async requestPin(
request: ParentalLockPromptRequest
): Promise<string | null> {
let module: Awaited<ReturnType<typeof this.loadPinDialog>>;
try {
module = await this.loadPinDialog();
} catch (error) {
// A failed chunk load reads as a cancelled prompt: the gate it
// guards stays closed.
console.error(
'The parental lock PIN dialog could not be loaded.',
error
);
return null;
}
const dialogRef = module.ParentalLockPinDialogComponent.open(
this.dialog,
{
mode: request.mode,
verify: request.verify,
throttle: request.throttle,
titleKey: request.titleKey,
descriptionKey: request.descriptionKey,
}
);
const pin = await firstValueFrom(dialogRef.afterClosed());
return typeof pin === 'string' ? pin : null;
}
}
export function provideParentalLockPrompt(): Provider[] {
return [
AppParentalLockPromptService,
{
provide: PARENTAL_LOCK_PROMPT,
useExisting: AppParentalLockPromptService,
},
];
}
@@ -0,0 +1,69 @@
import { Injector } from '@angular/core';
import { Router } from '@angular/router';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { toParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
import { STALKER_ROUTE } from './parental-lock-enforcement.service';
/**
* The Stalker step of `ParentalLockEnforcementService`, in its own file so
* the enforcement service can import it dynamically: a static import of the
* Stalker store would put the whole Stalker data layer back on the initial
* path (see docs/architecture/nx-workspace-boundaries.md).
*/
export function applyParentalLockToStalker(
injector: Injector,
parentalLock: ParentalLockService,
router: Router
): void {
const stalkerStore = injector.get(StalkerStore);
const playlist = stalkerStore.currentPlaylist();
const playlistId = playlist?._id;
if (!playlistId) {
return;
}
const contentType = toParentalLockStalkerCategoryType(
stalkerStore.selectedContentType()
);
if (!contentType) {
return;
}
const selectedCategoryId = stalkerStore.selectedCategoryId();
const categoryWithheld =
!!selectedCategoryId &&
parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
selectedCategoryId
);
// An item opened from "All" (`*`) or search has its own genre to be
// judged by; the list dropping its row is not enough. Live and radio
// rows carry that genre in `tv_genre_id`, VOD and series rows in
// `category_id` (the store's withheld filter applies the same rule).
const selectedItem = stalkerStore.selectedItem?.() as {
category_id?: string | number;
tv_genre_id?: string | number;
} | null;
const itemCategoryId =
contentType === 'itv' || contentType === 'radio'
? selectedItem?.tv_genre_id
: selectedItem?.category_id;
const itemWithheld =
!!selectedItem &&
parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
itemCategoryId
);
if (!categoryWithheld && !itemWithheld) {
return;
}
stalkerStore.clearSelectedItem();
if (categoryWithheld) {
stalkerStore.setSelectedCategory(null);
}
const match = STALKER_ROUTE.exec(router.url);
if (match && match[1] === playlistId) {
void router.navigate(['/workspace', 'stalker', match[1], match[2]]);
}
}
@@ -1,7 +1,7 @@
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
type ElectronWindow = Window & {
electron?: { setPlaybackKeepAwake?: jest.Mock };
type ElectronWindow = {
electron?: Pick<typeof window.electron, 'setPlaybackKeepAwake'>;
};
const flush = () => Promise.resolve().then(() => Promise.resolve());
@@ -215,9 +215,7 @@ describe('PlaybackKeepAwakeService', () => {
});
it('releases a lock that resolves after playback already stopped', async () => {
let resolveRequest:
| ((value: typeof sentinel) => void)
| undefined;
let resolveRequest: ((value: typeof sentinel) => void) | undefined;
request.mockImplementationOnce(
() =>
new Promise((resolve) => {
@@ -72,6 +72,11 @@ export class PlaybackKeepAwakeService {
this.sync();
};
/** Whether any tracked `<video>` is currently playing. */
hasPlayingVideo(): boolean {
return this.playingVideos.size > 0;
}
start(): void {
if (this.started) {
return;
@@ -1,7 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { DataService } from '@iptvnator/services';
import { ExternalPlayerSession, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
ExternalPlayerSession,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from './settings-store.service';
import { PlayerService } from './player.service';
@@ -92,6 +95,57 @@ describe('PlayerService', () => {
expect(result).toEqual(session);
});
it('opens the external-player info dialog once its chunk has loaded', async () => {
settingsStore.player.mockReturnValue(VideoPlayer.MPV);
dataService.sendIpcEvent.mockResolvedValue(undefined);
await service.openResolvedPlayback(
{ streamUrl: 'https://example.com/video.mp4', title: 'Example' },
false
);
await new Promise((resolve) => setTimeout(resolve));
expect(dialog.open).toHaveBeenCalledTimes(1);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
'OPEN_MPV_PLAYER',
expect.anything()
);
});
it('still starts playback and logs when the info dialog fails to load', async () => {
const error = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const unhandled = jest.fn();
process.on('unhandledRejection', unhandled);
try {
settingsStore.player.mockReturnValue(VideoPlayer.VLC);
dataService.sendIpcEvent.mockResolvedValue(undefined);
service.loadExternalPlayerInfoDialog = () =>
Promise.reject(new Error('chunk failed'));
await service.openResolvedPlayback(
{
streamUrl: 'https://example.com/video.mp4',
title: 'Example',
},
false
);
await new Promise((resolve) => setTimeout(resolve));
expect(dialog.open).not.toHaveBeenCalled();
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
'OPEN_VLC_PLAYER',
expect.anything()
);
expect(error).toHaveBeenCalledTimes(1);
expect(unhandled).not.toHaveBeenCalled();
} finally {
process.off('unhandledRejection', unhandled);
error.mockRestore();
}
});
it('forces external playback without changing the selected embedded player', async () => {
const session: ExternalPlayerSession = {
id: 'session-2',
+26 -5
View File
@@ -1,6 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ExternalPlayerInfoDialogComponent } from '@iptvnator/ui/playback/external-player-info-dialog';
import { DataService } from '@iptvnator/services';
import {
ExternalPlayerSession,
@@ -70,12 +69,12 @@ export class PlayerService {
if (player === VideoPlayer.MPV) {
if (!hideExternalInfoDialog) {
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.showExternalPlayerInfoDialog();
}
return await this.openExternalPlayback(playback, 'mpv');
} else if (player === VideoPlayer.VLC) {
if (!hideExternalInfoDialog) {
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.showExternalPlayerInfoDialog();
}
return await this.openExternalPlayback(playback, 'vlc');
}
@@ -83,12 +82,34 @@ export class PlayerService {
return;
}
/**
* Loaded on demand: the dialog's Material checkbox brings @angular/forms,
* and this service is created with the app, so a static import would put
* both on the initial path. Playback does not wait for the dialog.
*/
private showExternalPlayerInfoDialog(): void {
this.loadExternalPlayerInfoDialog()
.then(({ ExternalPlayerInfoDialogComponent }) =>
this.dialog.open(ExternalPlayerInfoDialogComponent)
)
.catch((error: unknown) => {
// Informational only: playback is already starting.
console.error(
'Could not load the external player info dialog:',
error
);
});
}
/** The dynamic import; a field so specs can substitute it. */
loadExternalPlayerInfoDialog = () =>
import('./external-player-info-dialog.lazy');
async openExternalPlayback(
playback: ResolvedPortalPlayback,
player: ExternalPlayerName
): Promise<ExternalPlayerSession | void> {
const ipcEvent =
player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER;
const ipcEvent = player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER;
return await this.dataService.sendIpcEvent<ExternalPlayerSession>(
ipcEvent,
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util';
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util/playlist-file-import';
import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces';
type PlaylistOpenRequestBridge = {
@@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core';
import {
PORTAL_PLAYBACK_POSITIONS,
PortalPlaybackPositions,
} from '@iptvnator/portal/shared/util';
} from '@iptvnator/portal/shared/util/tokens';
import {
PlaybackPositionRuntimeBridgeService,
RuntimeCapabilitiesService,
+1 -1
View File
@@ -34,7 +34,7 @@ import {
logPortalDebugEvent,
logPortalDebugRequest,
createLogger,
} from '@iptvnator/portal/shared/util';
} from '@iptvnator/portal/shared/util/logger';
import { getRuntimeBackendUrl } from './runtime-config';
/**
@@ -78,7 +78,8 @@ describe('Service: Settings', () => {
));
describe('Test theme switch', () => {
let spyOnAdd, spyOnRemove;
let spyOnAdd: jest.SpyInstance<void, [...tokens: string[]]>;
let spyOnRemove: jest.SpyInstance<void, [...tokens: string[]]>;
beforeEach(() => {
spyOnAdd = jest.spyOn(document.body.classList, 'add');
spyOnRemove = jest.spyOn(document.body.classList, 'remove');
@@ -48,6 +48,7 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
title: 'Stalker Portal',
count: 0,
importDate: '2026-08-08T00:00:00.000Z',
autoRefresh: false,
portalUrl: 'https://portal.example.com/c',
macAddress: '00:1A:79:AA:BB:CC',
username: 'subscriber',
@@ -15,7 +15,7 @@ import {
type StalkerPlaylistConnectionEditor,
type StalkerPlaylistConnectionResult,
type StalkerResolvedConnectionApplyOptions,
} from '@iptvnator/playlist/shared/ui';
} from '@iptvnator/playlist/shared/ui/stalker-connection-editor';
import {
normalizeStalkerPortalIdentity,
type Playlist,
@@ -1,7 +1,7 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util';
import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util/tokens';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspaceAccountInfoData,
@@ -9,7 +9,7 @@
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="version">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VERSION' | translate }}</h4>
<p>{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}</p>
@@ -31,7 +31,10 @@
</div>
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
<div class="setting-item setting-item--full-control app-update-panel">
<div
class="setting-item setting-item--full-control app-update-panel"
data-setting-id="app-update"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}</p>
@@ -263,7 +266,10 @@
</div>
}
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="tmdb-credits"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_CREDITS' | translate }}</h4>
<p>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</p>
@@ -281,7 +287,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="community"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.COMMUNITY' | translate }}</h4>
<p>{{ 'SETTINGS.COMMUNITY_DESCRIPTION' | translate }}</p>
@@ -364,7 +373,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="support"
>
<div class="support-card" data-test-id="support-card">
<div class="support-card__intro">
<div class="support-card__emblem">
@@ -53,6 +53,8 @@ describe('SettingsAboutSectionComponent app updates', () => {
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
supportedSelfUpdate: true,
channel: 'stable',
installedChannel: 'stable',
});
getButton(fixture, 'app-update-download')?.click();
@@ -76,6 +78,8 @@ describe('SettingsAboutSectionComponent app updates', () => {
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
supportedSelfUpdate: true,
channel: 'stable',
installedChannel: 'stable',
});
getButton(fixture, 'app-update-install')?.click();
@@ -96,6 +100,8 @@ describe('SettingsAboutSectionComponent app updates', () => {
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
supportedSelfUpdate: true,
channel: 'stable',
installedChannel: 'stable',
});
getButton(fixture, 'app-update-release-notes')?.click();
@@ -114,6 +120,8 @@ describe('SettingsAboutSectionComponent app updates', () => {
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
channel: 'stable',
installedChannel: 'stable',
});
getButton(fixture, 'app-update-open-release')?.click();
@@ -129,6 +137,8 @@ describe('SettingsAboutSectionComponent app updates', () => {
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
channel: 'stable',
installedChannel: 'stable',
});
expect(fixture.componentInstance.appUpdateStatusLabelKey()).toBe(
@@ -1,7 +1,4 @@
<section
class="settings-group"
id="backup"
>
<section class="settings-group" id="backup">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>backup</mat-icon>
@@ -12,7 +9,7 @@
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="import-export-data">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h4>
<p>
@@ -5,7 +5,7 @@ import {
selectIsEpgAvailable,
} from '@iptvnator/m3u-state';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaylistBackupService } from '@iptvnator/services';
import { PlaylistBackupService } from '@iptvnator/services/playlist-backup';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockProvider } from 'ng-mocks';
@@ -3,11 +3,11 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
PlaylistBackupImportSummary,
PlaylistBackupService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
} from '@iptvnator/services/playlist-backup';
import { SettingsSnackbarService } from './settings-snackbar.service';
@Injectable()
@@ -1,8 +1,4 @@
<section
class="settings-group"
id="dashboard"
[formGroup]="form()"
>
<section class="settings-group" id="dashboard" [formGroup]="form()">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>dashboard</mat-icon>
@@ -13,7 +9,7 @@
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="show-dashboard">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
<p>
@@ -29,7 +25,7 @@
</div>
<div formGroupName="dashboardRails">
<div class="setting-item">
<div class="setting-item" data-setting-id="dashboard-hero">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_HERO' | translate }}</h4>
<p>
@@ -44,7 +40,10 @@
</div>
</div>
<div class="setting-item">
<div
class="setting-item"
data-setting-id="dashboard-rail-continue-watching"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}</h4>
<p>
@@ -62,11 +61,14 @@
</div>
</div>
<div class="setting-item">
<div
class="setting-item"
data-setting-id="dashboard-rail-live-favorites"
>
<div class="setting-item__meta">
<h4>{{
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate
}}</h4>
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION'
@@ -82,11 +84,9 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="dashboard-rail-recent-live">
<div class="setting-item__meta">
<h4>{{
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate
}}</h4>
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION'
@@ -102,11 +102,11 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="dashboard-rail-favorite-vod">
<div class="setting-item__meta">
<h4>{{
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate
}}</h4>
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION'
@@ -122,7 +122,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="dashboard-rail-sources">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}</h4>
<p>
@@ -140,11 +140,11 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="dashboard-rail-xtream-added">
<div class="setting-item__meta">
<h4>{{
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate
}}</h4>
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION'
@@ -160,9 +160,14 @@
</div>
</div>
<div class="setting-item">
<div
class="setting-item"
data-setting-id="dashboard-rail-tmdb-trending"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}</h4>
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
@@ -178,7 +183,10 @@
</div>
</div>
<div class="setting-item">
<div
class="setting-item"
data-setting-id="dashboard-rail-tmdb-recommendations"
>
<div class="setting-item__meta">
<h4>
{{
@@ -9,7 +9,10 @@
</div>
</header>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="epg-view-mode"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}</h4>
<p>{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}</p>
@@ -43,7 +46,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="epg-offset"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}</p>
@@ -69,7 +75,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="epg-sources"
>
<div class="setting-item__meta" data-test-id="epg-source-formats">
<h4>{{ 'SETTINGS.EPG_SOURCE_LIST_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}</p>
@@ -178,7 +187,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="prefer-uploaded-epg">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}</p>
@@ -21,10 +21,15 @@ import {
MockSettingsStore,
} from './test-stubs/settings-test-harness.stub';
/** Writable double for the bridge's read-only capability getters. */
type EpgBridgeStub = {
-readonly [K in keyof EpgRuntimeBridgeService]?: EpgRuntimeBridgeService[K];
};
describe('SettingsEpgFacade', () => {
let facade: SettingsEpgFacade;
let formFacade: SettingsFormFacade;
let epgBridge: Partial<EpgRuntimeBridgeService>;
let epgBridge: EpgBridgeStub;
let epgService: EpgService;
let dialogService: DialogService;
let snackBar: MatSnackBarStub;
@@ -19,6 +19,7 @@ import {
normalizeEmbeddedMpvExtraOptions,
normalizeExternalPlayerArguments,
normalizeEpgOffsetMinutes,
normalizeParentalLockRelockMinutes,
normalizeStartupWindowMode,
Settings,
StartupBehavior,
@@ -228,6 +229,12 @@ export function createSettingsFromFormValue(
enabled: value.tmdb?.enabled ?? DEFAULT_TMDB_SETTINGS.enabled,
apiKey: value.tmdb?.apiKey?.trim() ?? '',
},
// Not on the form: the parental lock section applies its changes
// immediately (they gate on the PIN), so Save must not undo them.
parentalLockEnabled: currentSettings.parentalLockEnabled === true,
parentalLockRelockMinutes: normalizeParentalLockRelockMinutes(
currentSettings.parentalLockRelockMinutes
),
};
}
@@ -9,7 +9,7 @@
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="language">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.LANGUAGE' | translate }}</h4>
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
@@ -40,7 +40,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="theme"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.THEME' | translate }}</h4>
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
@@ -74,7 +77,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="cover-size"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.COVER_SIZE' | translate }}</h4>
<p>{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}</p>
@@ -108,7 +114,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="cover-titles">
<div class="setting-item__meta">
<h4 id="cover-titles-label">
{{ 'SETTINGS.COVER_TITLES' | translate }}
@@ -127,7 +133,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="startup-behavior">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
<p>
@@ -160,7 +166,11 @@
</div>
@if (supportsStartupWindowMode()) {
<div class="setting-item" data-test-id="startup-window-mode-setting">
<div
class="setting-item"
data-setting-id="startup-window-mode"
data-test-id="startup-window-mode-setting"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}</h4>
<p>
@@ -195,7 +205,7 @@
</div>
}
<div class="setting-item">
<div class="setting-item" data-setting-id="show-captions">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}</h4>
<p>
@@ -207,7 +217,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="strip-country-prefix">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}</h4>
<p>
@@ -233,7 +243,7 @@
<h3>{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}</h3>
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="portal-connectivity-guard">
<div class="setting-item__meta">
<h4 id="portal-connectivity-label">
{{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }}
Loaded 100 of 494 files, more files were not shown because too many files have changed in this diff. Show more