Merge origin/master into claude/parental-control-feature-31dde2

Resolves the settings-store defaults split, the electron-conf key list and
the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental
lock contract is linked from the agent context map instead).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-25 22:12:58 +02:00
commit ae5d3f49de
732 files changed
+42572 -15380

No files matched your search

@@ -1,9 +0,0 @@
---
type: internal
area: dashboard
---
The dashboard's live "now playing" time range and progress bar now read
pre-computed EPG timestamps as unix seconds, matching the rest of the EPG
code. Today's dashboard lookups never carry those fields, so nothing changes
on screen; this closes the gap before a future data path supplies them.
@@ -1,10 +0,0 @@
---
type: perf
area: database
issues: [1292]
---
Refreshing or removing a large Xtream playlist is several times faster: the
"Removing cached content" stage and the re-import now commit around 5,000
rows at a time instead of 100, and progress updates arrive at most ten times a
second instead of once per batch.
@@ -1,7 +0,0 @@
---
type: fix
area: database
issues: [1580]
---
Updating directly from older versions no longer blocks playlist loading with a missing database column error. Existing sources, favorites, and playback history are preserved.
-10
View File
@@ -1,10 +0,0 @@
---
type: internal
area: deps
---
Closes the four open Dependabot alerts on transitive npm dependencies via
pinned pnpm overrides: browserslist (crash on untrusted stats), @xmldom/xmldom
(XML fragment injection — its existing override target had itself fallen into
the advisory range), @humanfs/node (recursive copy follows symlinks out of the
tree) and postcss-selector-parser (AST recursion DoS).
-6
View File
@@ -1,6 +0,0 @@
---
type: feature
area: downloads
---
Download completed Xtream catch-up programmes as TS files from Live TV programme details. Find them in Downloads with the channel and broadcast date and play them offline. Interrupted archive downloads restart from the beginning.
@@ -1,11 +0,0 @@
---
type: feature
area: embedded-mpv
highlight: Embedded MPV reconnects dropped streams
---
The embedded MPV player now reloads a live stream that drops mid-playback on
its own, with increasing delays and an "attempt N of 6" line instead of a
dead error screen; a new Settings > Playback toggle turns this off. The same
section gains an advanced field for extra libmpv options (one key=value per
line) that applies on every engine, with a short network timeout on by default.
-10
View File
@@ -1,10 +0,0 @@
---
type: fix
area: embedded-mpv
---
When a stream refuses the connection, the embedded MPV player (frame-copy
engine) no longer hands the URL to yt-dlp before giving up: the failure
shows up right away and its error no longer reads "youtube-dl failed:
unexpected error occurred", matching the native-view engines and the
external MPV player.
-6
View File
@@ -1,6 +0,0 @@
---
type: feature
area: epg
---
Copy a catch-up programme’s URL from its EPG details in Xtream and supported M3U sources, including Favorites and Recent, without interrupting playback. The link can be used in an external player or download tool.
-7
View File
@@ -1,7 +0,0 @@
---
type: feature
area: epg
issues: [50]
---
EPG settings now include a display-only time offset, making it easy to correct provider feeds with missing or incorrect timezone information without re-importing the guide.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: epg
issues: [1586]
---
EPG guides now load when a server adds gzip compression to an already compressed XMLTV file. Sources using a single gzip layer continue to work.
-13
View File
@@ -1,13 +0,0 @@
---
type: feature
area: epg
issues: [171]
highlight: Programme guide, rebuilt
---
The programme guide now shows your playlist's own channels in their order,
with the current group or favorites one click away. Click a channel to
switch playback while the player stays on screen above the grid;
double-click switches and closes. Open it from the new Guide button, the
header, the command palette, or the G key. Hide channels without EPG and
pick comfortable or compact rows.
-6
View File
@@ -1,6 +0,0 @@
---
type: fix
area: epg
---
Removing and saving an EPG source now clears its cached programmes, including data left by previously removed sources on restart. Other configured sources and playlist guides are preserved, and Live TV stops showing programmes from the removed source.
@@ -1,6 +0,0 @@
---
type: feature
area: epg
---
The EPG timeline toolbar is more compact: "Now" is an icon button, and the zoom slider is replaced by a single button that cycles day overview → by hour → detailed. Ctrl/⌘ + scroll (or a trackpad pinch) over the timeline zooms smoothly around the cursor. The channel and programme title now use all the space the controls free up.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: host-health
issues: [1439]
---
Portal recovery now waits for an active probe to finish before sending another request, even when redirects or a slow response take longer than 45 seconds. Completed and cancelled requests release the probe slot reliably.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: live-tv
issues: [1458]
---
A hidden channel list no longer looks like a playlist that lost its channels: the player now says the list is hidden and offers a "Show channels list" button, a toggle in the workspace header stays in place in both states, and hiding the list in one place (M3U player, portal Live TV, favorites/recent) no longer hides it everywhere else.
-6
View File
@@ -1,6 +0,0 @@
---
type: fix
area: m3u
---
ClearKey channels with ordinary Base64 keys now play when imported or refreshed, including JSON playlist exports that use + and / characters. Refresh an existing playlist once to apply the fix.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: m3u
issues: [1590]
---
DASH channels, including ClearKey-protected streams, now play from Recently Viewed and Favorites using the compatible built-in player. Existing imports keep their DRM keys without needing to re-import the playlist.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: m3u
issues: [465, 1120]
---
M3U URL imports now accept a custom User-Agent for providers that require it before downloading the playlist. The value is saved and reused for playlist refreshes in the desktop app and self-hosted web app.
-6
View File
@@ -1,6 +0,0 @@
---
type: fix
area: m3u
---
Movies and episodes recognized as video files in M3U playlists now offer playback time and seeking in built-in players, even when TMDB or movie details are disabled. Seeking remains dependent on the source's support.
@@ -1,7 +0,0 @@
---
type: fix
area: migration
issues: [1504]
---
Upgrades from older desktop versions retain all sources from the legacy profile. Already-upgraded users can choose to recover missing sources without replacing current sources or settings. Migration keeps the original data and retries safely after a failed write.
-6
View File
@@ -1,6 +0,0 @@
---
type: fix
area: migration
---
Startup now shows a theme-aware preparation screen while loading sources and checking a large programme guide. Temporary source-read failures retry automatically; if loading still fails, you can retry without restarting the app. Recovery also completes any previously failed programme-guide cleanup before opening the library.
@@ -1,6 +0,0 @@
---
type: feature
area: packaging
---
AppImage builds now include an update source for AppManager, allowing it to discover new GitHub releases and download full application updates without manually configuring the repository.
@@ -1,10 +0,0 @@
---
type: fix
area: playback
---
The built-in player controls now hide on their own after you click a button
in the bar with the mouse, such as fullscreen or mute. Previously the clicked
button kept the bar pinned on screen until you clicked the video, which also
paused it. Applies to the shared controls in the web players and the
Embedded MPV frame-copy engine.
-6
View File
@@ -1,6 +0,0 @@
---
type: feature
area: playback
---
Playback errors now explain confirmed access and DRM license failures, with stream codecs, DRM systems and failure stages in the details. Copy diagnostics creates a support report without stream URLs or credentials. No extra requests are made to your provider.
@@ -1,10 +0,0 @@
---
type: fix
area: playback
---
Arrow keys and the ±10 s buttons in the Embedded MPV player now move by their
full step every time. Pressing an arrow repeatedly, or holding it, used to
advance only about a second per press because each step was computed from a
stale position; steps are now relative seeks executed by mpv itself, so rapid
presses add up.
@@ -1,12 +0,0 @@
---
type: feature
area: playback
issues: [48]
highlight: Channel list in fullscreen
---
Rest the mouse on the left edge (tap it on touch) or press C while a live
channel plays fullscreen: the channel list (M3U all/groups/favorites/recent,
Xtream, Stalker, global favorites) slides over the video with search, so you
can zap without leaving fullscreen. Nothing covers the video while it is
closed. M3U playlists also zap with PageUp/PageDown.
@@ -1,6 +0,0 @@
---
type: fix
area: playback
---
The fullscreen channel panel stays open under the pointer when its opening animation is delayed, instead of closing before the list appears.
@@ -1,10 +0,0 @@
---
type: fix
area: playback
---
With the default shared player controls, the built-in player now stays in
fullscreen when you switch to another episode, when the next episode starts
automatically, and when a live channel or an alternative movie source is
switched — previously every switch dropped back to the page. The legacy
vendor-controls opt-out keeps its previous behavior.
-6
View File
@@ -1,6 +0,0 @@
---
type: fix
area: playback
---
Switching channels or leaving playback now closes the old picture-in-picture window in HTML5, Video.js, and ArtPlayer even when shared player controls are disabled, preventing frozen or outdated video from remaining on top. This includes Safari’s legacy picture-in-picture mode.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: playback
issues: [1530]
---
Embedded MPV controls and programme-guide panels now follow the visual theme and stay readable in light and dark mode. Video overlays retain their contrasting dark backgrounds.
@@ -1,9 +0,0 @@
---
type: fix
area: playback
---
The built-in HTML5 player now plays `.mkv`, `.webm`, `.avi`, `.mov` and other
non-HLS video files directly instead of handing them to the HLS engine, which
failed with a "network or provider loading error" on many Xtream episodes and
movies. ArtPlayer and the HTML5 player now choose their engine by the same rule.
@@ -1,10 +0,0 @@
---
type: fix
area: playback
---
Keyboard shortcuts work again right after you click a button in the built-in
player controls. Previously the clicked button kept the keyboard: after
clicking fullscreen, Space left fullscreen instead of pausing, and the seek,
volume and mute keys did nothing until you clicked the video. Applies to the
shared controls in the web players and the Embedded MPV frame-copy engine.
@@ -1,10 +0,0 @@
---
type: fix
area: playback
---
With IPTVnator's shared controls turned off, the Video.js player's keyboard
shortcuts now work again right after you click a button in its control bar.
Previously the clicked button kept the keyboard: after clicking fullscreen,
Space left fullscreen instead of pausing, and the seek, volume and mute keys
did nothing until you clicked the video.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: playback
issues: [871]
---
VLC now opens without an extra console window on Windows when playback progress tracking or Reuse VLC instance is enabled.
-11
View File
@@ -1,11 +0,0 @@
---
type: feature
area: playback
highlight: See what a stream really is while it plays
---
The player overlay now shows stream information: resolution, playback and source
frame rates, stream and codec bitrates, audio details, buffer and dropped frames.
Available in built-in web players and experimental frame-copy Embedded MPV.
Unknown or unavailable values stay hidden; measured frame rates reflect dropped frames and
stalls.
@@ -1,6 +0,0 @@
---
type: feature
area: playlist
---
Desktop users can review inactive sources and delete selected ones together. The dialog checks the whole library, preselects confirmed expired or disabled accounts, and lets you keep individual sources. Busy sources are skipped, and deletion results are reported individually.
@@ -1,6 +0,0 @@
---
type: feature
area: playlist
---
Desktop playlist lists now show availability for Stalker portals and M3U links alongside Xtream accounts. Checks share cached results, run in the background, and explain when a source could not be verified.
@@ -1,6 +0,0 @@
---
type: fix
area: playlists
---
Opening an M3U playlist immediately after refreshing now waits for its pending save, so the channel list does not revert to the previous catalog.
@@ -1,7 +0,0 @@
---
type: fix
area: portals
issues: [1438]
---
Xtream and Stalker portals no longer enter a connection cooldown merely because several parallel requests fail in the same millisecond. This fixes false unavailability in both the desktop app and the self-hosted web version.
-6
View File
@@ -1,6 +0,0 @@
---
type: feature
area: portals
---
Desktop settings now let you disable the temporary pause after repeated Xtream or Stalker connection failures, without restarting. Account info explains when requests are paused and offers an immediate retry.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: portals
issues: [1520]
---
Xtream and Stalker keep remote channel order while you browse other categories or search. Use Show playing channel to return to the current channel without restarting playback. Stalker radio supports the same behavior.
@@ -1,10 +0,0 @@
---
type: feature
area: portals
---
Live TV panels now fold in steps: hide only the categories rail and keep the
channel list, or hide both for a player-only view. With categories hidden, the
list header becomes a category dropdown, so switching categories stays one
click away, and hiding both panels then bringing them back returns to the
level you had before.
-10
View File
@@ -1,10 +0,0 @@
---
type: feature
area: shell
issues: [1455]
---
Settings → General has a new "Window on startup" option: open the desktop app
at its last size, maximized, or fullscreen — handy on a TV or HTPC. Starting
with `--fullscreen` forces a single fullscreen launch without changing the
setting, and F11 now toggles fullscreen anywhere in the app.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: stalker
issues: [1543]
---
Stalker Live TV search stays within the selected category in the sidebar and fullscreen panel, including channels beyond the first page. All Items searches the whole catalog, and the two search fields work independently.
@@ -1,9 +0,0 @@
---
type: fix
area: stalker
---
In Stalker portals, switching the Live TV or radio category in the sidebar no
longer stops the channel that is playing. The category only changes which
channels are listed, matching how Xtream portals and M3U playlists already
behave.
@@ -1,6 +0,0 @@
---
type: fix
area: stalker
---
Stalker series with season markers such as “s02” or “2 сезон” now show the correct season in tabs and episode labels while preserving watch progress. TMDB updates no longer reset loaded episodes, and delayed portal responses cannot mix episodes from different series.
-6
View File
@@ -1,6 +0,0 @@
---
type: fix
area: stalker
---
Seeking in Stalker movies and series now reaches the selected position instead of jumping forward or skipping to the next episode after resuming playback.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: ui
issues: [1506]
---
Live TV now supports keyboard movement between categories and channels, and channel lists keep scrolling after mouse selection. Channel scrollbars and resize handles are independently accessible. Movie and series details support immediate keyboard scrolling and no longer hide their scrollbars.
-6
View File
@@ -1,6 +0,0 @@
---
type: fix
area: ui
---
Detail buttons, episode cards and episode rows now keep visible backgrounds and borders in the light theme. The grid/list switch also clearly highlights the selected view in both themes.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: ui
issues: [1570]
---
Movie and series detail pages keep their Back button visible while scrolling. Escape closes the inline player to the description, then returns to the previous view. Open menus, dialogs and fullscreen retain priority.
@@ -1,7 +0,0 @@
---
type: fix
area: web-backend
issues: [1436]
---
The self-hosted backend now checks every provider redirect and pins connections to validated addresses, preventing redirects or DNS changes from bypassing private-network restrictions. Trusted LAN access remains available through the existing opt-in.
-7
View File
@@ -1,7 +0,0 @@
---
type: fix
area: xtream
issues: [1513]
---
Xtream Live TV in Auto can try advertised TS once when HLS initially fails with an HTTP error in a web player. The selected player and stream headers are kept. Settings explain the manual TS workaround for external players, Embedded MPV, and HLS retries that never report a terminal failure.
@@ -1,7 +0,0 @@
---
type: fix
area: xtream
issues: [1562]
---
Catch-up (timeshift) from the Favorites and Recent tabs now asks the panel for the programme you clicked: the start time is rendered in the panel's own timezone instead of your computer's. The panel's timezone is remembered per source, survives restarts, and panels that report an unusual timezone name are handled through their clock.
@@ -1,7 +0,0 @@
---
type: fix
area: xtream
issues: [812, 816]
---
Xtream category management now selects or deselects only matching categories while searching, preserving the visibility of all other categories. Button labels and availability reflect the search results, and the counter clearly shows the total selected.
-6
View File
@@ -1,6 +0,0 @@
---
type: fix
area: xtream
---
The explicit HTTPS and HTTP connection test now detects Xtream portals that accept HTTP when HTTPS is unavailable and fills in the working address before you save. The saved address is used for catalog updates, provider EPG and playback. Connection failures now show a more specific explanation.
-6
View File
@@ -1,6 +0,0 @@
---
type: fix
area: xtream
---
The Xtream sync panel now keeps its status text, labels and Stop sync button clearly readable in both light and dark themes.
+2
View File
@@ -7,6 +7,8 @@ description: Use when preparing, cutting, tagging, publishing, or verifying an I
Full contract, asset table and rationale: `docs/architecture/release-pipeline.md`.
For imagegen announcement covers, reuse [the approved artwork prompt](../../../docs/development/release-cover-artwork.md).
The tag workflow authors the public GitHub body with
`node tools/release/extract-changelog-section.mjs --public "${VERSION}"`.
Keep the full changelog, including internal notes, committed before tagging.
+2
View File
@@ -7,6 +7,8 @@ description: Use when preparing, cutting, tagging, publishing, or verifying an I
Full contract, asset table and rationale: `docs/architecture/release-pipeline.md`.
For imagegen announcement covers, reuse [the approved artwork prompt](../../../docs/development/release-cover-artwork.md).
The tag workflow authors the public GitHub body with
`node tools/release/extract-changelog-section.mjs --public "${VERSION}"`.
Keep the full changelog, including internal notes, committed before tagging.
+297 -12
View File
@@ -13,6 +13,14 @@ name: Build and Make Electron App
# cleanup-pr-draft.yml deletes the draft when the PR closes, so the stale
# window is visible and bounded; refreshing drafts on skipped runs is not
# worth a separate workflow.
#
# Master pushes are the nightly channel: the nightly-version job computes
# one <patch>-nightly.<date>.<run number> version for the whole run
# (tools/release/nightly-version.mjs), every build job writes it into
# package.json so electron-updater treats the build as newer than the
# released version, and the release job publishes the artifacts as a
# prerelease of 4gray/iptvnator-nightly instead of the rolling test-master
# draft. Contract: docs/architecture/release-pipeline.md ("Nightly channel").
on:
push:
branches:
@@ -42,6 +50,39 @@ permissions:
contents: read
jobs:
# One version for the whole run. Computed here rather than in each build
# job because the rule depends on whether the base tag exists on origin:
# a tag pushed while the matrix runs would otherwise give one run two
# different versions. Empty output means "not a nightly build".
nightly-version:
name: Resolve nightly version
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.resolve.outputs.version }}
steps:
- name: Checkout code
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator'
uses: actions/checkout@v7
- name: Resolve nightly version
id: resolve
shell: bash
env:
NIGHTLY: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator' }}
run: |
set -euo pipefail
if [ "${NIGHTLY}" != "true" ]; then
echo "version=" >> "${GITHUB_OUTPUT}"
echo "Not a master push; no nightly version."
exit 0
fi
VERSION="$(node tools/release/nightly-version.mjs)"
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
echo "Nightly version: ${VERSION}"
linux-embedded-mpv-runtime:
name: Build pinned Linux Embedded MPV runtime
runs-on: ubuntu-22.04
@@ -61,7 +102,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
node-version-file: '.nvmrc'
- name: Resolve Linux runtime toolchain cache key
id: linux-runtime-cache-key
@@ -340,6 +381,7 @@ jobs:
build-cross-platform:
name: Build on ${{ matrix.os }} ${{ matrix.arch }}
needs: nightly-version
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
concurrency:
@@ -379,7 +421,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install Linux system dependencies
@@ -445,6 +487,23 @@ jobs:
BUILD_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
run: node tools/build/inject-build-commit.mjs
- name: Apply nightly version
# Master merges feed the nightly update channel. The version
# must be greater than the released one for electron-updater to
# offer it, and it must be in package.json before the frontend
# and backend builds and electron-builder read it. The value
# comes from the nightly-version job so every job of this run
# builds the same version. The script also sets the electron-
# builder publish channel to "nightly", which names the updater
# metadata nightly-mac.yml / nightly.yml / nightly-linux.yml.
if: needs.nightly-version.outputs.version != ''
# Windows runners default to PowerShell, where "${NIGHTLY_VERSION}"
# expands to nothing and the script rejects the empty version.
shell: bash
env:
NIGHTLY_VERSION: ${{ needs.nightly-version.outputs.version }}
run: node tools/release/nightly-version.mjs --apply --version "${NIGHTLY_VERSION}"
- name: Build frontend
run: pnpm nx build web --skip-nx-cache
@@ -1091,6 +1150,16 @@ jobs:
electron-backend-e2e:packaged-frame-copy-smoke \
--skip-nx-cache
- name: Upload packaged frame-copy smoke diagnostics
if: always() && matrix.os == 'linux' && matrix.linux_profile == 'portable'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: packaged-frame-copy-smoke
path: |
dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke/
dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/
retention-days: 7
- name: Diagnose packaged x64 frame-copy hardware path
if: matrix.os == 'linux' && matrix.linux_profile == 'portable'
continue-on-error: true
@@ -1188,6 +1257,7 @@ jobs:
dist/executables/**/*.dmg
dist/executables/**/*.zip
dist/executables/**/latest-mac.yml
dist/executables/**/nightly-mac.yml
dist/executables/**/*.blockmap
retention-days: 7
@@ -1212,6 +1282,7 @@ jobs:
dist/executables/*.AppImage
dist/executables/*.snap
dist/executables/**/latest-linux*.yml
dist/executables/**/nightly-linux*.yml
dist/executables/**/*.blockmap
retention-days: 7
@@ -1234,12 +1305,15 @@ jobs:
dist/executables/**/*.msi
dist/executables/**/*.zip
dist/executables/**/latest.yml
dist/executables/**/nightly.yml
dist/executables/**/*.blockmap
retention-days: 7
build-linux:
name: Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }})
needs: linux-embedded-mpv-runtime
needs:
- linux-embedded-mpv-runtime
- nightly-version
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
concurrency:
@@ -1276,6 +1350,7 @@ jobs:
create-release:
name: Create Draft Release
needs:
- nightly-version
- build-cross-platform
- build-linux
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
@@ -1285,6 +1360,12 @@ jobs:
cancel-in-progress: false
permissions:
contents: write
env:
# Master pushes publish to the nightly repository (steps at the
# end of this job) instead of the rolling draft.
NIGHTLY: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator' }}
NIGHTLY_REPOSITORY: 4gray/iptvnator-nightly
NIGHTLY_KEEP_RELEASES: '20'
steps:
- name: Checkout code
@@ -1306,10 +1387,20 @@ jobs:
node <<'NODE'
const fs = require('fs');
const candidates = [
'artifacts/macos-x64-artifacts/latest-mac.yml',
'artifacts/macos-arm64-artifacts/latest-mac.yml',
].filter((filePath) => fs.existsSync(filePath));
// electron-builder names the updater metadata after the
// channel: latest-mac.yml for releases, nightly-mac.yml for
// the prerelease versions master builds carry.
const channelFile = ['latest-mac.yml', 'nightly-mac.yml'].find(
(name) =>
fs.existsSync(`artifacts/macos-x64-artifacts/${name}`) ||
fs.existsSync(`artifacts/macos-arm64-artifacts/${name}`)
);
const candidates = channelFile
? [
`artifacts/macos-x64-artifacts/${channelFile}`,
`artifacts/macos-arm64-artifacts/${channelFile}`,
].filter((filePath) => fs.existsSync(filePath))
: [];
if (candidates.length === 0) {
console.log('No macOS update metadata found; skipping merge.');
@@ -1407,8 +1498,8 @@ jobs:
mergedEntries
);
fs.writeFileSync('artifacts/latest-mac.yml', merged);
console.log(`Merged ${candidates.length} macOS update metadata files.`);
fs.writeFileSync(`artifacts/${channelFile}`, merged);
console.log(`Merged ${candidates.length} macOS update metadata files into ${channelFile}.`);
NODE
- name: Get version from package.json
@@ -1422,6 +1513,7 @@ jobs:
# for every push. PR builds must not use github.sha here: that is the
# ephemeral merge-commit SHA, which resolves to nothing in the repo.
- name: Compose release metadata
if: env.NIGHTLY != 'true'
id: release-meta
shell: bash
env:
@@ -1499,7 +1591,7 @@ jobs:
# full current set right after. Only drafts are pruned; published
# releases are never touched.
- name: Prune stale draft assets
if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
if: env.NIGHTLY != 'true' && (github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.release-meta.outputs.tag }}
@@ -1522,7 +1614,7 @@ jobs:
- name: Create Draft Release
id: draft-release
if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
if: env.NIGHTLY != 'true' && (github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open')
uses: softprops/action-gh-release@v3
with:
draft: true
@@ -1569,7 +1661,7 @@ jobs:
# body is left as the action set it and only title/commitish are
# re-asserted.
- name: Ensure draft metadata is current
if: steps.draft-release.outputs.id != ''
if: env.NIGHTLY != 'true' && steps.draft-release.outputs.id != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
@@ -1612,3 +1704,196 @@ jobs:
--arg body "${FULL_BODY}" \
'{tag_name: $tag, name: $name, target_commitish: $commitish, body: ($body | .[0:120000])}' |
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null
# ── Nightly channel ──────────────────────────────────────────
# Drafts are invisible to anyone without write access and to
# electron-updater, so master builds are published as prereleases
# of the nightly repository, which the desktop app's Nightly update
# channel follows. The repository needs one commit on its default
# branch (gh creates the release tag there) and a fine-grained PAT
# with Contents: read/write on it, stored as NIGHTLY_RELEASE_TOKEN.
# Without the token the build still succeeds and only warns.
- name: Resolve nightly release metadata
if: env.NIGHTLY == 'true'
id: nightly-meta
shell: bash
env:
NIGHTLY_RELEASE_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }}
VERSION: ${{ needs.nightly-version.outputs.version }}
run: |
set -euo pipefail
if [ -z "${VERSION}" ]; then
echo "::error::The nightly-version job produced no version for this master push."
exit 1
fi
{
echo "version=${VERSION}"
echo "tag=v${VERSION}"
} >> "${GITHUB_OUTPUT}"
if [ -z "${NIGHTLY_RELEASE_TOKEN}" ]; then
echo "::warning::NIGHTLY_RELEASE_TOKEN is not configured; the nightly release for ${VERSION} is skipped."
echo "publish=false" >> "${GITHUB_OUTPUT}"
else
echo "publish=true" >> "${GITHUB_OUTPUT}"
fi
# The notes list the master commits since the previous nightly.
# That nightly's source commit is read back from the marker its
# own notes carry, because the nightly repository has no copy of
# the app history to compare against. The main-repository compare
# uses GITHUB_TOKEN; only the nightly repository is read with the
# PAT.
- name: Compose nightly release notes
if: steps.nightly-meta.outputs.publish == 'true'
id: nightly-notes
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NIGHTLY_RELEASE_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }}
VERSION: ${{ steps.nightly-meta.outputs.version }}
HEAD_SHA: ${{ github.sha }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
SHORT_SHA="${HEAD_SHA:0:7}"
NOTES_FILE="${RUNNER_TEMP}/nightly-notes.md"
PREVIOUS_TAG="$(GH_TOKEN="${NIGHTLY_RELEASE_TOKEN}" gh release list \
--repo "${NIGHTLY_REPOSITORY}" --exclude-drafts --limit 1 \
--json tagName --jq '.[0].tagName // ""')"
PREVIOUS_SHA=""
if [ -n "${PREVIOUS_TAG}" ]; then
PREVIOUS_SHA="$(GH_TOKEN="${NIGHTLY_RELEASE_TOKEN}" gh release view "${PREVIOUS_TAG}" \
--repo "${NIGHTLY_REPOSITORY}" --json body --jq '.body // ""' |
sed -n 's/.*<!-- iptvnator-commit: \([0-9a-f]\{40\}\) -->.*/\1/p' | head -n 1)"
fi
COMMITS=""
if [ -n "${PREVIOUS_SHA}" ] && [ "${PREVIOUS_SHA}" != "${HEAD_SHA}" ]; then
# A rewritten history makes the compare fail; the notes
# then just omit the list rather than failing the release.
COMMITS="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PREVIOUS_SHA}...${HEAD_SHA}" \
--jq '.commits[] | "- [`\(.sha[0:7])`](\(.html_url)) \(.commit.message | split("\n")[0])"' || true)"
fi
{
printf '🌙 Nightly build from `master` — commit [`%s`](%s/commit/%s) · [workflow run](%s)\n\n' \
"${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${RUN_URL}"
printf 'Untested snapshot of master for the desktop app'"'"'s **Nightly** update channel (Settings → About → Update channel). Nightly builds may break, and their database changes are permanent: switching back to Stable keeps this build installed until the next stable release is newer. Back up your playlists first.\n\n'
if [ -n "${COMMITS}" ]; then
printf '## Changes since %s\n\n%s\n\n' "${PREVIOUS_TAG}" "${COMMITS}"
else
printf 'See the [commit history](%s/commits/master) for what changed.\n\n' "${REPO_URL}"
fi
printf '<!-- iptvnator-commit: %s -->\n' "${HEAD_SHA}"
} > "${NOTES_FILE}"
echo "notes-file=${NOTES_FILE}" >> "${GITHUB_OUTPUT}"
# Created as a draft, assets uploaded, then published in one edit,
# so electron-updater never sees a release whose channel file is
# still missing. A published release is never deleted here: a
# rerun after a successful publish is a no-op, and only a draft
# left behind by a failed run is replaced. The release job is
# serialized per ref but two master runs can still finish out of
# order, so a nightly that is older than the newest published one
# is dropped instead of becoming the feed's newest entry.
- name: Publish nightly release
if: steps.nightly-meta.outputs.publish == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }}
TAG: ${{ steps.nightly-meta.outputs.tag }}
VERSION: ${{ steps.nightly-meta.outputs.version }}
HEAD_SHA: ${{ github.sha }}
NOTES_FILE: ${{ steps.nightly-notes.outputs.notes-file }}
run: |
set -euo pipefail
shopt -s nullglob
NEWEST_PUBLISHED="$(gh release list --repo "${NIGHTLY_REPOSITORY}" --exclude-drafts --limit 200 \
--json tagName --jq '.[].tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+-nightly\\."))' |
sort -V | tail -n 1)"
if [ -n "${NEWEST_PUBLISHED}" ] && [ "${NEWEST_PUBLISHED}" != "${TAG}" ] &&
[ "$(printf '%s\n%s\n' "${NEWEST_PUBLISHED}" "${TAG}" | sort -V | tail -n 1)" != "${TAG}" ]; then
echo "::notice::${NEWEST_PUBLISHED} is already published and newer than ${TAG}; not publishing this superseded build."
exit 0
fi
for required in \
artifacts/nightly-mac.yml \
artifacts/windows-artifacts/nightly.yml \
artifacts/linux-portable-artifacts/nightly-linux.yml; do
if [ ! -f "${required}" ]; then
echo "::error::Missing updater metadata ${required}; the nightly channel would be unable to install this build."
exit 1
fi
done
assets=(
artifacts/macos-x64-artifacts/*-x64.dmg
artifacts/macos-x64-artifacts/*-x64.zip
artifacts/macos-x64-artifacts/*.blockmap
artifacts/macos-arm64-artifacts/*-arm64.dmg
artifacts/macos-arm64-artifacts/*-arm64.zip
artifacts/macos-arm64-artifacts/*.blockmap
artifacts/nightly-mac.yml
artifacts/linux-system-artifacts/*.deb
artifacts/linux-system-artifacts/*.rpm
artifacts/linux-system-artifacts/*.pacman
artifacts/linux-system-artifacts/*.pkg.tar.*
artifacts/linux-portable-artifacts/*.AppImage
artifacts/linux-portable-artifacts/*.snap
artifacts/linux-portable-artifacts/nightly-linux*.yml
artifacts/linux-portable-artifacts/*.blockmap
artifacts/linux-flatpak-artifacts/*.flatpak
artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz
artifacts/windows-artifacts/*-setup.exe
artifacts/windows-artifacts/*.msi
artifacts/windows-artifacts/*.zip
artifacts/windows-artifacts/nightly.yml
artifacts/windows-artifacts/*.blockmap
)
EXISTING="$(gh api "repos/${NIGHTLY_REPOSITORY}/releases?per_page=100" --paginate |
jq -c --arg tag "${TAG}" 'map(select(.tag_name == $tag)) | first // empty')"
if [ -n "${EXISTING}" ]; then
if [ "$(jq -r '.draft' <<< "${EXISTING}")" != "true" ]; then
echo "::notice::${TAG} is already published in ${NIGHTLY_REPOSITORY}; nothing to do for this re-run."
exit 0
fi
DRAFT_ID="$(jq -r '.id' <<< "${EXISTING}")"
echo "Removing the draft ${TAG} (id ${DRAFT_ID}) a failed run left behind."
gh api -X DELETE "repos/${NIGHTLY_REPOSITORY}/releases/${DRAFT_ID}"
fi
gh release create "${TAG}" "${assets[@]}" \
--repo "${NIGHTLY_REPOSITORY}" \
--draft \
--prerelease \
--title "Nightly ${VERSION} (${HEAD_SHA:0:7})" \
--notes-file "${NOTES_FILE}"
gh release edit "${TAG}" --repo "${NIGHTLY_REPOSITORY}" --draft=false --prerelease
echo "Published ${TAG} to ${NIGHTLY_REPOSITORY} with ${#assets[@]} assets."
- name: Prune old nightly releases
if: steps.nightly-meta.outputs.publish == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }}
run: |
set -euo pipefail
gh release list --repo "${NIGHTLY_REPOSITORY}" --exclude-drafts --limit 200 \
--json tagName --jq '.[].tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+-nightly\\."))' |
sort -V -r | tail -n "+$((NIGHTLY_KEEP_RELEASES + 1))" |
while read -r tag; do
[ -n "${tag}" ] || continue
echo "Deleting nightly ${tag} (keeping the newest ${NIGHTLY_KEEP_RELEASES})."
gh release delete "${tag}" --repo "${NIGHTLY_REPOSITORY}" --cleanup-tag --yes
done
+5 -2
View File
@@ -103,7 +103,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
@@ -141,12 +141,15 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Validate agent guidance
run: pnpm run agents:validate
- name: Validate Nx dependency version policy
run: pnpm run deps:nx:validate
+107 -5
View File
@@ -3,12 +3,20 @@ name: Cleanup PR Draft Release
on:
pull_request:
types: [closed]
# The event above is the fast path, not a guarantee: GitHub does not run a
# `pull_request: closed` workflow when the head ref is already gone at
# event time, which is exactly what Dependabot does when it supersedes one
# of its own PRs (closes it and deletes the branch in a single operation).
# Those drafts — and any the event path missed for other reasons — are
# collected by the scheduled sweep below.
schedule:
- cron: '17 4 * * *'
workflow_dispatch:
# contents: write — delete the draft release; actions: write — cancel the
# closed PR's still-running build workflow before deleting.
# contents: write — delete the draft release. The `actions: write` needed to
# cancel a closed PR's still-running build is scoped to the event job.
permissions:
actions: write
contents: write
contents: read
jobs:
delete-draft:
@@ -16,8 +24,11 @@ jobs:
# Fork PRs never get a draft (the release job skips them) and their
# GITHUB_TOKEN is read-only regardless of the permissions block, so
# there is nothing to cancel or delete.
if: github.event.pull_request.head.repo.full_name == github.repository
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
# A closed PR can be reopened while this job is still queued or
# waiting; a reopened PR's fresh build must not be cancelled and
@@ -90,3 +101,94 @@ jobs:
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \
--jq ".[] | select(.draft and .tag_name == \"test-pr-${PR_NUMBER}\") | .id" |
xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/{}"
sweep-drafts:
name: Sweep orphaned PR draft releases
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
# Two sweeps must not race each other into a double delete; a manual
# dispatch during the nightly cron would otherwise produce a spurious
# failure on an already-deleted draft.
concurrency:
group: cleanup-pr-draft-sweep
cancel-in-progress: false
steps:
# Unlike the event job this one does not cancel in-progress builds:
# the build's draft steps are themselves gated on the live PR state
# being `open` ("Check PR is still open" in build-and-make.yaml), so
# a run that outlives the close cannot recreate what was swept. A
# build that passed that check just before the PR closed is caught
# by the next sweep.
#
# Draft releases have no real git tag, so they are invisible to
# `gh release view <tag>` and to the tags API. Enumerate releases
# and match on the stored tag_name, exactly as the event job does.
# The `test-<branch>` drafts and every other release are left
# alone by the ^test-pr-<n>$ shape.
- name: Delete drafts whose PR is closed
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Assigned rather than piped: a failed or partially paginated
# listing must fail the job instead of silently sweeping a
# short list.
drafts="$(
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \
--jq '.[] | select(.draft and (.tag_name | test("^test-pr-[0-9]+$"))) | "\(.id) \(.tag_name)"'
)"
if [ -z "${drafts}" ]; then
echo "No test-pr-<n> drafts found."
exit 0
fi
failed=0
while IFS=' ' read -r release_id tag; do
pr_number="${tag#test-pr-}"
# Fail closed: a lookup error (404, rate limit, outage)
# leaves `state` empty and the draft untouched. Only a
# PR GitHub currently reports as closed loses its draft,
# so a reopened PR and an open PR mid-build keep theirs.
# gh's own stderr is left visible on purpose — a draft
# kept as `unknown` should say why in the job log.
state="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.state' || true)"
if [ "${state}" != "closed" ]; then
echo "Keeping ${tag}: PR #${pr_number} is ${state:-unknown}."
continue
fi
if gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/${release_id}" >/dev/null; then
echo "Deleted ${tag} (release ${release_id}) for closed PR #${pr_number}."
continue
fi
# The delete failed. Only a release GitHub confirms is
# gone (404) excuses that — the event job racing us to
# the same draft. `gh api` exits 1 for every failure
# alike, so a rate limit or outage hitting both calls
# would otherwise read as "already deleted" and leave a
# green sweep behind an undeleted draft. Read the status
# line instead: `-i` prints it even on an error status,
# and a request that never got a response leaves it
# empty, which is not 404 and so stays a failure.
recheck_status="$(
gh api -i "repos/${GITHUB_REPOSITORY}/releases/${release_id}" 2>/dev/null |
sed -n '1s#^HTTP/[0-9.]* \([0-9]\{3\}\).*#\1#p' || true
)"
if [ "${recheck_status}" = "404" ]; then
echo "Draft ${tag} (release ${release_id}) was already gone."
else
echo "::error::Failed to delete draft ${tag} (release ${release_id}); re-check returned ${recheck_status:-no HTTP status}."
failed=1
fi
done <<< "${drafts}"
exit "${failed}"
+1
View File
@@ -6,6 +6,7 @@
name: "CodeQL"
on:
workflow_dispatch:
push:
branches: [master]
pull_request:
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
+5 -2
View File
@@ -59,7 +59,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install Dependencies
@@ -68,6 +68,9 @@ jobs:
- name: Build Backend
run: pnpm nx build electron-backend
- name: Verify Electron process cleanup
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
@@ -113,7 +116,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install Dependencies
@@ -35,7 +35,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '22'
node-version-file: '.nvmrc'
- name: Refresh pin when it approaches upstream retention
id: refresh
+1
View File
@@ -0,0 +1 @@
22.23.2
@@ -0,0 +1,67 @@
# Agent guidance reorganization — issue #1643
Approved implementation plan, 2026-09-20.
## Outcome
One source of common instructions: AGENTS.md (at most 200 lines / 16 KiB).
CLAUDE.md imports @AGENTS.md and contains only Claude-specific guidance
(at most 30 lines / 2 KiB). Do not increase Codex loading limits. No runtime
or public API changes.
## Knowledge preservation
Inventory both original files at the starting commit in
`docs/maintenance/agent-guidance-migration.md`. Record source section and line
ranges, destination document and heading, and whether each contract was moved,
merged with an existing equivalent, or corrected with evidence. Split long
player sections into individual contracts. Preserve exceptions, commands,
rationale and platform constraints. Do not create a required monolithic archive.
## Destinations
Use existing authoritative docs first: Nx boundaries for structure/dependencies;
validation-map for tests/lint; release-pipeline and release skills for releases;
sqlite-db-worker and the database README for IPC/migrations; m3u-playlist-module
for M3U/XMLTV/startup/source health; Xtream/Stalker compatibility docs for portals;
player-controls-contract for web controls/radio/sleep; embedded-mpv-native for
native runtime/packaging; UI guidelines, detail navigation and remote control for
navigation; PWA/host connectivity/security docs for networking; existing download,
TMDB, multi-source, workspace and backup docs for their domains; website README
for website policy.
Create docs/development/agent-workflow.md for documentation/skill maintenance and
Angular conventions, and docs/development/electron-debugging.md for CDP/tracing.
Add a developer navigation link in README.md.
## Root guidance and navigation
Retain project purpose, essential commands, .nvmrc/frozen install/Nx bootstrap,
scoped imports and boundaries, migration safety, credential redaction, regression
coverage, release-note/doc requirements, protected Markdown formatting and plan
storage. Preserve the Nx-managed block/markers, conditional on available tools.
Replace mandatory root-file updates with updates to each subsystem's canonical
doc. Root instructions hold only universal rules and a compact topic routing table.
Create docs/maintenance/agent-context-map.md with topics, code paths, docs and
skills. Read affected contracts only; cross-domain work reads each relevant one.
Update existing skills rather than proliferating copies; preserve byte-identical
release mirrors. No mass nested instructions in this change.
## Tooling
Extend repository-skills (no new Nx project) with agents:validate and node:test
coverage. Check UTF-8 bytes/line budgets, one standalone @AGENTS.md import in
CLAUDE.md and no other root imports, local navigation/map/migration links and
anchors, and literal repository paths without treating globs/commands as paths.
Add an unconditional CI validation step and correct Nx test inputs/lint commands.
## Acceptance
Tests cover exact/over budgets, UTF-8, LF/CRLF, missing/duplicate/extra imports,
missing local files and anchors. Run frozen install, Nx discovery, repository-skills
test/lint, agents:validate, skills:validate, release:notes:validate, git diff --check
and workflow validation. Audit every source block to a destination, with no
unresolved or lost unique contract. Walk navigation for XMLTV, Xtream, MPV,
migrations and releases. App unit/E2E is unnecessary (no runtime changes); no
release note for docs/tooling validation. Do not run whole-file Prettier on docs,
AGENTS.md or CLAUDE.md.
+115 -1163
View File
File diff suppressed because it is too large. Load diff
+141
View File
@@ -11,6 +11,147 @@ this marker — see `.changes/README.md`.
<!-- next-release -->
# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-09-21)
![IPTVnator 0.24.0 — programme guide, fullscreen browsing and stream info](https://raw.githubusercontent.com/4gray/iptvnator/v0.24.0/apps/website/public/blog/v0-24/announce.png)
## Highlights
### Programme guide, rebuilt
The M3U programme guide now follows your playlist's own channels and order, with groups and favorites one click away. Keep the player above the grid while you browse, click a channel to switch, or double-click to switch and close. Open the guide with **G**, hide channels without EPG and choose compact or comfortable rows. ([#1560](https://github.com/4gray/iptvnator/pull/1560), closes [#171](https://github.com/4gray/iptvnator/issues/171))
### Browse channels and episodes without leaving fullscreen
Press **C** or use the left edge of the video to open a searchable channel list. Series get season tabs and an episode list with artwork and watch progress too. With the default shared controls, switching channels, episodes or alternative movie sources keeps the player fullscreen. ([#1519](https://github.com/4gray/iptvnator/pull/1519), [#1620](https://github.com/4gray/iptvnator/pull/1620), [#1509](https://github.com/4gray/iptvnator/pull/1509), closes [#48](https://github.com/4gray/iptvnator/issues/48))
### See what is playing while it plays
The stream-info overlay shows resolution, frame rates, bitrates, audio details, buffer and dropped frames when the player can report them. Available in the built-in web players and experimental frame-copy Embedded MPV; unavailable values stay hidden. ([#1578](https://github.com/4gray/iptvnator/pull/1578))
---
**27 features · 54 fixes · 1 performance improvement** — including local XMLTV files, Xtream catch-up downloads, automatic reconnects for embedded MPV, season artwork and recovery for sources missing after an older upgrade.
Read the 📝 [release story on the blog](https://4gray.github.io/iptvnator/blog/v0-24-release-notes/), the [full CHANGELOG](https://github.com/4gray/iptvnator/blob/v0.24.0/CHANGELOG.md), or the [changes since v0.23.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0).
Before updating, back up your playlists and important data. Desktop startup applies database updates and clears outdated metadata matches; allow the preparation screen to finish and avoid downgrading an upgraded profile.
## Thanks
Thank you to [@Bpl5966](https://github.com/Bpl5966) for MPV reconnects and extra options ([#1515](https://github.com/4gray/iptvnator/pull/1515)), [@mark-jardine](https://github.com/mark-jardine) for the EPG time offset ([#1489](https://github.com/4gray/iptvnator/pull/1489)), and [@larsemig](https://github.com/larsemig) for stream information ([#1578](https://github.com/4gray/iptvnator/pull/1578)). A warm thank-you to everyone who reported issues, tested builds, helped in the [Telegram community](https://t.me/iptvnator), or supported development. 💙
If IPTVnator is useful to you, you can help keep it moving through [GitHub Sponsors](https://github.com/sponsors/4gray) or [Ko-fi](https://ko-fi.com/4gray).
<details>
<summary><b>All features, fixes and performance improvements</b></summary>
### Features
- **collections** — A live channel you watch from Favorites or Recently viewed now says which playlist it belongs to: a chip with the playlist name sits in the programme panel next to the channel, and clicking it (or "Open in <playlist>" in the channel's right-click menu) jumps to that channel inside its own playlist. Works for Xtream and M3U sources. ([#1634](https://github.com/4gray/iptvnator/pull/1634))
- **collections** — "Open in <playlist>" for live channels in Favorites and Recently viewed now works for Stalker portals too: the chip in the programme panel and the channel's right-click menu jump to that channel inside its portal's Live TV, with its category selected and the channel playing. Radio stations keep the existing behaviour. ([#1639](https://github.com/4gray/iptvnator/pull/1639))
- **dashboard** — The dashboard no longer labels every title with "Xtream · Series" or "Stalker · Movie": the hero shows only the source name, and Continue Watching cards show the episode you are on plus the minutes left. Stalker shows filed under Movies now get that S·E badge, the progress bar, and a working "Resume episode" that opens the show at the saved episode. ([#1646](https://github.com/4gray/iptvnator/pull/1646))
- **dashboard** — On the desktop app, favourite and recently watched Xtream and Stalker channels on the dashboard now show what is on air, with the programme's time and progress. Each card asks its portal only once it scrolls into view and fills in on its own, so a slow portal never holds up the page or the other cards. ([#1638](https://github.com/4gray/iptvnator/pull/1638))
- **downloads** — Download completed Xtream catch-up programmes as TS files from Live TV programme details. Find them in Downloads with the channel and broadcast date and play them offline. Interrupted archive downloads restart from the beginning. ([#1572](https://github.com/4gray/iptvnator/pull/1572))
- **embedded-mpv** — The embedded MPV player now reloads a live stream that drops mid-playback on its own, with increasing delays and an "attempt N of 6" line instead of a dead error screen; a new Settings > Playback toggle turns this off. The same section gains an advanced field for extra libmpv options (one key=value per line) that applies on every engine, with a short network timeout on by default. ([#1515](https://github.com/4gray/iptvnator/pull/1515))
- **epg** — Copy a catch-up programme’s URL from its EPG details in Xtream and supported M3U sources, including Favorites and Recent, without interrupting playback. The link can be used in an external player or download tool. ([#1569](https://github.com/4gray/iptvnator/pull/1569))
- **epg** — EPG settings now include a display-only time offset, making it easy to correct provider feeds with missing or incorrect timezone information without re-importing the guide. ([#1489](https://github.com/4gray/iptvnator/pull/1489), closes [#50](https://github.com/4gray/iptvnator/issues/50))
- **epg** — EPG sources no longer have to be URLs: point Settings → EPG or a playlist's EPG list at an XMLTV file on your computer, by absolute path, `file://` link, or the new folder button. Plain `.xml` and gzip `.xml.gz` both work. A typed path is confirmed once in a native dialog before it is read; the settings page spells out the accepted formats with examples. ([#1600](https://github.com/4gray/iptvnator/pull/1600))
- **epg** — The programme guide now shows your playlist's own channels in their order, with the current group or favorites one click away. Click a channel to switch playback while the player stays on screen above the grid; double-click switches and closes. Open it from the new Guide button, the header, the command palette, or the G key. Hide channels without EPG and pick comfortable or compact rows. ([#1560](https://github.com/4gray/iptvnator/pull/1560), closes [#171](https://github.com/4gray/iptvnator/issues/171))
- **epg** — The EPG timeline toolbar is more compact: "Now" is an icon button, and the zoom slider is replaced by a single button that cycles day overview → by hour → detailed. Ctrl/⌘ + scroll (or a trackpad pinch) over the timeline zooms smoothly around the cursor. The channel and programme title now use all the space the controls free up. ([#1557](https://github.com/4gray/iptvnator/pull/1557))
- **packaging** — AppImage builds now include an update source for AppManager, allowing it to discover new GitHub releases and download full application updates without manually configuring the repository. ([#1559](https://github.com/4gray/iptvnator/pull/1559))
- **playback** — Playback errors now explain confirmed access and DRM license failures, with stream codecs, DRM systems and failure stages in the details. Copy diagnostics creates a support report without stream URLs or credentials. No extra requests are made to your provider. ([#1574](https://github.com/4gray/iptvnator/pull/1574))
- **playback** — Rest the mouse on the left edge (tap it on touch) or press C while a live channel plays fullscreen: the channel list (M3U all/groups/favorites/recent, Xtream, Stalker, global favorites) slides over the video with search, so you can zap without leaving fullscreen. Nothing covers the video while it is closed. M3U playlists also zap with PageUp/PageDown. ([#1519](https://github.com/4gray/iptvnator/pull/1519), closes [#48](https://github.com/4gray/iptvnator/issues/48))
- **playback** — Series playback now has the slide-in list in fullscreen too: rest the mouse on the left edge, click it, or press C to see the season tabs and episodes with stills, runtimes, descriptions and watch progress, and jump to another episode without leaving fullscreen. Works for Xtream and Stalker series with any built-in player. ([#1620](https://github.com/4gray/iptvnator/pull/1620))
- **playback** — The player overlay now shows stream information: resolution, playback and source frame rates, stream and codec bitrates, audio details, buffer and dropped frames. Available in built-in web players and experimental frame-copy Embedded MPV. Unknown or unavailable values stay hidden; measured frame rates reflect dropped frames and stalls. ([#1578](https://github.com/4gray/iptvnator/pull/1578))
- **playlist** — Desktop users can review inactive sources and delete selected ones together. The dialog checks the whole library, preselects confirmed expired or disabled accounts, and lets you keep individual sources. Busy sources are skipped, and deletion results are reported individually. ([#1596](https://github.com/4gray/iptvnator/pull/1596))
- **playlist** — Desktop playlist lists now show availability for Stalker portals and M3U links alongside Xtream accounts. Checks share cached results, run in the background, and explain when a source could not be verified. ([#1592](https://github.com/4gray/iptvnator/pull/1592))
- **portal** — Movies can now be marked as watched (or unwatched) by hand from their detail page on Xtream and Stalker sources, including the Favorites and Recently Viewed views — the same way seasons and episodes already could. The catalog shows the green check right away, and a watched movie offers Play instead of a Resume near the end. ([#1605](https://github.com/4gray/iptvnator/pull/1605))
- **portal** — Series with more than six seasons now show a small season poster in the season dropdown — on each row that has one and on the closed selector — so a long list of "Season N" entries is easier to scan. The fullscreen episode panel's season strip also gets its own "N episodes" translation instead of borrowing the download manager's. ([#1633](https://github.com/4gray/iptvnator/pull/1633))
- **portal** — Series pages now show each season's own poster next to the season tabs, and the fullscreen episode list shows it above its tabs too. The picture comes from TMDB when metadata enrichment is on, otherwise from your provider's season art; one-season shows keep the page as it was. ([#1628](https://github.com/4gray/iptvnator/pull/1628))
- **portals** — Desktop settings now let you disable the temporary pause after repeated Xtream or Stalker connection failures, without restarting. Account info explains when requests are paused and offers an immediate retry. ([#1536](https://github.com/4gray/iptvnator/pull/1536))
- **portals** — Live TV panels now fold in steps: hide only the categories rail and keep the channel list, or hide both for a player-only view. With categories hidden, the list header becomes a category dropdown, so switching categories stays one click away, and hiding both panels then bringing them back returns to the level you had before. ([#1556](https://github.com/4gray/iptvnator/pull/1556))
- **portals** — Movie and series grids can now drop the title row: turn off "Show titles under covers" in Settings > General and the catalog, favorites and recent grids become a wall of covers that shows more rows per screen. The title slides in on the cover when you point at it or focus it with the keyboard, and stays on for items whose cover is missing. Live channels and search results keep their labels. ([#1604](https://github.com/4gray/iptvnator/pull/1604))
- **shell** — Settings → General has a new "Window on startup" option: open the desktop app at its last size, maximized, or fullscreen — handy on a TV or HTPC. Starting with `--fullscreen` forces a single fullscreen launch without changing the setting, and F11 now toggles fullscreen anywhere in the app. ([#1514](https://github.com/4gray/iptvnator/pull/1514), closes [#1455](https://github.com/4gray/iptvnator/issues/1455))
- **shell** — Zoom the app from the keyboard on every platform: Ctrl (Cmd on macOS) with +/- steps the size in about 10 % increments, including on the numpad, and Ctrl/Cmd+0 returns to the default. Windows and Linux gain these shortcuts, which previously existed only in the macOS menu. ([#1623](https://github.com/4gray/iptvnator/pull/1623), closes [#1109](https://github.com/4gray/iptvnator/issues/1109))
- **updater** — Settings → About now has an Update channel switch. Nightly follows every merge into master: the desktop updater offers each new build automatically, and About shows the build's commit for bug reports. Stable stays the default; a nightly build remains installed until the next stable release is newer. ([#1608](https://github.com/4gray/iptvnator/pull/1608))
### Fixes
- **collections** — Switching Favorites or Recently Viewed between "This playlist" and "All playlists" now shows a thin progress bar under the header and dims the list while the new items load, instead of leaving the old list on screen with no sign that anything happened. The toggle reflects your choice at once, a playing channel keeps playing, and quick switches show no indicator at all. ([#1636](https://github.com/4gray/iptvnator/pull/1636))
- **dashboard** — The dashboard's "Now on air on favorite channels" and "Recently watched Live TV" rails now show the current programme for channels whose guide lives in an XMLTV another playlist imported, not only in the global EPG sources from Settings — the same lookup the "See all" pages already used. ([#1637](https://github.com/4gray/iptvnator/pull/1637))
- **database** — Updating directly from older versions no longer blocks playlist loading with a missing database column error. Existing sources, favorites, and playback history are preserved. ([#1582](https://github.com/4gray/iptvnator/pull/1582), closes [#1580](https://github.com/4gray/iptvnator/issues/1580))
- **embedded-mpv** — When a stream refuses the connection, the embedded MPV player (frame-copy engine) no longer hands the URL to yt-dlp before giving up: the failure shows up right away and its error no longer reads "youtube-dl failed: unexpected error occurred", matching the native-view engines and the external MPV player. ([#1526](https://github.com/4gray/iptvnator/pull/1526))
- **epg** — EPG guides now load when a server adds gzip compression to an already compressed XMLTV file. Sources using a single gzip layer continue to work. ([#1589](https://github.com/4gray/iptvnator/pull/1589), closes [#1586](https://github.com/4gray/iptvnator/issues/1586))
- **epg** — The EPG import panel no longer shows a horizontal scrollbar while sources download. Source names now read as host/file instead of running the two together, channel and programme counts use your locale's digit grouping, and the minimize button and summary are translated. ([#1599](https://github.com/4gray/iptvnator/pull/1599))
- **epg** — Removing and saving an EPG source now clears its cached programmes, including data left by previously removed sources on restart. Other configured sources and playlist guides are preserved, and Live TV stops showing programmes from the removed source. ([#1548](https://github.com/4gray/iptvnator/pull/1548))
- **host-health** — Portal recovery now waits for an active probe to finish before sending another request, even when redirects or a slow response take longer than 45 seconds. Completed and cancelled requests release the probe slot reliably. ([#1547](https://github.com/4gray/iptvnator/pull/1547), closes [#1439](https://github.com/4gray/iptvnator/issues/1439))
- **live-tv** — A hidden channel list no longer looks like a playlist that lost its channels: the player now says the list is hidden and offers a "Show channels list" button, a toggle in the workspace header stays in place in both states, and hiding the list in one place (M3U player, portal Live TV, favorites/recent) no longer hides it everywhere else. ([#1555](https://github.com/4gray/iptvnator/pull/1555), closes [#1458](https://github.com/4gray/iptvnator/issues/1458))
- **m3u** — ClearKey channels with ordinary Base64 keys now play when imported or refreshed, including JSON playlist exports that use + and / characters. Refresh an existing playlist once to apply the fix. ([#1575](https://github.com/4gray/iptvnator/pull/1575))
- **m3u** — DASH channels, including ClearKey-protected streams, now play from Recently Viewed and Favorites using the compatible built-in player. Existing imports keep their DRM keys without needing to re-import the playlist. ([#1597](https://github.com/4gray/iptvnator/pull/1597), closes [#1590](https://github.com/4gray/iptvnator/issues/1590))
- **m3u** — M3U URL imports now accept a custom User-Agent for providers that require it before downloading the playlist. The value is saved and reused for playlist refreshes in the desktop app and self-hosted web app. ([#1535](https://github.com/4gray/iptvnator/pull/1535), closes [#465](https://github.com/4gray/iptvnator/issues/465), closes [#1120](https://github.com/4gray/iptvnator/issues/1120))
- **m3u** — Movies and episodes recognized as video files in M3U playlists now offer playback time and seeking in built-in players, even when TMDB or movie details are disabled. Seeking remains dependent on the source's support. ([#1594](https://github.com/4gray/iptvnator/pull/1594))
- **migration** — Upgrades from older desktop versions retain all sources from the legacy profile. Already-upgraded users can choose to recover missing sources without replacing current sources or settings. Migration keeps the original data and retries safely after a failed write. ([#1550](https://github.com/4gray/iptvnator/pull/1550), closes [#1504](https://github.com/4gray/iptvnator/issues/1504))
- **migration** — Startup now shows a theme-aware preparation screen while loading sources and checking a large programme guide. Temporary source-read failures retry automatically; if loading still fails, you can retry without restarting the app. Recovery also completes any previously failed programme-guide cleanup before opening the library. ([#1568](https://github.com/4gray/iptvnator/pull/1568))
- **playback** — The built-in player controls now hide on their own after you click a button in the bar with the mouse, such as fullscreen or mute. Previously the clicked button kept the bar pinned on screen until you clicked the video, which also paused it. Applies to the shared controls in the web players and the Embedded MPV frame-copy engine. ([#1512](https://github.com/4gray/iptvnator/pull/1512))
- **playback** — Arrow keys and the ±10 s buttons in the Embedded MPV player now move by their full step every time. Pressing an arrow repeatedly, or holding it, used to advance only about a second per press because each step was computed from a stale position; steps are now relative seeks executed by mpv itself, so rapid presses add up. ([#1518](https://github.com/4gray/iptvnator/pull/1518))
- **playback** — The fullscreen channel list is easier to find and calmer to use: moving the mouse shows a small tab on the left edge, a click on that edge opens the list at once, hover still opens it after a short rest, and the list now stays open for a full second after the mouse leaves. Opened with `C`, it no longer closes while the mouse only wanders over the video. ([#1619](https://github.com/4gray/iptvnator/pull/1619))
- **playback** — The fullscreen channel panel stays open under the pointer when its opening animation is delayed, instead of closing before the list appears. ([#1591](https://github.com/4gray/iptvnator/pull/1591))
- **playback** — With the default shared player controls, the built-in player now stays in fullscreen when you switch to another episode, when the next episode starts automatically, and when a live channel or an alternative movie source is switched — previously every switch dropped back to the page. The legacy vendor-controls opt-out keeps its previous behavior. ([#1509](https://github.com/4gray/iptvnator/pull/1509))
- **playback** — Switching channels or leaving playback now closes the old picture-in-picture window in HTML5, Video.js, and ArtPlayer even when shared player controls are disabled, preventing frozen or outdated video from remaining on top. This includes Safari’s legacy picture-in-picture mode. ([#1538](https://github.com/4gray/iptvnator/pull/1538))
- **playback** — Embedded MPV controls and programme-guide panels now follow the visual theme and stay readable in light and dark mode. Video overlays retain their contrasting dark backgrounds. ([#1541](https://github.com/4gray/iptvnator/pull/1541), closes [#1530](https://github.com/4gray/iptvnator/issues/1530))
- **playback** — The built-in HTML5 player now plays `.mkv`, `.webm`, `.avi`, `.mov` and other non-HLS video files directly instead of handing them to the HLS engine, which failed with a "network or provider loading error" on many Xtream episodes and movies. ArtPlayer and the HTML5 player now choose their engine by the same rule. ([#1510](https://github.com/4gray/iptvnator/pull/1510))
- **playback** — Keyboard shortcuts work again right after you click a button in the built-in player controls. Previously the clicked button kept the keyboard: after clicking fullscreen, Space left fullscreen instead of pausing, and the seek, volume and mute keys did nothing until you clicked the video. Applies to the shared controls in the web players and the Embedded MPV frame-copy engine. ([#1516](https://github.com/4gray/iptvnator/pull/1516))
- **playback** — With IPTVnator's shared controls turned off, the Video.js player's keyboard shortcuts now work again right after you click a button in its control bar. Previously the clicked button kept the keyboard: after clicking fullscreen, Space left fullscreen instead of pausing, and the seek, volume and mute keys did nothing until you clicked the video. ([#1523](https://github.com/4gray/iptvnator/pull/1523))
- **playback** — VLC now opens without an extra console window on Windows when playback progress tracking or Reuse VLC instance is enabled. ([#1533](https://github.com/4gray/iptvnator/pull/1533), closes [#871](https://github.com/4gray/iptvnator/issues/871))
- **playlists** — Opening an M3U playlist immediately after refreshing now waits for its pending save, so the channel list does not revert to the previous catalog. ([#1591](https://github.com/4gray/iptvnator/pull/1591))
- **portals** — Xtream and Stalker portals no longer enter a connection cooldown merely because several parallel requests fail in the same millisecond. This fixes false unavailability in both the desktop app and the self-hosted web version. ([#1537](https://github.com/4gray/iptvnator/pull/1537), closes [#1438](https://github.com/4gray/iptvnator/issues/1438))
- **portals** — Xtream and Stalker keep remote channel order while you browse other categories or search. Use Show playing channel to return to the current channel without restarting playback. Stalker radio supports the same behavior. ([#1554](https://github.com/4gray/iptvnator/pull/1554), closes [#1520](https://github.com/4gray/iptvnator/issues/1520))
- **portals** — A slow Xtream or Stalker panel is no longer mistaken for a dead one: a request that reached the panel and then timed out no longer trips the 30-second "portal is not responding" pause, which only fires when the panel never accepts the connection. The desktop app also gives IPv4 fallback the same 2.5-second budget as the self-hosted backend, so dual-stack panels behind VPNs stop failing in bursts. ([#1621](https://github.com/4gray/iptvnator/pull/1621))
- **search** — Search now returns the same results whether you type upper or lower case Turkish characters. Titles with the dotted capital I (for example "İnşaat") are found by typing a plain lower case i, so "inş" and "İnş" match the same channels and movies — in global search, in each portal's search, in the command palette, and in the channel, category, favorites and download list filters. ([#1640](https://github.com/4gray/iptvnator/pull/1640), closes [#609](https://github.com/4gray/iptvnator/issues/609))
- **settings** — The update status in Settings → About now names the channel it describes, and after picking another channel the check button becomes "Save and check": one click saves the choice and reports what that channel offers. Previously "Check again" silently re-checked the still-saved channel, so a fresh nightly could look like "no update". ([#1631](https://github.com/4gray/iptvnator/pull/1631))
- **shell** — Reloading the desktop app while on any page no longer breaks it: View › Reload on macOS used to leave a blank, dead window until restart, and the reload offered by the settings unsaved-changes dialog did nothing. Both now reload the app back onto the page you were on. ([#1622](https://github.com/4gray/iptvnator/pull/1622))
- **shell** — The app now remembers your zoom level (Cmd/Ctrl and +/-): it holds when you switch between sections or resize the window, and comes back after a restart, so a larger font size no longer resets to the default. ([#1617](https://github.com/4gray/iptvnator/pull/1617), closes [#1109](https://github.com/4gray/iptvnator/issues/1109))
- **stalker** — Stalker items whose portal sends an empty `id` next to a valid one are no longer lost: such a channel or movie can be favorited, played, resumed and found again, instead of being stored without an identity and quietly refusing to open. ([#1639](https://github.com/4gray/iptvnator/pull/1639))
- **stalker** — Stalker Live TV search stays within the selected category in the sidebar and fullscreen panel, including channels beyond the first page. All Items searches the whole catalog, and the two search fields work independently. ([#1552](https://github.com/4gray/iptvnator/pull/1552), closes [#1543](https://github.com/4gray/iptvnator/issues/1543))
- **stalker** — In Stalker portals, switching the Live TV or radio category in the sidebar no longer stops the channel that is playing. The category only changes which channels are listed, matching how Xtream portals and M3U playlists already behave. ([#1517](https://github.com/4gray/iptvnator/pull/1517))
- **stalker** — Stalker series with season markers such as “s02” or “2 сезон” now show the correct season in tabs and episode labels while preserving watch progress. TMDB updates no longer reset loaded episodes, and delayed portal responses cannot mix episodes from different series. ([#1545](https://github.com/4gray/iptvnator/pull/1545))
- **stalker** — Seeking in Stalker movies and series now reaches the selected position instead of jumping forward or skipping to the next episode after resuming playback. ([#1544](https://github.com/4gray/iptvnator/pull/1544))
- **tmdb** — Russian titles containing "й" or "ё" and Arabic titles with hamza letters now match on TMDB. The search used to send a folded spelling — dropping the breve from "й", for instance — that TMDB never recognised, and cached the miss for a week; those cached misses are cleared on the next start. ([#1626](https://github.com/4gray/iptvnator/pull/1626))
- **tmdb** — A new series no longer picks up the poster, cast and plot of an older show that happens to share its title. Metadata is looked up in your own language, so an unrelated foreign series can be listed under the very same name — and the better-known one used to win. The release year the provider states now decides first. ([#1648](https://github.com/4gray/iptvnator/pull/1648))
- **ui** — Live TV now supports keyboard movement between categories and channels, and channel lists keep scrolling after mouse selection. Channel scrollbars and resize handles are independently accessible. Movie and series details support immediate keyboard scrolling and no longer hide their scrollbars. ([#1542](https://github.com/4gray/iptvnator/pull/1542), closes [#1506](https://github.com/4gray/iptvnator/issues/1506))
- **ui** — Detail buttons, episode cards and episode rows now keep visible backgrounds and borders in the light theme. The grid/list switch also clearly highlights the selected view in both themes. ([#1549](https://github.com/4gray/iptvnator/pull/1549))
- **ui** — Movie and series detail pages now show one Back arrow instead of two. The floating arrow always returns to the list, while watching too, and the "Close player" button beside the player is the only control that returns to the description. Escape still closes the player first and then goes back. ([#1627](https://github.com/4gray/iptvnator/pull/1627))
- **ui** — Movie and series detail pages keep their Back button visible while scrolling. Escape closes the inline player to the description, then returns to the previous view. Open menus, dialogs and fullscreen retain priority. ([#1576](https://github.com/4gray/iptvnator/pull/1576), closes [#1570](https://github.com/4gray/iptvnator/issues/1570))
- **updater** — "What's new" no longer fails with a raw error for a build published after the app started: the release list is re-read from GitHub when the requested version is missing, so a freshly offered nightly shows its notes. When a version really has no release, the dialog now says so in plain words and links to the channel's releases page. ([#1631](https://github.com/4gray/iptvnator/pull/1631))
- **web-backend** — The self-hosted backend now checks every provider redirect and pins connections to validated addresses, preventing redirects or DNS changes from bypassing private-network restrictions. Trusted LAN access remains available through the existing opt-in. ([#1553](https://github.com/4gray/iptvnator/pull/1553), closes [#1436](https://github.com/4gray/iptvnator/issues/1436))
- **xtream** — Xtream Live TV in Auto can try advertised TS once when HLS initially fails with an HTTP error in a web player. The selected player and stream headers are kept. Settings explain the manual TS workaround for external players, Embedded MPV, and HLS retries that never report a terminal failure. ([#1558](https://github.com/4gray/iptvnator/pull/1558), closes [#1513](https://github.com/4gray/iptvnator/issues/1513))
- **xtream** — Catch-up (timeshift) from the Favorites and Recent tabs now asks the panel for the programme you clicked: the start time is rendered in the panel's own timezone instead of your computer's. The panel's timezone is remembered per source, survives restarts, and panels that report an unusual timezone name are handled through their clock. ([#1563](https://github.com/4gray/iptvnator/pull/1563), closes [#1562](https://github.com/4gray/iptvnator/issues/1562))
- **epg** — The current program shown under each channel in the Xtream Live TV list now moves on by itself once that program ends, and its progress bar keeps advancing. Previously both stayed frozen on the old program until you left the category and came back in. ([#1647](https://github.com/4gray/iptvnator/pull/1647), closes [#767](https://github.com/4gray/iptvnator/issues/767))
- **xtream** — Xtream category management now selects or deselects only matching categories while searching, preserving the visibility of all other categories. Button labels and availability reflect the search results, and the counter clearly shows the total selected. ([#1534](https://github.com/4gray/iptvnator/pull/1534), closes [#812](https://github.com/4gray/iptvnator/issues/812), closes [#816](https://github.com/4gray/iptvnator/issues/816))
- **xtream** — The explicit HTTPS and HTTP connection test now detects Xtream portals that accept HTTP when HTTPS is unavailable and fills in the working address before you save. The saved address is used for catalog updates, provider EPG and playback. Connection failures now show a more specific explanation. ([#1588](https://github.com/4gray/iptvnator/pull/1588))
- **xtream** — Opening a live channel in another Xtream playlist (from global search, the dashboard or the new "open in playlist" chip) no longer lands on an empty Live TV page: the jump used to get lost while the app was still opening that playlist, and now waits for its catalog before selecting and playing the channel. ([#1634](https://github.com/4gray/iptvnator/pull/1634))
- **xtream** — The Xtream sync panel now keeps its status text, labels and Stop sync button clearly readable in both light and dark themes. ([#1546](https://github.com/4gray/iptvnator/pull/1546))
### Performance
- **database** — Refreshing or removing a large Xtream playlist is several times faster: the "Removing cached content" stage and the re-import now commit around 5,000 rows at a time instead of 100, and progress updates arrive at most ten times a second instead of once per batch. ([#1511](https://github.com/4gray/iptvnator/pull/1511), closes [#1292](https://github.com/4gray/iptvnator/issues/1292))
</details>
<details>
<summary>Internal changes</summary>
- **dashboard** — The dashboard's live "now playing" time range and progress bar now read pre-computed EPG timestamps as unix seconds, matching the rest of the EPG code. Today's dashboard lookups never carry those fields, so nothing changes on screen; this closes the gap before a future data path supplies them. ([#1532](https://github.com/4gray/iptvnator/pull/1532))
- **deps** — The application uses Angular 22 and Nx 23 with updated build and test tooling. ([#1603](https://github.com/4gray/iptvnator/pull/1603))
- **deps** — The Embedded MPV native addon build now declares its `node-gyp` dependency explicitly, so `node apps/electron-backend/build-embedded-mpv.js` and the Homebrew development scripts work on a clean checkout instead of failing with "Unable to resolve node-gyp". Packaged builds are unchanged. ([#1625](https://github.com/4gray/iptvnator/pull/1625))
- **deps** — Closes the eight open Dependabot alerts of September 2026: astro 7.2.4 → 7.2.10 (website build), and pinned pnpm overrides moving js-yaml to 4.3.2 (the one runtime path, via electron-updater), smol-toml to 1.7.1, svgo to 4.1.0 and hono to 4.13.5. No application behavior changes. ([#1635](https://github.com/4gray/iptvnator/pull/1635))
- **deps** — Closes the four open Dependabot alerts on transitive npm dependencies via pinned pnpm overrides: browserslist (crash on untrusted stats), @xmldom/xmldom (XML fragment injection — its existing override target had itself fallen into the advisory range), @humanfs/node (recursive copy follows symlinks out of the tree) and postcss-selector-parser (AST recursion DoS). ([#1527](https://github.com/4gray/iptvnator/pull/1527))
</details>
# [0.23.0](https://github.com/4gray/iptvnator/compare/v0.22.0...v0.23.0) (2026-08-30)
### Features
+8 -2002
View File
File diff suppressed because it is too large. Load diff
+29 -2
View File
@@ -53,7 +53,7 @@ The application is a cross-platform, open-source project built with Electron and
**Discovery & metadata**
- Global search across live TV, movies, and series _(desktop)_
- TMDB enrichment (opt-in) — plots, cast & crew, trailers, ratings, artwork, a "Similar" rail, clickable actor pages, and a trending dashboard rail _(trending rail: desktop)_
- TMDB enrichment (opt-in, requires your own TMDB API key) — plots, cast & crew, trailers, ratings, artwork, a "Similar" rail, clickable actor pages, and a trending dashboard rail _(trending rail: desktop)_
- Dashboard with recently watched & continue-watching
**Organization**
@@ -146,6 +146,18 @@ Download the latest version of the application for macOS, Windows, and Linux fro
Alternatively, you can install the application using one of the following package managers:
### Nightly builds
Every merge into `master` is published as a prerelease in
[4gray/iptvnator-nightly](https://github.com/4gray/iptvnator-nightly). The
desktop app can follow them: **Settings → About → Update channel → Nightly**
offers each new build through the built-in updater (macOS, Windows, and the
Linux AppImage). Nightly builds are untested snapshots — they may break, and
their database changes are permanent, so switching back to **Stable** keeps
the installed nightly until the next stable release is newer. Back up your
playlists first, and mention the version and commit shown in *About* when
reporting a bug from a nightly.
### Homebrew
```shell
@@ -272,11 +284,18 @@ needed.
Requirements:
- Node.js 22.13–22.x or 24 and newer with pnpm (via Corepack)
- Node.js 22.22.3 or newer within 22.x, or 24.15.0 or newer within 24.x
- pnpm 10.33.0 (via Corepack)
The repository's `.nvmrc` pins the Node.js version used by CI. With nvm,
run `nvm install` and `nvm use` from the repository root to use the same version
for local development.
1. Clone this repository and install project dependencies:
```
$ nvm install
$ nvm use
$ corepack enable
$ pnpm install
```
@@ -370,3 +389,11 @@ The name **"IPTVnator"** and the IPTVnator logo are unregistered trademarks of t
[![All Contributors](https://img.shields.io/badge/all_contributors-13-orange.svg?style=flat-square)](#contributors)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
## Developer and agent documentation
Start with the [task context map](docs/maintenance/agent-context-map.md) to find
the authoritative contract and validation for your area. Common agent rules are
in [AGENTS.md](AGENTS.md); Claude Code imports that same file. Development and
documentation-maintenance conventions live in the
[agent workflow](docs/development/agent-workflow.md).
@@ -590,8 +590,8 @@ async function firstVisibleGridTitle(page: Page): Promise<string> {
}
async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: browse uses the sticky Back, watch uses the
// now-playing bar's direct Back (the sticky watch action is Close player).
// Return to the list: the shell's sticky Back is route-level in browse
// and watch alike (closing the player is the bar's own Close button).
const backButton = page
.locator('app-portal-detail-shell')
.first()
@@ -18,6 +18,7 @@ import {
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamEpgFixture,
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
@@ -47,6 +48,33 @@ test.describe('Dashboard Activation', () => {
liveFixture.items,
getXtreamTitle
);
// The mock's guide for the channel favourited below (its first live
// stream). The programme on air is read off the full guide at
// assertion time: the mock cuts its slots from the second the guide
// was generated, so the "current" listing of a fixture fetched in
// that same second is the slot that ended just then.
const epgFixture = await fetchXtreamEpgFixture(
request,
xtreamCredentials
);
expect(getXtreamTitle(epgFixture.stream)).toBe(liveTitle);
const liveNowTitle = () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const index = epgFixture.fullEpg.findIndex(
(listing) =>
listing.startTimestamp <= nowSeconds &&
nowSeconds < listing.stopTimestamp
);
expect(index).toBeGreaterThanOrEqual(0);
// Tolerate a slot boundary passing between the app's answer and
// this assertion.
const titles = epgFixture.fullEpg
.slice(index, index + 2)
.map((listing) =>
listing.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
);
return new RegExp(titles.join('|'));
};
const app = await launchElectronApp(dataDir);
try {
@@ -139,6 +167,15 @@ test.describe('Dashboard Activation', () => {
app.mainWindow,
'dashboard-live-favorites-rail'
);
// An Xtream card has no XMLTV key: its "now on air" line comes
// from the portal, asked for lazily once the card is on screen.
await expect(
dashboardRailCardByTitle(
app.mainWindow,
'dashboard-live-favorites-rail',
liveTitle
).locator('.rail__channel-now')
).toContainText(liveNowTitle(), { timeout: 30000 });
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-live-favorites-rail',
@@ -167,6 +204,14 @@ test.describe('Dashboard Activation', () => {
app.mainWindow,
'dashboard-recent-live-rail'
);
// Same channel, same key: the recent card shares the answer.
await expect(
dashboardRailCardByTitle(
app.mainWindow,
'dashboard-recent-live-rail',
liveTitle
).locator('.rail__channel-now')
).toContainText(liveNowTitle(), { timeout: 30000 });
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-recent-live-rail',
@@ -249,8 +294,8 @@ function dashboardRailCardByTitle(
}
async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: browse uses the sticky Back, watch uses the
// now-playing bar's direct Back (the sticky watch action is Close player).
// Return to the list: the shell's sticky Back is route-level in browse
// and watch alike (closing the player is the bar's own Close button).
const backButton = page
.locator('app-portal-detail-shell')
.first()
@@ -1,13 +1,13 @@
import type { ChildProcess } from 'node:child_process';
import { terminateElectronProcess } from './electron-process-termination';
type ElectronChildProcess = Pick<
ChildProcess,
'exitCode' | 'kill' | 'once' | 'removeListener' | 'signalCode'
'exitCode' | 'kill' | 'once' | 'pid' | 'removeListener' | 'signalCode'
>;
export interface ClosableElectronApplication {
close(): Promise<void>;
process(): ElectronChildProcess;
}
export interface ElectronExitConfirmationOptions {
@@ -15,6 +15,19 @@ export interface ElectronExitConfirmationOptions {
readonly exitTimeoutMs: number;
}
const electronProcesses = new WeakMap<
ClosableElectronApplication,
ElectronChildProcess
>();
export function captureElectronProcess<Process extends ElectronChildProcess>(
application: ClosableElectronApplication & { process(): Process }
): Process {
const child = application.process();
electronProcesses.set(application, child);
return child;
}
export interface PrepareElectronApplicationOptions<Application, Prepared> {
readonly application: Application;
readonly dispose: (application: Application) => Promise<void>;
@@ -49,11 +62,19 @@ export async function prepareElectronApplication<Application, Prepared>(
export async function closeElectronApplicationAndConfirmExit(
application: ClosableElectronApplication,
options: ElectronExitConfirmationOptions
options: ElectronExitConfirmationOptions,
terminate: (
child: ElectronChildProcess,
signal: NodeJS.Signals
) => void = terminateElectronProcess
): Promise<void> {
assertTimeout(options.closeTimeoutMs);
assertTimeout(options.exitTimeoutMs);
const child = application.process();
// Playwright discards its process dispatcher when Electron exits. Keep
// using the Node handle captured immediately after launch, including when
// the application's last window already caused a normal process exit.
const child = electronProcesses.get(application);
if (!child) throw new Error('electron-process-handle-not-captured');
if (hasExited(child)) return;
const exit = observeProcessExit(child);
@@ -82,14 +103,19 @@ export async function closeElectronApplicationAndConfirmExit(
}
if (first.kind === 'close-rejected') failure = first.failure;
try {
child.kill();
} catch (killFailure) {
failure = failure
? new AggregateError([failure, killFailure])
: killFailure;
// A failed termination must not skip exit observation or let a
// caller restart against a profile that is still owned by Electron.
for (const signal of ['SIGTERM', 'SIGKILL'] as const) {
try {
terminate(child, signal);
} catch (killFailure) {
failure = failure
? new AggregateError([failure, killFailure])
: killFailure;
}
if (await resolvesWithin(exit.promise, options.exitTimeoutMs))
return;
}
if (await resolvesWithin(exit.promise, options.exitTimeoutMs)) return;
} finally {
exit.cancel();
}
@@ -0,0 +1,25 @@
import { execFileSync, type ChildProcess } from 'node:child_process';
type ElectronProcess = Pick<ChildProcess, 'pid' | 'kill'>;
export function terminateElectronProcess(
child: ElectronProcess,
signal: NodeJS.Signals = 'SIGTERM',
platform: NodeJS.Platform = process.platform,
run: typeof execFileSync = execFileSync
): void {
// Playwright launches Electron through cmd.exe on Windows. Killing only
// that ChildProcess leaves Electron (and its profile lock) alive.
if (platform === 'win32') {
if (!Number.isSafeInteger(child.pid) || (child.pid ?? 0) <= 0) {
throw new Error('electron-process-pid-unavailable');
}
run('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
stdio: 'pipe',
timeout: 5000,
windowsHide: true,
});
return;
}
child.kill(signal);
}
@@ -7,7 +7,7 @@ import {
Page,
test as base,
} from '@playwright/test';
import { spawn } from 'child_process';
import { spawn, type ChildProcess } from 'child_process';
import { createServer, Server } from 'http';
import {
accessSync,
@@ -28,6 +28,7 @@ import {
writeDataDirOwnerMarker,
} from './data-dir-reaper';
import {
captureElectronProcess,
closeElectronApplicationAndConfirmExit,
prepareElectronApplication,
} from './electron-process-lifecycle';
@@ -234,6 +235,7 @@ export async function launchElectronApp(
args,
env: buildElectronLaunchEnvironment(dataDir, options),
});
const electronProcess = captureElectronProcess(electronApp);
return prepareElectronApplication({
application: electronApp,
dispose: (application) =>
@@ -242,7 +244,7 @@ export async function launchElectronApp(
exitTimeoutMs: electronAppKillWaitMs,
}),
prepare: async (application) => {
attachElectronProcessDiagnostics(application);
attachElectronProcessDiagnostics(electronProcess);
const mainWindow = await findMainWindow(application);
await waitForAppReady(mainWindow);
await startPortalDebugCapture(mainWindow);
@@ -458,26 +460,31 @@ export async function launchPackagedElectronApp(
NODE_ENV: 'test',
},
});
attachElectronProcessDiagnostics(electronApp);
const mainWindow = await findMainWindow(electronApp);
await waitForAppReady(mainWindow);
return {
electronApp,
mainWindow,
};
const electronProcess = captureElectronProcess(electronApp);
return prepareElectronApplication({
application: electronApp,
dispose: (application) =>
closeElectronApplicationAndConfirmExit(application, {
closeTimeoutMs: electronAppCloseTimeoutMs,
exitTimeoutMs: electronAppKillWaitMs,
}),
prepare: async (application) => {
attachElectronProcessDiagnostics(electronProcess);
const mainWindow = await findMainWindow(application);
await waitForAppReady(mainWindow);
return {
electronApp: application,
mainWindow,
};
},
});
}
function attachElectronProcessDiagnostics(
electronApp: ElectronApplication
): void {
function attachElectronProcessDiagnostics(childProcess: ChildProcess): void {
if (!process.env['CI']) {
return;
}
const childProcess = electronApp.process();
childProcess.stdout?.on('data', (chunk: Buffer) => {
console.log(`[electron stdout] ${chunk.toString().trimEnd()}`);
});
@@ -564,52 +571,7 @@ export async function launchCompetingElectronInstance(
export async function closeElectronApp(
app: LaunchedElectronApp
): Promise<void> {
try {
const closePromise = app.electronApp.close();
const closed = await waitForPromiseWithTimeout(
closePromise,
electronAppCloseTimeoutMs
);
if (closed) {
return;
}
console.warn(
`Electron app did not close within ${electronAppCloseTimeoutMs}ms; killing process`
);
const childProcess = app.electronApp.process();
if (!childProcess.killed) {
childProcess.kill();
}
await waitForPromiseWithTimeout(
closePromise.catch(() => undefined),
electronAppKillWaitMs
);
// SIGTERM asks Electron for a graceful quit, which the app can
// legitimately refuse — the unsaved-settings close guard cancels the
// quit while it waits for an answer. A process that survives here
// would outlive the test, hold its data dir, and time out the worker
// teardown, so escalate to SIGKILL.
if (
childProcess.exitCode === null &&
childProcess.signalCode === null
) {
console.warn(
'Electron app survived SIGTERM; escalating to SIGKILL'
);
childProcess.kill('SIGKILL');
await waitForPromiseWithTimeout(
closePromise.catch(() => undefined),
electronAppKillWaitMs
);
}
} catch (error) {
console.warn('Failed to close Electron app cleanly:', error);
}
await closeElectronAppAndConfirmExit(app);
}
export async function closeElectronAppAndConfirmExit(
@@ -621,26 +583,6 @@ export async function closeElectronAppAndConfirmExit(
});
}
async function waitForPromiseWithTimeout(
promise: Promise<unknown>,
timeoutMs: number
): Promise<boolean> {
let timeoutId: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise.then(() => true),
new Promise<boolean>((resolvePromise) => {
timeoutId = setTimeout(() => resolvePromise(false), timeoutMs);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function assertPackagedRendererBuildIsElectronSafe(): void {
if (!existsSync(packagedRendererIndexPath)) {
throw new Error(
+258 -32
View File
@@ -1,3 +1,4 @@
import { writeFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { join } from 'node:path';
@@ -233,10 +234,7 @@ test.describe('Electron EPG', () => {
removedUrls: [],
}));
});
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
@@ -296,6 +294,106 @@ test.describe('Electron EPG', () => {
}
});
test('@epg @electron imports a local gzipped XMLTV file given as an absolute path', async ({
dataDir,
}) => {
const localGuide = join(dataDir, 'local guide.xml.gz');
writeFileSync(localGuide, gzipSync(epgFixtureXml));
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await expect(
app.mainWindow.getByTestId('epg-source-formats')
).toContainText('file://');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
const field = app.mainWindow
.locator('.epg-source-row input')
.first();
await field.fill('guide.xml');
// Material reveals errors once the control is touched.
await field.blur();
await expect(
app.mainWindow.locator('.epg-source-row mat-error')
).toBeVisible();
await field.fill(localGuide);
await expect(
app.mainWindow.locator('.epg-source-row mat-error')
).toHaveCount(0);
// Native dialogs cannot be driven from Playwright: stub them in
// the main process. A hand-typed path is confirmed once through
// the message box; a refused path is reported, never read.
await app.electronApp.evaluate(({ dialog }) => {
dialog.showMessageBox = (async () => ({
response: 1,
checkboxChecked: false,
})) as typeof dialog.showMessageBox;
});
await app.mainWindow.getByTestId('epg-source-refresh').click();
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-error'
)
).toContainText('was not allowed');
expect(await getEpgChannelCount(app.mainWindow)).toBe(0);
await app.electronApp.evaluate(({ dialog }) => {
dialog.showMessageBox = (async () => ({
response: 0,
checkboxChecked: false,
})) as typeof dialog.showMessageBox;
});
await app.mainWindow
.locator('.epg-progress-panel .retry-btn')
.click();
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 30000,
})
.toBeGreaterThan(0);
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-complete'
)
).toHaveCount(1);
// A file chosen in the native picker is trusted without a prompt.
const browsedGuide = join(dataDir, 'browsed guide.xml.gz');
writeFileSync(browsedGuide, gzipSync(epgFixtureXml));
await app.electronApp.evaluate(({ dialog }, filePath) => {
dialog.showOpenDialog = (async () => ({
canceled: false,
filePaths: [filePath],
})) as typeof dialog.showOpenDialog;
dialog.showMessageBox = (async () => {
throw new Error('picked files must not prompt');
}) as typeof dialog.showMessageBox;
}, browsedGuide);
await app.mainWindow.getByTestId('epg-source-browse').click();
await expect(field).toHaveValue(browsedGuide);
await app.mainWindow.getByTestId('epg-source-refresh').click();
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-complete'
)
).toHaveCount(2);
// Leave with a pristine form: a dirty settings form arms the
// main-process close guard, and the app would then wait for a
// confirmation dialog instead of closing.
await app.mainWindow.getByTestId('discard-settings').click();
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({
dataDir,
}) => {
@@ -316,10 +414,7 @@ test.describe('Electron EPG', () => {
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow
.locator('.epg-source-row button')
.first()
.click();
await app.mainWindow.getByTestId('epg-source-refresh').click();
await expect(
app.mainWindow.locator('.epg-progress-panel')
).toBeVisible();
@@ -334,12 +429,28 @@ test.describe('Electron EPG', () => {
.first()
).toBeVisible();
// The row must fit the 300px panel: Material icon buttons carry a
// 48px touch target that used to poke past the row padding and
// turn into a horizontal scrollbar.
const importsList = app.mainWindow.locator(
'.epg-progress-panel .imports-list'
);
await expect
.poll(() =>
importsList.evaluate(
(element) => element.scrollWidth - element.clientWidth
)
)
.toBe(0);
await expect(
app.mainWindow.locator('.epg-progress-panel .item-url')
).toHaveText(
`${new URL(epgServer.resourceUrl).hostname}/guide.xml`
);
await saveSettings(app.mainWindow);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(
0
);
@@ -417,10 +528,7 @@ test.describe('Electron EPG', () => {
'.epg-progress-panel .import-item.status-error'
)
).toHaveCount(1);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.locator('.epg-progress-panel .import-item')
@@ -483,8 +591,7 @@ test.describe('Electron EPG', () => {
await app.mainWindow
.locator('.epg-source-row')
.first()
.locator('button')
.nth(1)
.getByTestId('epg-source-remove')
.click();
// A staged removal must not delete data before Save.
expect(await programs()).toContain('Removed Bulletin');
@@ -504,10 +611,7 @@ test.describe('Electron EPG', () => {
await expect.poll(programs).toEqual(['Retained Bulletin']);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
await expect.poll(programs).toEqual([]);
await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0);
@@ -573,8 +677,7 @@ test.describe('Electron EPG', () => {
await app.mainWindow
.locator('.epg-source-row')
.nth(1)
.locator('button')
.nth(1)
.getByTestId('epg-source-remove')
.click();
await saveSettings(app.mainWindow);
const firstMetadata = {
@@ -682,10 +785,7 @@ test.describe('Electron EPG', () => {
.locator('.epg-source-row input')
.fill(epgServer.resourceUrl);
await saveSettings(app.mainWindow);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
// The same URL still belongs to the saved M3U playlist.
const retained = await app.mainWindow.evaluate(async () =>
@@ -701,6 +801,135 @@ test.describe('Electron EPG', () => {
}
});
test("@epg @electron dashboard live rails find a programme that only another playlist's XMLTV carries", async ({
dataDir,
}) => {
test.setTimeout(120000);
// The reported case: the favourited channel's own playlist declares
// no guide, Settings hold a global XMLTV that does not know it, and
// the programme exists only in the guide a DIFFERENT playlist
// imported. The rails searched the global scope alone and showed
// nothing, while the "See all" row — resolved unscoped — had it.
const otherPlaylistEpgServer = await createMutableTextServer(
createCurrentXmltvFixture(
'playlist-guide-news',
'Playlist Guide News',
'Other Playlist Bulletin'
),
{
contentType: 'application/xml; charset=utf-8',
resourcePath: '/guides/other-playlist.xml',
}
);
const globalEpgServer = await createMutableTextServer(
createCurrentXmltvFixture(
'global-other',
'Global Other',
'Global Other Bulletin'
),
{
contentType: 'application/xml; charset=utf-8',
resourcePath: '/guides/global-guide.xml',
}
);
// Declares the guide, so importing it is what puts those programmes
// in the database under that source.
const guideOwnerServer = await createMutableTextServer(
buildM3uContent([
{
name: 'Playlist Guide News',
tvgId: 'playlist-guide-news',
url: 'https://example.com/live/guide-owner.m3u8',
},
]).replace(
'#EXTM3U',
`#EXTM3U x-tvg-url="${otherPlaylistEpgServer.resourceUrl}"`
),
{
contentType: 'application/x-mpegurl; charset=utf-8',
resourcePath: '/guide-owner.m3u',
}
);
// Carries the same XMLTV id under its own display name and declares
// no guide at all — the playlist the dashboard card comes from.
const guidelessServer = await createMutableTextServer(
buildM3uContent([
{
name: 'Mirror News',
tvgId: 'playlist-guide-news',
url: 'https://example.com/live/mirror-news.m3u8',
},
]),
{
contentType: 'application/x-mpegurl; charset=utf-8',
resourcePath: '/guideless.m3u',
}
);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromUrl(
app.mainWindow,
guideOwnerServer.resourceUrl
);
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-complete'
)
).toHaveCount(1, { timeout: 30000 });
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.first()
.fill(globalEpgServer.resourceUrl);
await saveSettings(app.mainWindow);
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 30000,
})
.toBe(2);
await importM3uPlaylistFromUrl(
app.mainWindow,
guidelessServer.resourceUrl
);
await openWorkspaceSection(app.mainWindow, 'All channels');
const channelItem = channelItemByTitle(
app.mainWindow,
'Mirror News'
);
await expect(channelItem).toBeVisible({ timeout: 20000 });
await channelItem.hover();
await channelItem.locator('.favorite-button').first().click();
await expect(
channelItem.locator('.favorite-button mat-icon').first()
).toHaveText(/star/);
await goToDashboard(app.mainWindow);
const card = app.mainWindow
.locator('[data-test-id="dashboard-live-favorites-rail-card"]')
.filter({ hasText: 'Mirror News' })
.first();
await expect(card).toBeVisible({ timeout: 20000 });
await expect(card.locator('.rail__channel-now')).toContainText(
'Other Playlist Bulletin',
{ timeout: 30000 }
);
} finally {
await closeElectronApp(app);
await guidelessServer.close();
await guideOwnerServer.close();
await otherPlaylistEpgServer.close();
await globalEpgServer.close();
}
});
test('@epg @electron uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({
dataDir,
}) => {
@@ -752,10 +981,7 @@ test.describe('Electron EPG', () => {
.locator('.epg-source-row input')
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow
.locator('.epg-source-row button')
.first()
.click();
await app.mainWindow.getByTestId('epg-source-refresh').click();
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
@@ -606,8 +606,8 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
}
async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: browse uses the sticky Back, watch uses the
// now-playing bar's direct Back (the sticky watch action is Close player).
// Return to the list: the shell's sticky Back is route-level in browse
// and watch alike (closing the player is the bar's own Close button).
const backButton = page
.locator('app-portal-detail-shell')
.first()
@@ -18,6 +18,7 @@ import {
openSettingsSection,
} from './electron-test-fixtures';
import { seedLegacyProfile, legacyPlaylists } from './legacy-profile-fixture';
import { captureElectronProcess } from './electron-process-lifecycle';
import { applyTheme } from './theme-contrast';
interface StartupTestGlobals {
@@ -94,6 +95,7 @@ require(${JSON.stringify(electronMainPath)});`
],
env: buildElectronLaunchEnvironment(dataDir),
});
captureElectronProcess(app);
const page = await app.firstWindow();
try {
// The route is intentionally still empty while recovery is pending.
@@ -1,13 +1,28 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { describe, it } from 'node:test';
import {
closeElectronApp,
type LaunchedElectronApp,
} from '../electron-test-fixtures';
import {
closeElectronApplicationAndConfirmExit,
captureElectronProcess,
closeElectronApplicationAndConfirmExit as closeApplication,
ElectronApplicationDisposalError,
prepareElectronApplication,
} from '../electron-process-lifecycle';
// Fake children have no OS PID; exercise lifecycle decisions independently
// of the platform process-tree integration test.
const closeElectronApplicationAndConfirmExit: typeof closeApplication = (
app,
options
) =>
closeApplication(app, options, (child, signal) => {
child.kill(signal);
});
class FakeChildProcess extends EventEmitter {
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
@@ -25,6 +40,94 @@ class FakeChildProcess extends EventEmitter {
}
describe('Electron process lifecycle', () => {
it('rejects cleanup when no process was captured for that application', async () => {
await assert.rejects(
closeApplication(
{ close: async () => assert.fail('exit cannot be confirmed') },
{ closeTimeoutMs: 1, exitTimeoutMs: 1 }
),
/electron-process-handle-not-captured/
);
});
it('cleans the replacement application after a partial restart assignment', async () => {
const oldChild = new FakeChildProcess();
const newChild = new FakeChildProcess();
const oldApplication = {
close: async () => {
oldChild.exitCode = 0;
oldChild.emit('exit', 0, null);
},
process: () => oldChild,
};
const newApplication = {
close: async () => {
newChild.exitCode = 0;
newChild.emit('exit', 0, null);
},
process: () => newChild,
};
const app = {
electronApp: oldApplication,
} as unknown as LaunchedElectronApp;
captureElectronProcess(oldApplication);
captureElectronProcess(newApplication);
await closeElectronApp(app);
// Existing E2E callers replace the application/window fields only.
app.electronApp =
newApplication as unknown as LaunchedElectronApp['electronApp'];
await closeElectronApp(app);
assert.equal(newChild.exitCode, 0);
});
it('confirms an already exited process after Playwright disposes its dispatcher', async () => {
const child = new FakeChildProcess();
const app = {
electronApp: {
close: async () => {
assert.fail(
'an exited application must not be closed again'
);
},
process: () => child,
},
} as unknown as LaunchedElectronApp;
captureElectronProcess(app.electronApp);
child.exitCode = 0;
app.electronApp.process = () => {
throw new TypeError(
"Cannot read properties of undefined (reading '_object')"
);
};
await closeElectronApp(app);
assert.equal(child.killCalls, 0);
});
it('does not return from public cleanup when close and termination both fail', async () => {
const child = new FakeChildProcess();
child.kill = () => {
throw new Error('termination failed');
};
const app = {
electronApp: {
close: async () => {
throw new Error('CDP disconnected');
},
process: () => child,
},
} as unknown as LaunchedElectronApp;
captureElectronProcess(app.electronApp);
app.electronApp.process = () => {
assert.fail('cleanup must use the retained child process');
};
await assert.rejects(
closeElectronApp(app),
/electron-process-exit-unconfirmed/
);
});
it('closes and confirms exit when post-spawn launch preparation fails', async () => {
const child = new FakeChildProcess();
const launchFailure = new Error('renderer readiness failed');
@@ -37,6 +140,7 @@ describe('Electron process lifecycle', () => {
},
process: () => child,
};
captureElectronProcess(application);
await assert.rejects(
prepareElectronApplication({
@@ -56,6 +160,40 @@ describe('Electron process lifecycle', () => {
assert.equal(child.exitCode, 0);
});
it('preserves preparation failure when Electron has already exited', async () => {
const child = new FakeChildProcess();
const launchFailure = new Error('renderer closed before readiness');
const application = {
close: async () => {
assert.fail('an exited application must not be closed again');
},
process: () => child,
};
captureElectronProcess(application);
await assert.rejects(
prepareElectronApplication({
application,
dispose: (app) =>
closeElectronApplicationAndConfirmExit(app, {
closeTimeoutMs: 10,
exitTimeoutMs: 10,
}),
prepare: async () => {
child.exitCode = 0;
application.process = () => {
assert.fail(
'the Playwright dispatcher is already disposed'
);
};
throw launchFailure;
},
}),
(error: unknown) => error === launchFailure
);
assert.equal(child.killCalls, 0);
});
it('fails when process exit remains unconfirmed after forced teardown', async () => {
const child = new FakeChildProcess();
child.shouldExitOnKill = false;
@@ -63,6 +201,7 @@ describe('Electron process lifecycle', () => {
close: () => new Promise<void>(() => undefined),
process: () => child,
};
captureElectronProcess(application);
await assert.rejects(
closeElectronApplicationAndConfirmExit(application, {
@@ -71,7 +210,7 @@ describe('Electron process lifecycle', () => {
}),
/electron-process-exit-unconfirmed/
);
assert.equal(child.killCalls, 1);
assert.equal(child.killCalls, 2);
});
it('preserves both preparation and unconfirmed-disposal failures', async () => {
@@ -107,6 +246,7 @@ describe('Electron process lifecycle', () => {
},
process: () => child,
};
captureElectronProcess(application);
await Promise.race([
closeElectronApplicationAndConfirmExit(application, {
@@ -129,6 +269,7 @@ describe('Electron process lifecycle', () => {
close: () => new Promise<void>(() => undefined),
process: () => child,
};
captureElectronProcess(application);
await closeElectronApplicationAndConfirmExit(application, {
closeTimeoutMs: 1,
@@ -137,4 +278,25 @@ describe('Electron process lifecycle', () => {
assert.equal(child.killCalls, 1);
assert.equal(child.signalCode, 'SIGTERM');
});
it('still observes exit after the first termination attempt throws', async () => {
const child = new FakeChildProcess();
const signals: NodeJS.Signals[] = [];
const application = {
close: () => new Promise<void>(() => undefined),
process: () => child,
};
captureElectronProcess(application);
await closeApplication(
application,
{ closeTimeoutMs: 1, exitTimeoutMs: 1 },
(_child, signal) => {
signals.push(signal);
if (signal === 'SIGTERM') throw new Error('termination failed');
child.kill();
}
);
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
assert.equal(child.signalCode, 'SIGTERM');
});
});
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import { spawn, type execFileSync } from 'node:child_process';
import { once } from 'node:events';
import { describe, it } from 'node:test';
import { terminateElectronProcess } from '../electron-process-termination';
describe('Electron forced termination', () => {
it('terminates the Windows shell and its Electron descendants together', () => {
let shellAlive = true;
let electronAlive = true;
const child = {
pid: 1234,
kill: () => {
shellAlive = false;
return true;
},
};
const run = ((file, args, options) => {
assert.equal(file, 'taskkill.exe');
assert.deepEqual(args, ['/pid', '1234', '/T', '/F']);
assert.equal(options?.timeout, 5000);
shellAlive = false;
electronAlive = false;
return Buffer.alloc(0);
}) as typeof execFileSync;
terminateElectronProcess(child, 'SIGTERM', 'win32', run);
assert.equal(shellAlive, false);
assert.equal(
electronAlive,
false,
'Electron must release the profile before relaunch'
);
});
it('preserves signal escalation on Unix', () => {
const signals: NodeJS.Signals[] = [];
const child = {
pid: 1234,
kill: (signal: NodeJS.Signals) => {
signals.push(signal);
return true;
},
};
terminateElectronProcess(child, 'SIGTERM', 'linux');
terminateElectronProcess(child, 'SIGKILL', 'darwin');
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
});
it('does not fall back to killing only the shell when tree termination fails', () => {
const failure = new Error('taskkill failed');
const child = {
pid: 1234,
kill: () => assert.fail('would orphan the Electron descendant'),
};
const run = (() => {
throw failure;
}) as typeof execFileSync;
assert.throws(
() => terminateElectronProcess(child, 'SIGTERM', 'win32', run),
(error) => error === failure
);
});
it(
'reaps a real Windows shell child instead of orphaning its descendant',
{
skip: process.platform !== 'win32',
timeout: 15000,
},
async () => {
// Match Playwright's Windows launch topology: cmd.exe -> application.
const shell = spawn(
`"${process.execPath}"`,
[
'-e',
'"setInterval(() => {}, 1000); console.log(process.pid)"',
],
{ shell: true, stdio: ['ignore', 'pipe', 'pipe'] }
);
try {
const [output] = await once(shell.stdout, 'data', {
signal: AbortSignal.timeout(5000),
});
const descendantPid = Number(String(output).trim());
assert.ok(
Number.isSafeInteger(descendantPid) && descendantPid > 0
);
assert.notEqual(descendantPid, shell.pid);
process.kill(descendantPid, 0);
terminateElectronProcess(shell);
await assert.rejects(
async () => process.kill(descendantPid, 0),
{
code: 'ESRCH',
}
);
} finally {
if (shell.exitCode === null && shell.signalCode === null) {
// The successful taskkill can precede Node's exit event.
try {
terminateElectronProcess(shell);
} catch {
/* already exited */
}
}
}
}
);
});
+2 -2
View File
@@ -653,8 +653,8 @@ async function expectUnifiedLiveDetailOpen(
}
async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: browse uses the sticky Back, watch uses the
// now-playing bar's direct Back (the sticky watch action is Close player).
// Return to the list: the shell's sticky Back is route-level in browse
// and watch alike (closing the player is the bar's own Close button).
const backButton = page
.locator('app-portal-detail-shell')
.first()
@@ -0,0 +1,82 @@
import {
closeElectronApp,
expect,
expectPathname,
launchElectronApp,
openSettings,
openSettingsSection,
openSources,
test,
} from './electron-test-fixtures';
import {
expectRendererReloadedOnRoute,
reloadFromMainProcess,
reloadFromRenderer,
} from './renderer-reload.support';
/**
* The packaged renderer is index.html over file:// with path routing, so
* once the user is on a section the document URL names a path with no file
* behind it. A main-process reload of that URL used to fail with
* ERR_FILE_NOT_FOUND and leave the window on Chromium's error page until the
* app restarted, and a renderer-initiated reload was cancelled by the
* navigation guard and silently did nothing. Both now boot the app straight
* back into the route it was on.
*/
test.describe('Renderer reload on an in-app route', () => {
test('@electron @window a main-process reload keeps the app on the Sources page', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await openSources(app.mainWindow);
await reloadFromMainProcess(app);
await expectRendererReloadedOnRoute(
app.mainWindow,
/\/workspace\/sources$/
);
// A fresh data dir has no sources: the page shows its empty state.
await expect(
app.mainWindow.getByRole('heading', {
name: 'Add your first playlist',
})
).toBeVisible();
// The page is alive, not a leftover paint: navigation still works.
await app.mainWindow
.getByRole('link', { name: 'Dashboard', exact: true })
.click();
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
} finally {
await closeElectronApp(app);
}
});
test('@electron @window a renderer-initiated reload keeps the app on its settings section', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await reloadFromRenderer(app);
await expectRendererReloadedOnRoute(
app.mainWindow,
/\/workspace\/settings\/playback$/
);
await expect(
app.mainWindow.getByTestId('settings-container')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('settings-section-playback')
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
});
@@ -0,0 +1,69 @@
import { expect, Page } from '@playwright/test';
import { LaunchedElectronApp } from './electron-test-fixtures';
/**
* Helpers for E2E that reload the packaged renderer on an in-app route.
*
* The URL before and after a recovered reload is the same routed `file://`
* URL, so waiting on the URL alone passes before anything happened. The
* current document is marked instead, and the wait is for a document
* WITHOUT the mark that has reached the route.
*/
const DOCUMENT_MARK = 'data-e2e-pre-reload';
async function markCurrentDocument(page: Page): Promise<void> {
await page.evaluate((attribute) => {
document.documentElement.setAttribute(attribute, '');
}, DOCUMENT_MARK);
}
/** What the macOS View › Reload menu role does: `webContents.reload()`. */
export async function reloadFromMainProcess(
app: LaunchedElectronApp
): Promise<void> {
await markCurrentDocument(app.mainWindow);
await app.electronApp.evaluate(({ BrowserWindow }) => {
const [win] = BrowserWindow.getAllWindows();
win.webContents.reload();
});
}
/**
* What the settings unsaved-changes guard does after a confirmed reload:
* `window.location.reload()`. The evaluate may lose its execution context
* to the navigation it starts; that is not a failure.
*/
export async function reloadFromRenderer(
app: LaunchedElectronApp
): Promise<void> {
await markCurrentDocument(app.mainWindow);
await app.mainWindow
.evaluate(() => {
window.location.reload();
})
.catch(() => undefined);
}
/**
* Waits until a NEW document is rendered on `pathname` — the app re-booted
* on the route rather than the old document still being on screen — and
* checks that the restore parameter was consumed on the way.
*/
export async function expectRendererReloadedOnRoute(
page: Page,
pathname: RegExp
): Promise<void> {
await expect(page.locator(`html[${DOCUMENT_MARK}]`)).toHaveCount(0);
await expect(page).toHaveURL(pathname);
await expect
.poll(() =>
page.evaluate(
() =>
document.querySelector('app-root')?.innerHTML.trim()
.length ?? 0
)
)
.toBeGreaterThan(0);
expect(new URL(page.url()).searchParams.has('restoreRoute')).toBe(false);
}
+2 -2
View File
@@ -1540,8 +1540,8 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
}
async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: browse uses the sticky Back, watch uses the
// now-playing bar's direct Back (the sticky watch action is Close player).
// Return to the list: the shell's sticky Back is route-level in browse
// and watch alike (closing the player is the bar's own Close button).
const backButton = page
.locator('app-portal-detail-shell')
.first()
@@ -0,0 +1,146 @@
import {
closeElectronApp,
expect,
launchElectronApp,
LaunchedElectronApp,
openSources,
restartElectronApp,
test,
} from './electron-test-fixtures';
import {
expectRendererReloadedOnRoute,
reloadFromMainProcess,
} from './renderer-reload.support';
/**
* Chromium's zoom factor as the renderer actually renders it: the window's
* content width in device-independent pixels over the CSS pixels the page
* sees. Independent of `getZoomLevel()`, which under file:// reports the
* per-URL entry and can disagree with what is on screen.
*/
async function renderedZoomFactor(app: LaunchedElectronApp): Promise<number> {
const contentWidth = await app.electronApp.evaluate(({ BrowserWindow }) => {
const [win] = BrowserWindow.getAllWindows();
return win.getContentSize()[0];
});
const innerWidth = await app.mainWindow.evaluate(() => window.innerWidth);
return contentWidth / innerWidth;
}
/**
* The app zoom shortcuts as the renderer receives them: Cmd on macOS, Ctrl
* elsewhere. Dispatched through CDP they reach the renderer's keydown
* binding directly (`WorkspaceKeyboardShortcutsService`), which steps the
* frame-bound level through the preload bridge; the macOS application menu
* is not in this path.
*/
const ZOOM_MODIFIER = process.platform === 'darwin' ? 'Meta' : 'Control';
async function pressZoomShortcut(
app: LaunchedElectronApp,
key: 'Equal' | 'Minus' | 'Digit0' | 'NumpadAdd' | 'NumpadSubtract',
times = 1
): Promise<void> {
for (let index = 0; index < times; index += 1) {
await app.mainWindow.keyboard.press(`${ZOOM_MODIFIER}+${key}`);
}
}
/**
* A cross-document navigation of the renderer (what a reload is for zoom:
* Chromium drops the temporary level and the new document's preload must
* restore it). A real reload of the routed `file://` URL: the main process
* recovers the missing file by re-loading the index on the same route
* (`renderer-reload.e2e.ts`).
*/
async function reloadRenderer(app: LaunchedElectronApp): Promise<void> {
await reloadFromMainProcess(app);
await expectRendererReloadedOnRoute(
app.mainWindow,
/\/workspace\/sources$/
);
}
async function resizeWindowBy(
app: LaunchedElectronApp,
delta: number
): Promise<void> {
await app.electronApp.evaluate(({ BrowserWindow }, step) => {
const [win] = BrowserWindow.getAllWindows();
const [width, height] = win.getSize();
win.setSize(width + step, height + step);
}, delta);
}
// Zoom level 1 is a 1.2 factor; each shortcut press steps the level by 0.5
// (Electron's zoomIn/zoomOut role step). The packaged renderer runs under
// file:// with path routing, where Chromium keys zoom by full URL: without
// frame-bound (temporary) zoom the level "holds" only until the next resize
// after a section change, and a restart brings back the default (issue #1109).
const ZOOMED_FACTOR = 1.2;
const HALF_STEP_FACTOR = Math.sqrt(ZOOMED_FACTOR);
test('@electron @window keeps the zoom level across sections, resizes and a restart', async ({
dataDir,
}) => {
let app = await launchElectronApp(dataDir);
try {
expect(await renderedZoomFactor(app)).toBeCloseTo(1, 1);
// In three times, out once (main keys and numpad): level 1.
await pressZoomShortcut(app, 'Equal', 2);
await pressZoomShortcut(app, 'NumpadAdd');
await expect
.poll(() => renderedZoomFactor(app))
.toBeCloseTo(ZOOMED_FACTOR * HALF_STEP_FACTOR, 1);
await pressZoomShortcut(app, 'Minus');
await expect
.poll(() => renderedZoomFactor(app))
.toBeCloseTo(ZOOMED_FACTOR, 1);
// Reset returns to level 0, then zoom back in (three in, one out on
// the numpad) for the persistence checks below.
await pressZoomShortcut(app, 'Digit0');
await expect.poll(() => renderedZoomFactor(app)).toBeCloseTo(1, 1);
await pressZoomShortcut(app, 'Equal', 3);
await pressZoomShortcut(app, 'NumpadSubtract');
await expect
.poll(() => renderedZoomFactor(app))
.toBeCloseTo(ZOOMED_FACTOR, 1);
// A route change (new file:// URL) followed by the visual-properties
// sync a resize forces: per-URL zoom snaps back to 1 here.
await openSources(app.mainWindow);
await resizeWindowBy(app, 24);
await app.mainWindow.waitForTimeout(500);
expect(await renderedZoomFactor(app)).toBeCloseTo(ZOOMED_FACTOR, 1);
// A reload rebuilds the document; the level must be saved before it
// and re-applied by the new document's preload.
await reloadRenderer(app);
await expect
.poll(() => renderedZoomFactor(app))
.toBeCloseTo(ZOOMED_FACTOR, 1);
} finally {
app = await restartElectronApp(app, dataDir);
}
try {
await expect
.poll(() => renderedZoomFactor(app))
.toBeCloseTo(ZOOMED_FACTOR, 1);
expect(
await app.electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0].webContents.getZoomLevel()
)
).toBe(1);
await openSources(app.mainWindow);
await resizeWindowBy(app, -24);
await app.mainWindow.waitForTimeout(500);
expect(await renderedZoomFactor(app)).toBeCloseTo(ZOOMED_FACTOR, 1);
} finally {
await closeElectronApp(app);
}
});
@@ -96,7 +96,7 @@ test('@epg @xtream @electron removes uploaded guide data and restores provider E
.toContain('Temporary XMLTV Bulletin');
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
@@ -190,7 +190,7 @@ test('@epg @stalker @electron invalidates a loaded manual XMLTV mapping after so
.toContain('Retired Stalker Bulletin');
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
+2 -1
View File
@@ -9,7 +9,8 @@
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"ignoreDeprecations": "6.0"
},
"include": [
"**/*.ts",
+9 -5
View File
@@ -677,11 +677,15 @@ function copyGenericRuntimeToNativeBuild(runtime) {
return manifest;
}
// Upstream node-gyp, resolved as a declared devDependency. This used to scan
// node_modules/.pnpm for the `@electron/node-gyp` fork, which was only ever in
// the tree as a transitive of `@electron/rebuild` 3 — rebuild 4 moved to
// upstream `node-gyp` and the scan started throwing. The Electron target is
// selected through the npm_config_* env below, not by the binary.
// Upstream node-gyp, declared as a root devDependency so this resolves from a
// plain `node apps/electron-backend/build-embedded-mpv.js`. Until it was
// declared it was only in the tree as a transitive of `@electron/rebuild`,
// reachable through pnpm's hidden hoist (node_modules/.pnpm/node_modules) —
// which pnpm's `.bin` shims put on NODE_PATH, so `pnpm nx …` and CI worked
// while a direct invocation on a clean install threw. Before that, this
// scanned node_modules/.pnpm for the `@electron/node-gyp` fork, which
// rebuild 4 dropped for upstream `node-gyp`. The Electron target is selected
// through the npm_config_* env below, not by the binary.
function resolveNodeGypBin() {
try {
return require.resolve('node-gyp/bin/node-gyp.js');
@@ -562,6 +562,12 @@ export const epgPreloadCases: PreloadInvokeCase[] = [
},
],
},
{
method: 'openEpgFileDialog',
args: [],
channel: 'EPG_OPEN_FILE_DIALOG',
forwardedArgs: [],
},
{
method: 'clearEpgData',
args: [],
@@ -31,11 +31,14 @@ type MockIpcRenderer = {
on: jest.Mock;
off: jest.Mock;
send: jest.Mock;
sendSync: jest.Mock;
};
let mockExposedApi: ExposedElectronApi | null;
let mockIpcRenderer: MockIpcRenderer;
let mockGetPathForFile: jest.Mock;
let mockWebFrame: { getZoomLevel: jest.Mock; setZoomLevel: jest.Mock };
let mockDocumentListeners: Map<string, () => void>;
function getExposedApi(): ExposedElectronApi {
if (!mockExposedApi) {
@@ -64,6 +67,19 @@ describe('main preload DB IPC contract', () => {
on: jest.fn(),
off: jest.fn(),
send: jest.fn(),
sendSync: jest.fn().mockReturnValue(1.5),
};
mockWebFrame = {
getZoomLevel: jest.fn().mockReturnValue(0),
setZoomLevel: jest.fn(),
};
// The preload defers webFrame.setZoomLevel to DOMContentLoaded; the
// jest environment is node, so stand in for the parsing document.
mockDocumentListeners = new Map();
(globalThis as { document?: unknown }).document = {
readyState: 'loading',
addEventListener: (event: string, listener: () => void) =>
mockDocumentListeners.set(event, listener),
};
jest.doMock('electron', () => ({
@@ -75,6 +91,7 @@ describe('main preload DB IPC contract', () => {
),
},
ipcRenderer: mockIpcRenderer,
webFrame: mockWebFrame,
webUtils: {
getPathForFile: mockGetPathForFile,
},
@@ -85,6 +102,44 @@ describe('main preload DB IPC contract', () => {
afterEach(() => {
jest.dontMock('electron');
delete (globalThis as { document?: unknown }).document;
});
it('requests the persisted zoom level synchronously and applies it at DOMContentLoaded', () => {
// Through webFrame (temporary zoom) so the app's pushState routing
// under file:// cannot reset it, and only once the document is parsed:
// an earlier webFrame.setZoomLevel leaves a hidden Linux/Windows
// window without ready-to-show (issue #1109).
expect(mockIpcRenderer.sendSync).toHaveBeenCalledWith(
'WINDOW:GET_ZOOM_LEVEL'
);
expect(mockWebFrame.setZoomLevel).not.toHaveBeenCalled();
expect(mockExposedApi).not.toBeNull();
mockDocumentListeners.get('DOMContentLoaded')?.();
expect(mockWebFrame.setZoomLevel).toHaveBeenCalledWith(1.5);
expect(mockIpcRenderer.send).toHaveBeenCalledWith(
'WINDOW:ZOOM_LEVEL_APPLIED'
);
});
it('steps the zoom shortcuts through webFrame without any IPC', () => {
// Same frame-bound temporary zoom as the restore: a main-process
// setZoomLevel would write the per-URL entry the app's file://
// routing resets (issue #1109).
mockWebFrame.getZoomLevel.mockReturnValue(1);
mockIpcRenderer.invoke.mockClear();
mockIpcRenderer.send.mockClear();
expect(mockExposedApi?.adjustZoomLevel('in')).toBe(1.5);
expect(mockWebFrame.setZoomLevel).toHaveBeenCalledWith(1.5);
expect(mockExposedApi?.adjustZoomLevel('reset')).toBe(0);
expect(mockWebFrame.setZoomLevel).toHaveBeenCalledWith(0);
expect(mockIpcRenderer.invoke).not.toHaveBeenCalled();
expect(mockIpcRenderer.send).not.toHaveBeenCalled();
});
it('covers every worker-backed DB operation exposed by the preload bridge', () => {
@@ -1,5 +1,5 @@
import type { SourceProbeContext } from '@iptvnator/shared/interfaces';
import { contextBridge, ipcRenderer, webUtils } from 'electron';
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron';
import {
APP_UPDATE_CHECK,
APP_UPDATE_DOWNLOAD,
@@ -10,6 +10,8 @@ import {
ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST,
ANNOUNCE_PLAYLIST_OPEN_LISTENER,
OPEN_FILE,
WINDOW_GET_ZOOM_LEVEL,
WINDOW_ZOOM_LEVEL_APPLIED,
} from '@iptvnator/shared/interfaces/ipc-commands';
import {
attachEmbeddedMpvFrameView,
@@ -19,6 +21,10 @@ import {
createPreloadPerformanceCapture,
toPreloadPerformanceTargetMethod,
} from './preload-performance-capture';
import {
adjustFrameZoomLevel,
applyPersistedZoomLevel,
} from './preload-zoom-level';
import {
createXtreamPreloadPerformanceCapture,
isXtreamPreloadPerformanceCaptureEnabled,
@@ -275,6 +281,11 @@ function wrapElectronApi<T extends object>(api: T): T {
) as T;
}
const frameZoomPorts = {
getZoomLevel: () => webFrame.getZoomLevel(),
setZoomLevel: (level: number) => webFrame.setZoomLevel(level),
};
const electronApi: ElectronBridgeApi = {
// Remote control channel change listener
onChannelChange: (
@@ -422,6 +433,9 @@ const electronApi: ElectronBridgeApi = {
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
toggleFullScreenWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_FULLSCREEN),
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
// Zoom shortcuts: same frame-bound webFrame route as the restore below;
// synchronous, no IPC — persistence reads the level back in main.
adjustZoomLevel: (action) => adjustFrameZoomLevel(frameZoomPorts, action),
getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE),
onWindowStateChange: (
callback: (state: ElectronBridgeWindowState) => void
@@ -683,6 +697,7 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('EPG_GET_PROGRAM_COVERAGE', window),
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
openEpgFileDialog: () => ipcRenderer.invoke('EPG_OPEN_FILE_DIALOG'),
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
reconcileEpgSources: (urls: string[]) =>
ipcRenderer.invoke('EPG_RECONCILE_SOURCES', { urls }),
@@ -1166,4 +1181,22 @@ const electronApi: ElectronBridgeApi = {
},
};
// Restore the app zoom level (issue #1109). Must be webFrame (temporary,
// frame-bound zoom), not a main-process setZoomLevel: under file:// Chromium
// keys zoom by full URL, and the app's pushState routing would reset it on
// the next resize. Applied at DOMContentLoaded, never earlier — see
// preload-zoom-level.ts for the Linux/Windows ready-to-show trap.
applyPersistedZoomLevel({
requestPersistedZoomLevel: () => ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL),
...frameZoomPorts,
whenDocumentParsed: (apply) => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', apply, { once: true });
} else {
apply();
}
},
notifyApplied: () => ipcRenderer.send(WINDOW_ZOOM_LEVEL_APPLIED),
});
contextBridge.exposeInMainWorld('electron', wrapElectronApi(electronApi));
@@ -0,0 +1,118 @@
import {
adjustFrameZoomLevel,
applyPersistedZoomLevel,
} from './preload-zoom-level';
function createPorts(saved: unknown, current = 0) {
const parsed: Array<() => void> = [];
return {
requestPersistedZoomLevel: jest.fn(() => saved),
getZoomLevel: jest.fn(() => current),
setZoomLevel: jest.fn(),
whenDocumentParsed: jest.fn((apply: () => void) => {
parsed.push(apply);
}),
notifyApplied: jest.fn(),
/** Fire DOMContentLoaded. */
parse(): void {
for (const apply of parsed.splice(0)) apply();
},
};
}
describe('applyPersistedZoomLevel', () => {
it('requests the level at once but applies it only once the document is parsed', () => {
const ports = createPorts(-1.5);
expect(applyPersistedZoomLevel(ports)).toBe(-1.5);
expect(ports.requestPersistedZoomLevel).toHaveBeenCalledTimes(1);
// Before DOMContentLoaded webFrame.setZoomLevel would leave a hidden
// Linux/Windows window without ready-to-show.
expect(ports.setZoomLevel).not.toHaveBeenCalled();
expect(ports.notifyApplied).not.toHaveBeenCalled();
ports.parse();
expect(ports.setZoomLevel).toHaveBeenCalledWith(-1.5);
expect(ports.notifyApplied).toHaveBeenCalledTimes(1);
expect(ports.getZoomLevel).not.toHaveBeenCalled();
});
it.each([null, undefined, Number.NaN, '2'])(
're-applies the current level as a temporary zoom when %p is stored',
(saved) => {
const ports = createPorts(saved, 0.5);
expect(applyPersistedZoomLevel(ports)).toBe(0.5);
ports.parse();
expect(ports.setZoomLevel).toHaveBeenCalledWith(0.5);
expect(ports.notifyApplied).toHaveBeenCalledTimes(1);
}
);
it('never throws out of the preload when the request fails', () => {
const ports = createPorts(1);
ports.requestPersistedZoomLevel.mockImplementation(() => {
throw new Error('no main process');
});
expect(applyPersistedZoomLevel(ports)).toBeNull();
expect(ports.whenDocumentParsed).not.toHaveBeenCalled();
});
it('does not acknowledge a level that webFrame refused', () => {
const ports = createPorts(1);
ports.setZoomLevel.mockImplementation(() => {
throw new Error('frame gone');
});
applyPersistedZoomLevel(ports);
expect(() => ports.parse()).not.toThrow();
expect(ports.notifyApplied).not.toHaveBeenCalled();
});
});
describe('adjustFrameZoomLevel', () => {
function createFrame(current: number) {
return {
getZoomLevel: jest.fn(() => current),
setZoomLevel: jest.fn(),
};
}
it('steps the frame level through webFrame and reports the applied level', () => {
const frame = createFrame(0);
expect(adjustFrameZoomLevel(frame, 'in')).toBe(0.5);
expect(frame.setZoomLevel).toHaveBeenCalledWith(0.5);
const zoomed = createFrame(0.5);
expect(adjustFrameZoomLevel(zoomed, 'out')).toBe(0);
expect(zoomed.setZoomLevel).toHaveBeenCalledWith(0);
});
it('resets to level 0', () => {
const frame = createFrame(2.5);
expect(adjustFrameZoomLevel(frame, 'reset')).toBe(0);
expect(frame.setZoomLevel).toHaveBeenCalledWith(0);
});
it('does not write when the level is already at the limit', () => {
const frame = createFrame(6);
expect(adjustFrameZoomLevel(frame, 'in')).toBe(6);
expect(frame.setZoomLevel).not.toHaveBeenCalled();
});
it('reports the unchanged level when webFrame refuses the write', () => {
const frame = createFrame(1);
frame.setZoomLevel.mockImplementation(() => {
throw new Error('frame gone');
});
expect(adjustFrameZoomLevel(frame, 'in')).toBe(1);
});
});
@@ -0,0 +1,95 @@
/**
* Preload half of the app zoom level persistence (issue #1109).
*
* The level is requested synchronously at preload start and applied through
* `webFrame.setZoomLevel`, which installs a temporary, frame-bound zoom level
* instead of Chromium's per-URL entry — the only form that survives the
* app's `pushState` routing under `file://` (see
* `services/window-zoom-level.ts` for the mechanism). When nothing is stored
* it re-applies the current level for the same reason: entering temporary
* mode makes the very first zoom shortcut URL-independent too, and keeps
* whatever per-URL level Chromium restored on its own for this document.
*
* The apply itself is deferred to `DOMContentLoaded`. Calling
* `webFrame.setZoomLevel` earlier — at preload start, or from a `setTimeout`
* — leaves a hidden window without a first frame on Linux and Windows:
* `ready-to-show` never fires, `show()` never runs, and the renderer gets no
* animation frames (the splash that `main.ts` removes in a
* `requestAnimationFrame` stays forever). macOS is unaffected, which is why
* only the packaged Linux/Windows E2E caught it. After the parser finishes
* the call is harmless, and it still lands before the first Angular paint.
*/
import {
stepZoomLevel,
type ZoomLevelAction,
} from '@iptvnator/shared/interfaces/zoom-level';
export interface PreloadZoomLevelPorts {
/** `ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL)` — the stored level or null. */
requestPersistedZoomLevel(): unknown;
/** `webFrame.getZoomLevel()` */
getZoomLevel(): number;
/** `webFrame.setZoomLevel(level)` */
setZoomLevel(level: number): void;
/** Runs `apply` once the document is parsed (DOMContentLoaded), or at once if it already is. */
whenDocumentParsed(apply: () => void): void;
/** `ipcRenderer.send(WINDOW_ZOOM_LEVEL_APPLIED)` — hands the main process ownership of the level. */
notifyApplied(): void;
}
/**
* Returns the level that will be applied, or `null` when the request failed.
* A failure here must never break the bridge: the preload continues and the
* window merely keeps Chromium's default zoom behaviour for this load.
*/
export function applyPersistedZoomLevel(
ports: PreloadZoomLevelPorts
): number | null {
try {
const saved = ports.requestPersistedZoomLevel();
const level =
typeof saved === 'number' && Number.isFinite(saved)
? saved
: ports.getZoomLevel();
ports.whenDocumentParsed(() => {
try {
ports.setZoomLevel(level);
ports.notifyApplied();
} catch {
// Same contract as below: never take the bridge down.
}
});
return level;
} catch {
return null;
}
}
/**
* Bridge half of the zoom shortcuts (`window.electron.adjustZoomLevel`):
* steps the frame's temporary level in place. Same `webFrame` route as the
* restore above, so the result is URL-independent and `getZoomLevel()` in the
* main process reports it for the close/quit/reload persistence. Returns the
* level now applied; a refused write leaves the frame as it was and returns
* its current level, so the caller never learns a level that is not on
* screen.
*/
export function adjustFrameZoomLevel(
ports: Pick<PreloadZoomLevelPorts, 'getZoomLevel' | 'setZoomLevel'>,
action: ZoomLevelAction
): number {
const current = ports.getZoomLevel();
const next = stepZoomLevel(current, action);
if (next !== current) {
try {
ports.setZoomLevel(next);
} catch {
return current;
}
}
return next;
}
@@ -42,6 +42,7 @@ jest.mock('./services/store.service', () => ({
set: jest.fn(),
},
WINDOW_BOUNDS: 'windowBounds',
ZOOM_LEVEL: 'zoomLevel',
}));
import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces';
+225 -1
View File
@@ -37,6 +37,7 @@ jest.mock('./services/store.service', () => ({
},
STARTUP_WINDOW_MODE: 'startupWindowMode',
WINDOW_BOUNDS: 'windowBounds',
ZOOM_LEVEL: 'zoomLevel',
}));
jest.mock('./services/embedded-mpv-frame-copy-platform.util', () => ({
@@ -54,10 +55,11 @@ import {
isTrustedRendererNavigationUrl,
} from './app';
import App from './app';
import { app as electronApp, BrowserWindow, screen } from 'electron';
import { app as electronApp, BrowserWindow, screen, shell } from 'electron';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { store } from './services/store.service';
import { markZoomLevelApplied } from './services/window-zoom-level';
import { getPendingFullScreenTarget } from './services/native-fullscreen-transitions';
type MockMainWindow = {
@@ -80,6 +82,8 @@ type MockMainWindow = {
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
openDevTools: jest.Mock<void, []>;
setWindowOpenHandler: jest.Mock<void, [unknown]>;
getZoomLevel: jest.Mock<number, []>;
isDestroyed: jest.Mock<boolean, []>;
};
};
@@ -102,6 +106,8 @@ function createMockMainWindow(): MockMainWindow {
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
openDevTools: jest.fn<void, []>(),
setWindowOpenHandler: jest.fn<void, [unknown]>(),
getZoomLevel: jest.fn<number, []>().mockReturnValue(0),
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
},
};
}
@@ -487,6 +493,224 @@ describe('Electron app security helpers', () => {
});
});
describe('zoom level persistence', () => {
const bounds = { x: 1, y: 2, width: 3, height: 4 };
function createWindowViaOnReady(): MockMainWindow {
const mainWindow = createMockMainWindow();
mainWindow.getNormalBounds.mockReturnValue(bounds);
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
getAppInternals().onReady();
return mainWindow;
}
// Every listener of the event fires, since the window registers
// more than one `did-start-navigation` listener (zoom persistence
// and the renderer reload recovery).
function fireHandlers(
calls: Array<[string, (...args: unknown[]) => void]>,
eventName: string,
...args: unknown[]
): void {
const handlers = calls
.filter(([name]) => name === eventName)
.map(([, handler]) => handler);
expect(handlers.length).toBeGreaterThanOrEqual(1);
for (const handler of handlers) {
handler(...args);
}
}
function fireWindowEvent(win: MockMainWindow, eventName: string): void {
fireHandlers(win.on.mock.calls, eventName);
}
/** The preload asked for the level: its getZoomLevel() is now the app's. */
function preloadAppliedZoom(win: MockMainWindow, level: number): void {
win.webContents.getZoomLevel.mockReturnValue(level);
markZoomLevelApplied(
win.webContents as unknown as Electron.WebContents
);
}
it('persists the zoom level next to the bounds when a window whose preload owns the level closes', () => {
const mainWindow = createWindowViaOnReady();
preloadAppliedZoom(mainWindow, 1.5);
fireWindowEvent(mainWindow, 'close');
expect(store.set).toHaveBeenCalledWith('zoomLevel', 1.5);
expect(store.set).toHaveBeenCalledWith('windowBounds', bounds);
});
it('saves only the bounds when the window closes before its preload applied the level', () => {
const mainWindow = createWindowViaOnReady();
// Before the preload handshake getZoomLevel() is Chromium's
// per-URL default; saving it would clobber the persisted level.
mainWindow.webContents.getZoomLevel.mockReturnValue(0);
fireWindowEvent(mainWindow, 'close');
expect(store.set).not.toHaveBeenCalledWith(
'zoomLevel',
expect.anything()
);
expect(store.set).toHaveBeenCalledWith('windowBounds', bounds);
});
it('does not read zoom from a destroyed webContents on close', () => {
const mainWindow = createWindowViaOnReady();
preloadAppliedZoom(mainWindow, 1.5);
mainWindow.webContents.isDestroyed.mockReturnValue(true);
fireWindowEvent(mainWindow, 'close');
expect(mainWindow.webContents.getZoomLevel).not.toHaveBeenCalled();
expect(store.set).not.toHaveBeenCalledWith(
'zoomLevel',
expect.anything()
);
expect(store.set).toHaveBeenCalledWith('windowBounds', bounds);
});
it('persists the zoom level and bounds on before-quit', () => {
const mainWindow = createMockMainWindow();
mainWindow.getNormalBounds.mockReturnValue(bounds);
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
(electronApp.isReady as jest.Mock).mockReturnValue(true);
App.main(electronApp, BrowserWindow);
preloadAppliedZoom(mainWindow, 2);
fireHandlers(
(electronApp.on as jest.Mock).mock.calls,
'before-quit'
);
expect(store.set).toHaveBeenCalledWith('zoomLevel', 2);
expect(store.set).toHaveBeenCalledWith('windowBounds', bounds);
});
it('saves the zoom level right before a reload, which drops the temporary level', () => {
const mainWindow = createWindowViaOnReady();
preloadAppliedZoom(mainWindow, 3);
fireHandlers(
mainWindow.webContents.on.mock.calls,
'did-start-navigation',
{ isMainFrame: true, isSameDocument: false }
);
expect(store.set).toHaveBeenCalledWith('zoomLevel', 3);
});
it('leaves in-app (same-document) routing alone', () => {
const mainWindow = createWindowViaOnReady();
preloadAppliedZoom(mainWindow, 3);
fireHandlers(
mainWindow.webContents.on.mock.calls,
'did-start-navigation',
{ isMainFrame: true, isSameDocument: true }
);
expect(store.set).not.toHaveBeenCalled();
});
});
describe('renderer reload recovery', () => {
const rendererRoot = path.dirname(
path.resolve(__dirname, '..', 'web', 'index.html')
);
const routedUrl = pathToFileURL(
path.join(rendererRoot, 'workspace', 'sources')
).href;
function fireWebContentsEvent(
win: MockMainWindow,
eventName: string,
...args: unknown[]
): void {
const handlers = win.webContents.on.mock.calls
.filter(([name]) => name === eventName)
.map(([, handler]) => handler);
expect(handlers).toHaveLength(1);
handlers[0](...args);
}
function createPackagedWindow(): MockMainWindow {
process.env.ELECTRON_IS_DEV = '0';
const mainWindow = createMockMainWindow();
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
getAppInternals().onReady();
return mainWindow;
}
it('re-loads the packaged index with the route a failed file:// reload named', () => {
const mainWindow = createPackagedWindow();
fireWebContentsEvent(
mainWindow,
'did-fail-load',
{},
-6,
'ERR_FILE_NOT_FOUND',
routedUrl,
true
);
// Deferred to the error page's dom-ready, or the new document
// never paints.
expect(mainWindow.loadFile).not.toHaveBeenCalled();
fireWebContentsEvent(mainWindow, 'dom-ready');
expect(mainWindow.loadFile).toHaveBeenCalledWith(
path.join(rendererRoot, 'index.html'),
{ query: { restoreRoute: 'workspace/sources' } }
);
});
it('sends a renderer-initiated reload of a routed URL to the index instead of cancelling it', () => {
const mainWindow = createPackagedWindow();
const event = { preventDefault: jest.fn() };
fireWebContentsEvent(mainWindow, 'will-navigate', event, routedUrl);
expect(event.preventDefault).toHaveBeenCalled();
expect(mainWindow.loadFile).toHaveBeenCalledWith(
path.join(rendererRoot, 'index.html'),
{ query: { restoreRoute: 'workspace/sources' } }
);
expect(shell.openExternal).not.toHaveBeenCalled();
});
it('still opens external URLs in the browser and blocks other navigations', () => {
const mainWindow = createPackagedWindow();
const external = { preventDefault: jest.fn() };
const foreignFile = { preventDefault: jest.fn() };
fireWebContentsEvent(
mainWindow,
'will-navigate',
external,
'https://example.com/'
);
fireWebContentsEvent(
mainWindow,
'will-navigate',
foreignFile,
pathToFileURL(path.join(rendererRoot, '..', 'other')).href
);
expect(external.preventDefault).toHaveBeenCalled();
expect(shell.openExternal).toHaveBeenCalledWith(
'https://example.com/'
);
expect(foreignFile.preventDefault).toHaveBeenCalled();
expect(mainWindow.loadFile).not.toHaveBeenCalled();
});
});
it('creates the main window immediately when Electron is already ready', () => {
const mainWindow = createMockMainWindow();
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
+49 -3
View File
@@ -18,6 +18,15 @@ import {
WINDOW_BOUNDS,
} from './services/store.service';
import { isFrameCopyRuntimeUsable } from './services/embedded-mpv-frame-copy-platform.util';
import {
attachZoomLevelPersistence,
persistZoomLevel,
} from './services/window-zoom-level';
import {
attachRendererReloadFallback,
resolveRoutedRendererUrl,
restoreRendererRoute,
} from './services/renderer-reload-fallback';
import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util';
import {
FULLSCREEN_LAUNCH_SWITCH,
@@ -354,6 +363,20 @@ export default class App {
event.preventDefault();
// A renderer-initiated reload (`location.reload()`, e.g. the
// settings unsaved-changes guard) on an in-app route arrives here as
// a routed file:// URL with no file behind it. Send it straight to
// the packaged index with that route instead of cancelling it.
if (!App.isDevelopmentMode() && App.mainWindow) {
const rendererIndexPath = getPackagedRendererIndexPath();
const route = resolveRoutedRendererUrl(url, rendererIndexPath);
if (route !== null) {
restoreRendererRoute(App.mainWindow, rendererIndexPath, route);
return;
}
}
if (isExternalBrowserUrl(url)) {
shell.openExternal(url);
}
@@ -450,6 +473,18 @@ export default class App {
});
}
/**
* Persist the state restored on the next launch: window bounds and the
* zoom level. Runs from both the window 'close' and app 'before-quit'
* handlers, since either can be the last to run before the process ends.
* The zoom half is a no-op until the window's preload took ownership of
* the level (`services/window-zoom-level.ts`).
*/
private static persistWindowState(win: Electron.BrowserWindow): void {
store.set(WINDOW_BOUNDS, win.getNormalBounds());
persistZoomLevel(win);
}
private static initMainWindow() {
const workAreaSize = screen.getPrimaryDisplay().workAreaSize;
const width = Math.min(1280, workAreaSize.width || 1280);
@@ -539,6 +574,18 @@ export default class App {
App.handleRendererNavigation
);
// The preload restores the zoom level on every document load; the
// main process only saves it back before a reload drops it.
attachZoomLevelPersistence(App.mainWindow);
// A reload on an in-app route asks file:// for a path that does not
// exist; re-load the packaged index with that route instead of
// leaving Chromium's error page (see renderer-reload-fallback.ts).
attachRendererReloadFallback(
App.mainWindow,
getPackagedRendererIndexPath()
);
// Emitted when the window is closed.
App.mainWindow.on('closed', () => {
// Dereference the window object, usually you would store windows
@@ -551,7 +598,7 @@ export default class App {
App.mainWindow.on('close', () => {
if (App.mainWindow) {
store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds());
App.persistWindowState(App.mainWindow);
}
});
@@ -653,8 +700,7 @@ export default class App {
}
App.application.on('activate', App.onActivate); // App is activated
App.application.on('before-quit', () => {
if (App.mainWindow)
store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds());
if (App.mainWindow) App.persistWindowState(App.mainWindow);
});
}
}
@@ -1,10 +1,14 @@
import {
buildContentTitleFtsMatchQuery,
buildCompoundFtsMatchQuery,
buildCompoundLikePatterns,
buildGlobPrefixPatterns,
buildLikePatterns,
buildM3uPayloadCompoundPatterns,
getCompoundResidualTokenGroups,
getCompoundSearchWords,
getSearchWordPlans,
getSqlSearchTokenGroups,
getCompoundSearchWords,
scoreSearchTextMatch,
shouldUseContentTitlePrefixIndex,
} from './content-search.util';
@@ -122,6 +126,84 @@ describe('content-search.util', () => {
});
});
describe('Turkish case folding (issue #609)', () => {
// Upper and lower case forms of the same query must build the same
// search and match the same titles. The folding is locale-invariant,
// so these hold under a Turkish OS locale too, where toLocaleLowerCase
// would otherwise send "İ" and "I" to different letters.
const pairs: ReadonlyArray<readonly [string, string]> = [
['İnş', 'inş'],
['Ünlü', 'ünlü'],
['Çan', 'çan'],
['Şan', 'şan'],
];
it.each(pairs)(
'builds the same search for %s and %s',
(upper, lower) => {
expect(getSqlSearchTokenGroups(upper)).toEqual(
getSqlSearchTokenGroups(lower)
);
expect(buildContentTitleFtsMatchQuery(upper)).toBe(
buildContentTitleFtsMatchQuery(lower)
);
expect(shouldUseContentTitlePrefixIndex(upper)).toBe(
shouldUseContentTitlePrefixIndex(lower)
);
}
);
it('keeps the dotted capital I as one word, not "i" + "nş"', () => {
// The combining dot from lower-casing "İ" must not split the word,
// which used to flip it onto the short-token prefix index.
expect(getSqlSearchTokenGroups('İnş')).toEqual([['inş', 'ins']]);
expect(shouldUseContentTitlePrefixIndex('İnş')).toBe(false);
});
it('scores the same title for the upper and lower case query', () => {
const upper = scoreSearchTextMatch('İnşaat', 'İnş');
const lower = scoreSearchTextMatch('İnşaat', 'inş');
expect(lower).not.toBeNull();
expect(lower).toBe(upper);
});
it('spells the dotted capital İ forms in LIKE and GLOB patterns', () => {
// SQLite LIKE folds only ASCII and GLOB folds nothing, and the
// locale-invariant toUpperCase() turns "inş" into "INŞ", which
// never matches a stored "İnşaat". The Turkish forms are added
// with an explicit 'tr' locale so they do not depend on the OS.
expect(buildLikePatterns('inş')).toEqual(
expect.arrayContaining(['%İNŞ%', '%İnş%', '%INŞ%', '%inş%'])
);
expect(buildLikePatterns('İnş', 'prefix')).toEqual(
expect.arrayContaining(['İnş%', 'İNŞ%', 'inş%'])
);
expect(buildGlobPrefixPatterns('iş')).toEqual(
expect.arrayContaining(['İş*', 'İŞ*', 'iş*', 'IŞ*'])
);
expect(buildCompoundLikePatterns('iş-tv')).toEqual(
expect.arrayContaining(['%İş-tv%', '%İŞ-TV%'])
);
});
it('adds no Turkish forms to values without an i', () => {
expect(buildLikePatterns('çan')).toEqual([
'%çan%',
'%ÇAN%',
'%Çan%',
'%can%',
'%CAN%',
'%Can%',
]);
expect(buildGlobPrefixPatterns('ма')).toEqual([
'ма*',
'МА*',
'Ма*',
]);
});
});
describe('scoreSearchTextMatch', () => {
it('matches compound words anywhere in the title (issue #1161)', () => {
expect(scoreSearchTextMatch('US: A&E', 'A&E')).toBe(40);
@@ -0,0 +1,221 @@
/**
* Runs the search helpers' output through a real SQLite instance with the
* same trigram FTS tokenizer the content index uses, so the case-folding
* contract is checked against SQLite's own LIKE/GLOB/MATCH semantics rather
* than against a string assertion (issue #609). Each SQL arm of the global
* and per-playlist search is reproduced from the same pattern builders the
* operations consume; the AND/OR composition mirrors `content.operations.ts`.
*/
import Database from 'better-sqlite3';
import {
buildCompoundLikePatterns,
buildContentTitleFtsMatchQuery,
buildGlobPrefixPatterns,
buildLikePatterns,
buildM3uPayloadTextFieldPatterns,
getCompoundSearchWords,
getSqlSearchTokenGroups,
isShortSearchTokenGroup,
scoreSearchTextMatch,
shouldUseContentTitleFts,
shouldUseContentTitlePrefixIndex,
} from './content-search.util';
const TITLES = [
'İnşaat Kanalı',
'inşaat dünyası',
'İşte Benim Stilim',
'Ünlü Şef',
'Çanakkale',
'Şan Ve Şeref',
'Первый канал HD',
'ПЕРВЫЙ КАНАЛ',
'Ёжик ТВ',
'Йога для всех',
'Матч ТВ',
'Ελλάδα Σήμερα',
'Amélie',
'US: iş-tv',
];
type Arm = 'fts' | 'glob' | 'like' | 'm3u' | 'compound' | 'score';
function likeClauses(
column: string,
groups: string[][],
build: (token: string, mode: 'contains' | 'prefix') => string[]
): { where: string; params: string[] } {
const clauses: string[] = [];
const params: string[] = [];
groups.forEach((tokens, index) => {
const mode =
index === 0 && isShortSearchTokenGroup(tokens)
? 'prefix'
: 'contains';
const patterns = tokens.flatMap((token) => build(token, mode));
clauses.push(
`(${patterns.map(() => `${column} LIKE ? ESCAPE '\\'`).join(' OR ')})`
);
params.push(...patterns);
});
return { where: clauses.join(' AND '), params };
}
describe('content-search.util against SQLite', () => {
let db: Database.Database;
beforeAll(() => {
db = new Database(':memory:');
db.exec(`
CREATE TABLE c(title TEXT);
CREATE VIRTUAL TABLE f USING fts5(
title, content='c', content_rowid='rowid',
tokenize='trigram remove_diacritics 1'
);
CREATE TABLE p(title TEXT, payload TEXT);
`);
const insert = db.prepare('INSERT INTO c(title) VALUES (?)');
const insertPayload = db.prepare(
'INSERT INTO p(title, payload) VALUES (?, ?)'
);
for (const title of TITLES) {
insert.run(title);
insertPayload.run(
title,
JSON.stringify({ items: [{ name: title, title }] })
);
}
db.exec(`INSERT INTO f(f) VALUES ('rebuild')`);
});
afterAll(() => {
db.close();
});
function titles(sql: string, params: string[]): string[] {
return db
.prepare(sql)
.all(...params)
.map((row) => (row as { title: string }).title)
.sort();
}
function run(query: string): Partial<Record<Arm, string[]>> {
const out: Partial<Record<Arm, string[]>> = {};
const groups = getSqlSearchTokenGroups(query);
if (shouldUseContentTitlePrefixIndex(query)) {
const patterns = groups[0].flatMap((token) =>
buildGlobPrefixPatterns(token)
);
out.glob = titles(
`SELECT title FROM c WHERE ${patterns
.map(() => 'title GLOB ?')
.join(' OR ')}`,
patterns
);
} else if (shouldUseContentTitleFts(query)) {
out.fts = titles(
`SELECT c.title FROM f INNER JOIN c ON c.rowid = f.rowid WHERE f MATCH ?`,
[buildContentTitleFtsMatchQuery(query)]
);
}
const like = likeClauses('title', groups, buildLikePatterns);
out.like = titles(
`SELECT title FROM c WHERE ${like.where}`,
like.params
);
const m3u = likeClauses(
'payload',
groups,
buildM3uPayloadTextFieldPatterns
);
out.m3u = titles(`SELECT title FROM p WHERE ${m3u.where}`, m3u.params);
// The compound arm of the prefix path: a punctuation-joined word is
// looked up as an intact substring, because the prefix index only
// sees titles starting with the short first token (issue #1161).
const compoundWords = getCompoundSearchWords(query);
if (compoundWords.length > 0) {
const patterns = compoundWords.flatMap((word) =>
buildCompoundLikePatterns(word)
);
out.compound = titles(
`SELECT title FROM c WHERE ${patterns
.map(() => `title LIKE ? ESCAPE '\\'`)
.join(' OR ')}`,
patterns
);
}
out.score = TITLES.filter(
(title) => scoreSearchTextMatch(title, query) !== null
).sort();
return out;
}
it.each([
['İnş', 'inş', ['İnşaat Kanalı', 'inşaat dünyası']],
['İşt', 'işt', ['İşte Benim Stilim']],
['İş', 'iş', ['İşte Benim Stilim']],
['Ünl', 'ünl', ['Ünlü Şef']],
['Çan', 'çan', ['Çanakkale']],
['Şan', 'şan', ['Şan Ve Şeref']],
])(
'finds the same Turkish titles for %s and %s in every SQL arm',
(upper, lower, expected) => {
const upperResult = run(upper);
const lowerResult = run(lower);
expect(lowerResult).toEqual(upperResult);
for (const arm of Object.keys(lowerResult) as Arm[]) {
expect(lowerResult[arm]).toEqual([...expected].sort());
}
}
);
it.each([
['Первый', 'первый', ['Первый канал HD', 'ПЕРВЫЙ КАНАЛ']],
['ПЕРВ', 'перв', ['Первый канал HD', 'ПЕРВЫЙ КАНАЛ']],
['Ёжик', 'ёжик', ['Ёжик ТВ']],
['Йога', 'йога', ['Йога для всех']],
['Ма', 'ма', ['Матч ТВ']],
['ΕΛΛ', 'ελλ', ['Ελλάδα Σήμερα']],
])(
'keeps Cyrillic and Greek case pairs %s / %s equal and complete',
(upper, lower, expected) => {
const upperResult = run(upper);
const lowerResult = run(lower);
expect(lowerResult).toEqual(upperResult);
for (const arm of Object.keys(lowerResult) as Arm[]) {
expect(lowerResult[arm]).toEqual([...expected].sort());
}
}
);
it('reaches a lower-case compound title from the dotted capital İ spelling', () => {
// "İş-TV" lower-cases to "i" + U+0307, which is not how the stored
// "US: iş-tv" is spelled, and its short first token keeps the token
// arm prefix-anchored — so the compound arm has to carry the
// mark-free lower-case form.
expect(run('İş-TV').compound).toEqual(['US: iş-tv']);
expect(run('iş-tv').compound).toEqual(['US: iş-tv']);
expect(run('İş-TV')).toEqual(run('iş-tv'));
});
it('matches a decomposed query against the precomposed stored title', () => {
// The query normalizer composes first, so "Ame" + U+0301 + "lie"
// produces the same tokens as the precomposed spelling.
expect(run('Ame\u0301lie').like).toEqual(run('Amélie').like);
expect(run('Ame\u0301lie').score).toEqual(['Amélie']);
});
it('keeps the diacritic-stripped fallback for Latin accents', () => {
expect(run('Amélie').fts).toEqual(['Amélie']);
expect(run('amelie').fts).toEqual(['Amélie']);
expect(run('amelie').score).toEqual(['Amélie']);
});
});
@@ -16,12 +16,55 @@ export function escapeLikePattern(term: string): string {
return term.replace(/[%_\\]/g, '\\$&');
}
/**
* Case spellings a stored title may use for `value`, for LIKE/GLOB patterns:
* SQLite LIKE folds only ASCII and GLOB folds nothing, so every form has to
* be spelled out. Besides lower/upper/title case, a value containing "i"
* also gets the Turkish forms with the dotted capital İ (U+0130) — "inş"
* becomes "İNŞ" / "İnş" — because the locale-invariant `toUpperCase()`
* yields "INŞ", which never matches a title such as "İnşaat" (issue #609),
* and conversely the mark-free lower-case form, which `toLowerCase()` alone
* does not produce for a value already spelled with "İ".
* The explicit `'tr'` locale is the point: it is fixed, not the OS locale.
*/
function getCaseVariants(value: string): string[] {
const lower = value.toLowerCase();
// Lower-casing "İ" leaves "i" + U+0307, so `lower` is not the spelling a
// stored lower-case title uses; `folded` is. Without it a query typed as
// "İş-TV" carries no pattern that matches a stored "US: iş-tv" — the
// token arm is prefix-anchored on the short first token, and the
// compound arm would only offer the diacritic-stripped "is-tv".
const folded = lower.replace(/[\u0300-\u036f]/g, '');
const variants = new Set<string>([
value,
lower,
folded,
value.toUpperCase(),
value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(),
]);
if (folded.includes('i')) {
variants.add(folded.toLocaleUpperCase('tr'));
variants.add(
folded.charAt(0).toLocaleUpperCase('tr') + folded.slice(1)
);
}
return [...variants];
}
// Search case folding is deliberately locale-invariant (toLowerCase, not
// toLocaleLowerCase). Under a Turkish or Azeri locale toLocaleLowerCase maps
// ASCII "I" to the dotless "ı" and folds the dotted "İ" its own way, so the
// same title would be found or missed depending on the user's OS locale, and
// the query would not line up with the SQLite FTS index, which folds
// locale-invariantly. Issue #609: "İnş" and "inş" returned different results.
export function normalizeSearchMatchText(value: unknown): string {
return typeof value === 'string'
? value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLocaleLowerCase()
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ')
@@ -31,7 +74,20 @@ export function normalizeSearchMatchText(value: unknown): string {
function normalizeSqlSearchText(value: unknown): string {
return typeof value === 'string'
? value
.toLocaleLowerCase()
.toLowerCase()
// Re-compose first so canonically equivalent spellings of the
// query produce one token: a decomposed "e" + U+0301 would
// otherwise lose its accent below and stop matching a stored
// precomposed "é". Same rule as `foldSearchText`.
.normalize('NFC')
// Drop the combining marks case folding leaves behind, before the
// split below can read them as word separators: "İ" (U+0130)
// lower-cases to "i" plus a combining dot above (U+0307), which
// otherwise split "İnş" into "i" and "nş" while "inş" stayed one
// token, routing the two differently (issue #609). Same
// U+0300-U+036F range normalizeSearchMatchText strips; precomposed
// letters (ç, ş, ü, é) are outside it and left untouched.
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ')
@@ -75,16 +131,9 @@ export function buildLikePatterns(
continue;
}
const titleCase =
trimmedValue.length > 0
? trimmedValue.charAt(0).toLocaleUpperCase() +
trimmedValue.slice(1).toLocaleLowerCase()
: trimmedValue;
variants.add(trimmedValue);
variants.add(trimmedValue.toLocaleLowerCase());
variants.add(trimmedValue.toLocaleUpperCase());
variants.add(titleCase);
for (const variant of getCaseVariants(trimmedValue)) {
variants.add(variant);
}
}
return [...variants].map((value) => {
@@ -97,13 +146,9 @@ export function buildGlobPrefixPatterns(token: string): string[] {
const variants = new Set<string>();
for (const value of [token, ...getSqlSearchTokenVariants(token)]) {
variants.add(value);
variants.add(value.toLocaleLowerCase());
variants.add(value.toLocaleUpperCase());
variants.add(
value.charAt(0).toLocaleUpperCase() +
value.slice(1).toLocaleLowerCase()
);
for (const variant of getCaseVariants(value)) {
variants.add(variant);
}
}
return [...variants].map((value) => `${value}*`);
@@ -236,13 +281,9 @@ export function buildCompoundLikePatterns(word: string): string[] {
const variants = new Set<string>();
for (const value of getCompoundWordVariants(word)) {
variants.add(value);
variants.add(value.toLocaleLowerCase());
variants.add(value.toLocaleUpperCase());
variants.add(
value.charAt(0).toLocaleUpperCase() +
value.slice(1).toLocaleLowerCase()
);
for (const variant of getCaseVariants(value)) {
variants.add(variant);
}
}
return [...variants].map((value) => `%${escapeLikePattern(value)}%`);
@@ -261,7 +302,7 @@ export function buildCompoundFtsMatchQuery(searchTerm: string): string {
const quotedVariants = [
...new Set(
getCompoundWordVariants(word).map((variant) =>
variant.toLocaleLowerCase()
variant.toLowerCase()
)
),
].map((variant) => `"${variant.replace(/"/g, '""')}"`);
Loaded 100 of 732 files, more files were not shown because too many files have changed in this diff. Show more