mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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.
|
||||
@@ -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".
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
@@ -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) {
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,6 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in new issue
Block a user