mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/analyze-second-bug-907512
# Conflicts: # libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts # libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts
This commit is contained in:
commit
11414f0b94
626 files changed
+41521
-5239
No files matched your search
+4
-2
@@ -55,8 +55,10 @@ The body is capped at 400 characters — depth belongs in the blog post.
|
||||
gives it a short, poster-worthy name. The 60-character cap keeps it roughly to
|
||||
one line on the hero card; card text wraps by estimated width, so a headline of
|
||||
unusually wide glyphs may still wrap or ellipsize rather than overflow. Highlights lead the Telegram/Reddit
|
||||
announcements (everything else collapses into a "+N more" counter) and become
|
||||
ready-made section headings in the blog scaffold. Set it on the two or three
|
||||
announcements (everything else collapses into a "+N more" counter) and open the
|
||||
blog scaffold: a row in its "What changed" table and a `##` section ahead of
|
||||
the themed sections, while fixes without a highlight collapse under a spoiler
|
||||
there. Set it on the two or three
|
||||
changes worth announcing — a release where everything is a highlight has none.
|
||||
Not allowed on `type: internal`.
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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).
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -40,9 +40,9 @@ consumers currently use relative `@use` paths to the needed partial.
|
||||
of copied SCSS.
|
||||
- In an Electron drag region, every interactive descendant—buttons, links,
|
||||
inputs, overlays, and resize handles—must explicitly use
|
||||
`app-region: no-drag`. The shared directive-generated `.resize-handle` does
|
||||
not set this centrally yet; consumers in drag regions must cover it
|
||||
themselves and must not assume the generated handle opts out.
|
||||
`app-region: no-drag`. The shared directive-generated `.resize-handle` sets
|
||||
this centrally. Shared live sidebars reserve 8 px between their scroll
|
||||
content and the edge so scrollbar and resize hit areas stay independent.
|
||||
- A shared change must be checked across M3U, Xtream, Stalker, workspace,
|
||||
portal catalog/shared UI, and unified collections where relevant.
|
||||
|
||||
|
||||
@@ -153,6 +153,9 @@ jobs:
|
||||
- name: Validate Vite dev-server transform filter patch
|
||||
run: pnpm run deps:vite:test
|
||||
|
||||
- name: Validate electron-builder keychain password patch
|
||||
run: pnpm run deps:electron-builder:test
|
||||
|
||||
- name: Validate stylesheet Nx inputs
|
||||
run: pnpm run styles:inputs:validate
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ jobs:
|
||||
|
||||
- name: Build website
|
||||
run: pnpm nx build website
|
||||
env:
|
||||
# Read by apps/website/src/lib/downloads.ts to resolve the latest published release.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
|
||||
@@ -24,6 +24,17 @@ This file provides guidance to coding agents working in this repository.
|
||||
`patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling
|
||||
resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`
|
||||
after related dependency updates.
|
||||
- `app-builder-lib` `26.15.7` (electron-builder's macOS signing) is patched in
|
||||
`patches/app-builder-lib@26.15.7.patch` with the upstream backport
|
||||
electron-userland/electron-builder#10172: `security set-key-partition-list -k`
|
||||
must receive the temporary keychain's own password, not the `.p12` import
|
||||
password. macOS runner images since `macos-26-arm64` 20260831 verify that
|
||||
password, and `Build on macos arm64` failed with `SecKeychainUnlock: The user
|
||||
name or passphrase you entered is not correct`. Keep the patch until
|
||||
electron-builder resolves an `app-builder-lib` containing the fix (26.16.1+),
|
||||
and run `pnpm run deps:electron-builder:test` after related dependency
|
||||
updates — the test fails when the patched version no longer matches the
|
||||
installed one.
|
||||
- A directory holding files consumed by other projects must be an Nx project.
|
||||
Nx builds its graph from TypeScript imports only, so a relative SCSS `@use`
|
||||
across project roots creates no edge and the imported file lands in no task
|
||||
@@ -91,6 +102,17 @@ This file provides guidance to coding agents working in this repository.
|
||||
- Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with `agent-browser` and the tracing flags documented below.
|
||||
- Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead.
|
||||
|
||||
## Legacy Desktop Profile Migration
|
||||
|
||||
`electron-profile-bootstrap.ts` selects the known v0.19 `electron-backend`
|
||||
profile before eager main-process imports only when current Chromium storage
|
||||
is unused. Existing profiles retain their settings and offer explicit recovery
|
||||
of missing sources from a disposable legacy snapshot. Playlist rows and a
|
||||
completion receipt commit atomically in the DB worker; original IndexedDB is
|
||||
retained, current payload rows are preserved, and completed imports never
|
||||
replay deleted sources. Contract and recovery limits:
|
||||
`docs/architecture/m3u-playlist-module.md` (Desktop upgrades from legacy profiles).
|
||||
|
||||
## Electron Debugging (CDP)
|
||||
|
||||
- Start the Electron development app with: `nx serve electron-backend`
|
||||
@@ -155,6 +177,68 @@ agent-browser connect ws://127.0.0.1:9222/devtools/page/<iptvnator-page-id>
|
||||
agent-browser screenshot /tmp/iptvnator-cdp.png
|
||||
```
|
||||
|
||||
## Xtream Category Management
|
||||
|
||||
The Electron Live TV, Movies, and Series category dialog applies Select/Deselect
|
||||
to search results while a filter is active and to the whole type otherwise.
|
||||
Button states use the matching group; "Total selected" counts the whole catalog.
|
||||
Save persists the complete draft, Close discards it, and refresh restores hidden
|
||||
categories by provider ID and type. See `docs/architecture/category-management.md`.
|
||||
|
||||
## XMLTV Source Removal
|
||||
|
||||
Saving Settings → EPG reconciles cached XMLTV with committed global URLs and
|
||||
all enabled M3U playlist sources. Startup runs the same reconciliation after
|
||||
settings load and playlist migration. Ordinary saves skip unchanged normalized
|
||||
source sets; an explicitly edited EPG field can retry a failed cleanup.
|
||||
A cleanup failure after persistence still mirrors committed settings to Electron;
|
||||
the form stays dirty for retry. Failed storage writes never mirror to main.
|
||||
Failed settings reads and incomplete playlist migration never authorize pruning.
|
||||
Removed sources retire queued/running imports and dismiss retained error rows
|
||||
before worker-owned deletion. Retry waits for reconciliation and rechecks its
|
||||
error row, including after trust-setting writes. Shared channel IDs survive while
|
||||
another source has programmes or per-source channel metadata. The additive
|
||||
`epg_channel_sources` table preserves each imported source's name, logo, URL and
|
||||
timestamp plus transaction-ordered `write_order`, so removal restores the latest
|
||||
surviving snapshot even when import timestamps tie; ambiguous legacy metadata
|
||||
falls back to the XMLTV ID until reimport. Manual mappings remain user preferences, but no
|
||||
longer resolve deleted data. Renderer lookup generations, Xtream previews and Stalker mapping-cache
|
||||
invalidation prevent late results from restoring removed programmes. Provider
|
||||
EPG is independent. See `docs/architecture/m3u-playlist-module.md`
|
||||
("XMLTV source lifecycle").
|
||||
|
||||
## Portal Connectivity Preference
|
||||
|
||||
- Half-open trial slots follow the complete request lifetime with no elapsed-time
|
||||
expiry. All four Electron/web-backend portal handlers release in `finally`,
|
||||
independently of outcome reporting; cleanup preserves trial/epoch ownership
|
||||
and works while the environment override is disabled. Contract:
|
||||
`docs/architecture/host-connectivity-guard.md` ("Trial ownership follows the
|
||||
request lifetime").
|
||||
- Desktop Settings > General > Portal connections exposes default-on
|
||||
`Settings.portalConnectivityGuard`. Only explicit false opts out. Save mirrors
|
||||
the value to Electron `PORTAL_CONNECTIVITY_GUARD` and applies it without restart;
|
||||
settings bootstrap restores it before the renderer loads. It controls Xtream
|
||||
and Stalker together. Preference transitions clear cooldowns and invalidate old
|
||||
request completions; unchanged saves preserve evidence. The environment switch
|
||||
`IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` remains authoritative. PWA clients do not
|
||||
control the shared backend's guard.
|
||||
- Both account-info dialogs explain guard refusals with localized paused-request
|
||||
copy and Retry now; Stalker preserves cached account data on a failed refresh.
|
||||
Contract: `docs/architecture/host-connectivity-guard.md`.
|
||||
|
||||
## Channel and Detail Keyboard Scrolling
|
||||
|
||||
Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection
|
||||
focuses the viewport, native scrolling survives virtual row recycling, and
|
||||
row Enter/Space activation stays separate from focus movement. Portal Live TV
|
||||
uses ArrowRight from the selected category and ArrowLeft from the channels
|
||||
pane to move between columns. Shared live sidebars reserve scrollbar space
|
||||
beside the resize handle. `PortalDetailShellComponent` owns a visible native
|
||||
scrollbar and guarded initial page focus. Contracts:
|
||||
`docs/architecture/iptvnator-ui-guidelines.md` and
|
||||
`docs/architecture/portal-detail-navigation.md`.
|
||||
|
||||
## Radio / Audio Player
|
||||
|
||||
M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected:
|
||||
@@ -174,8 +258,26 @@ Key files:
|
||||
- `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` — template conditionals for radio vs video
|
||||
- `libs/shared/interfaces/src/lib/channel.interface.ts` — `radio: string` field on Channel interface
|
||||
|
||||
## M3U URL User-Agent
|
||||
|
||||
- The URL import form accepts an optional User-Agent and stores it as
|
||||
`Playlist.userAgent`. Electron sends it on initial download, manual refresh,
|
||||
and startup auto-update. The self-hosted PWA sends it through the registered
|
||||
target `/parse` backend proxy for import and refresh; a matching backend is
|
||||
required, and browser playback-header restrictions still apply.
|
||||
- Reuse the existing source editor and channel-over-playlist playback header
|
||||
precedence. Contract: `docs/architecture/m3u-playlist-module.md`
|
||||
("User-Agent for URL sources").
|
||||
|
||||
## Shared Player Controls
|
||||
|
||||
- The Embedded MPV native-view dock follows app theme tokens as a solid app
|
||||
surface, including Material icon-button disabled states. Over-video loading,
|
||||
stalled and feedback overlays keep a paired light-on-dark palette. Video
|
||||
viewports remain black in windowed and fullscreen modes. Shared EPG panels
|
||||
use the library-local app-token palette in `libs/ui/epg/src/lib/_epg-theme.scss`.
|
||||
Theme/contrast contract: `docs/architecture/iptvnator-ui-guidelines.md`.
|
||||
|
||||
- `libs/ui/playback/src/lib/player-controls/` contains the additive,
|
||||
engine-neutral `PlayerController` contract, standalone
|
||||
`app-player-controls`, generic web-video adapter/helper, and component-scoped
|
||||
@@ -203,6 +305,29 @@ Key files:
|
||||
`PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`;
|
||||
movie and live hosts fall back to `playback.title`, skipping raw stream-URL
|
||||
fallbacks. Outside fullscreen the overlay stays hidden.
|
||||
- Auto-hide pauses while the pointer is over the controls bar or keyboard
|
||||
focus is inside it, but only keyboard-originated focus pins the bar open.
|
||||
Chromium also focuses a clicked `<button>`, so
|
||||
`ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent
|
||||
`pointerdown` inside the focused element; such focus reveals without
|
||||
blocking auto-hide (otherwise the fullscreen button left the controls on
|
||||
screen until a click-to-pause on the viewport). The press record is
|
||||
discarded on the first bar focus event it is asked about or on any
|
||||
`keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a
|
||||
`keydown` bubbling out of a bar control re-pins it, since operating a
|
||||
focused control produces no focus event. A completed pointer click then
|
||||
releases the focus it left on the control (`onBarClick` →
|
||||
`ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`:
|
||||
non-empty click `pointerType`, else a recent press inside the clicked
|
||||
element), because a focused control captures the keyboard: Space and
|
||||
Enter re-activated the clicked button and `ControlsShortcuts` yields to
|
||||
any interactive element in the key's path, so after a click on fullscreen
|
||||
Space left fullscreen instead of pausing. Keyboard activation (empty
|
||||
`pointerType`) keeps focus, only buttons and range sliders are released,
|
||||
Chromium keeps its sequential-focus starting point at the blurred control
|
||||
so Tab continues from it, and the volume popover ignores the release's
|
||||
`focusout` (`wasPointerFocusRelease`). Contract:
|
||||
`docs/architecture/player-controls-contract.md` (auto-hide paragraph).
|
||||
- Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored
|
||||
values coerce with `!== false`; only an explicit false opts out to the legacy
|
||||
vendor chrome), and its checkbox appears only when HTML5, Video.js, or
|
||||
@@ -255,6 +380,73 @@ Key files:
|
||||
commands are cancelled. Same-session IPC replies also yield to a broadcast
|
||||
snapshot received while the command was pending, preventing a successful
|
||||
recording acknowledgement from being rolled back by a stale reply.
|
||||
- `WebPlayerViewComponent` renders `app-fullscreen-channel-panel`
|
||||
(`libs/ui/playback/src/lib/fullscreen-channel-panel/`) beside the engine,
|
||||
staged on the view's `fullscreenSurface` — the same host element every engine
|
||||
receives as `fullscreenTarget` — so it lives inside the fullscreen element
|
||||
and survives the engine remount a channel switch causes. It is withheld
|
||||
(`enabled=false`) for native-view Embedded MPV, which paints above the DOM.
|
||||
A confirmed frame-copy capability survives the unknown support probe during
|
||||
an engine remount, preserving panel search/scroll on channel changes; the
|
||||
first unknown probe and confirmed native/unsupported results withhold it.
|
||||
A live host provides `FULLSCREEN_CHANNEL_PANEL` (`panelTemplate` + optional
|
||||
`panelTitle`) and the panel slides that list over the video: left-edge hover
|
||||
dwell, a touch tap on that edge, or `C`. Nothing is drawn while it is closed
|
||||
(no handle), the hot zone stops above the controls bar, and scrim/Escape/
|
||||
mouse-leave close it — while a CDK overlay opened from the list counts as
|
||||
the panel, so hover keeps it open and Escape closes the overlay first. The
|
||||
header is one row (search whose placeholder carries the host title, plus
|
||||
close) and the list stays mounted per fullscreen session.
|
||||
`Settings.fullscreenChannelPanel` (default on) gates it, offered only for the
|
||||
web players with shared controls and for Embedded MPV — the legacy vendor
|
||||
chrome fullscreens the engine's own element, outside which the panel cannot
|
||||
render. Providers: M3U `VideoPlayerComponent` (returns null while its VOD
|
||||
detail hosts the player; radio and recognized movies are filtered out of
|
||||
the list it is handed, since `app-audio-player` and the VOD detail shell
|
||||
each replace the fullscreen-owning `app-web-player-view`; with MPV/VLC
|
||||
configured, only DASH rows stay offered because other streams leave the
|
||||
inline host for the external-player UI), Xtream
|
||||
`LiveStreamLayoutComponent`,
|
||||
`StalkerLiveStreamLayoutComponent` (one `ng-template` stamped twice; a blank
|
||||
panel field shows the category untouched by the sidebar's search term (or
|
||||
the windowed full cache when playback starts from All Items without a
|
||||
category), the
|
||||
panel's search results are windowed by `PanelSearchWindow`, and on a paged
|
||||
portal the panel copy keeps requesting pages while its matches do not fill
|
||||
it, even while the sidebar's own search is active; the retained closed
|
||||
panel pauses paging and resumes automatic filling when reopened; inline video
|
||||
commits the selected channel with the resolved playback, retaining the old
|
||||
selection, EPG and recording metadata during a pending or failed replacement), and
|
||||
`UnifiedLiveTabComponent` (radio filtered the same way; it keeps the previous
|
||||
detail mounted until the next selection resolves, with `activeItem` paired
|
||||
to that detail so the session key and recording metadata keep describing
|
||||
the stream on screen — only the `activeUid` row highlight moves ahead; a
|
||||
second activation of the row still resolving folds its start-playback or
|
||||
auto-open intent into that request instead of launching the retained
|
||||
stream, and a
|
||||
failed replacement restores that highlight and retains the previous video,
|
||||
catch-up and session). M3U PageUp/PageDown yield to already-handled events
|
||||
and menu/dialog overlay targets even when the menu has no scroll overflow.
|
||||
Numeric and adjacent-channel commands share the panel eligibility filter
|
||||
while the live web-player host owns fullscreen (itself, or through the
|
||||
nested surface a legacy player fullscreens under the vendor-chrome
|
||||
opt-out); numbers keep their original positions and ineligible numbers are
|
||||
ignored. Windowed commands keep the
|
||||
complete catalog.
|
||||
Xtream's two `PortalChannelsListComponent` instances relay favorite toggles
|
||||
through `XtreamFavoriteMarksService`. CDK overlays follow the
|
||||
fullscreen element via `FullscreenOverlayContainer`. Contract:
|
||||
`docs/architecture/player-controls-contract.md` ("Fullscreen channel panel").
|
||||
- Embedded MPV seek steps (arrow keys, ±10 s buttons, `PlayerController.seekBy`)
|
||||
go through the relative `seekEmbeddedMpvBy` IPC: every backend forwards the
|
||||
delta as mpv `seek <delta> relative+exact` (addon export `seekBy`, helper
|
||||
stdin command `seek-by`, Linux JSON IPC) and never advances the snapshot
|
||||
position itself. Do not derive an absolute target from the renderer's
|
||||
`positionSeconds`: it is floored to whole seconds, polled every 500 ms, and
|
||||
a seek reply does not carry the new position, so rapid presses computed from
|
||||
it collapse onto one target. Only the timeline scrub commits an absolute
|
||||
`seek`. Contract: `docs/architecture/embedded-mpv-native.md` ("Resume And
|
||||
Track Handling").
|
||||
- DASH (`.mpd`) sources play through a lazily imported Shaka Player source
|
||||
engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and
|
||||
ArtPlayer components; ClearKey keys come from KODIPROP-derived
|
||||
@@ -434,7 +626,27 @@ Key files:
|
||||
path keeps the existing Video.js skin and legacy series navigation unchanged
|
||||
(still without `userActions.hotkeys`), while the playback keyboard shortcuts
|
||||
attach through `LegacyPlayerShortcuts` and drive the player API so the
|
||||
vendor control bar stays in sync (`vjs-legacy-shortcuts.ts`).
|
||||
vendor control bar stays in sync (`vjs-legacy-shortcuts.ts`). That chrome
|
||||
also releases the focus a pointer interaction leaves on a control
|
||||
(`vjs-pointer-focus-release.ts`, sharing `pointer-focus-release.ts`'s
|
||||
`blurFocusedControl` with `ControlsSurface`): a focused Video.js component
|
||||
stops every key before the document and turns Space/Enter into a click, so
|
||||
after a click on fullscreen Space left fullscreen instead of pausing. It is
|
||||
driven mainly by `focusin`, not the click, because choosing a menu item
|
||||
moves focus to the menu button a tick later and that click never bubbles to
|
||||
the shell: an eligible control (button/`role=button`/slider, never a menu
|
||||
item) is released when its focus is attributable to a recent shell
|
||||
`pointerdown` not yet ended by a document `keydown`, so `Tab` focus is kept.
|
||||
A `click` runs the same release for a control clicked while already focused
|
||||
(Tab, then a mouse click), which fires no `focusin`. The release is scoped
|
||||
to `.vjs-control-bar`, so the caption-settings dialog (a modal sibling of
|
||||
the bar) keeps its focus trap. Menu buttons live in the bar and are not
|
||||
exempt: a popup is navigated through its focused item, so releasing the
|
||||
button never disturbs an open menu, and the button focus a pointer moves
|
||||
through (open, item selection, toggling an open menu shut) is released so
|
||||
Space works again after the menu closes. ArtPlayer
|
||||
(non-focusable divs) and the native HTML5 controls (focus lands on the
|
||||
`<video>`) need no counterpart.
|
||||
- ArtPlayer is the fourth guarded consumer. `ArtPlayerComponent` provides a
|
||||
component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns
|
||||
HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and
|
||||
@@ -458,7 +670,7 @@ Key files:
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
`togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard
|
||||
element PiP from the adapter's attached video; shared ArtPlayer keeps vendor
|
||||
`pip: false`, while preference-off native/vendor paths remain unchanged. The
|
||||
`pip: false`, while preference-off native/vendor controls keep their own UI. The
|
||||
capability-gated button sits before fullscreen and uses active enter/exit
|
||||
semantics; entry is disabled until metadata, and the action is disabled while
|
||||
an operation is pending. Embedded MPV reports capability/state false with a
|
||||
@@ -471,7 +683,14 @@ Key files:
|
||||
serialized, and binding generation plus exact video identity protects
|
||||
replacement and teardown from stale completion. Video.js Tech reset and
|
||||
ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a
|
||||
retained target preserve PiP.
|
||||
retained target preserve PiP. Legacy HTML5/ArtPlayer teardown and Video.js
|
||||
Tech replacement also release exact-owned PiP through
|
||||
`web-video-picture-in-picture-lifecycle.ts`, independent of the controls
|
||||
preference. A one-shot listener on the retired video closes late native/vendor
|
||||
entries without retaining the host or touching another video's PiP. Legacy
|
||||
WebKit presentation-mode PiP also returns the retired video to inline; its
|
||||
presentation-change listener ignores fullscreen/inline events until a late
|
||||
PiP entry consumes it.
|
||||
Standard PiP shows the browser/OS video surface without Angular control
|
||||
chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
|
||||
keyboard shortcut, and Embedded MPV popup/native support are out of scope.
|
||||
|
||||
@@ -32,7 +32,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
|
||||
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
|
||||
- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.
|
||||
- `highlight: <short headline>` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
|
||||
- `highlight: <short headline>` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, open the blog scaffold (a "What changed" table row plus a leading `##` section each, while the remaining features fold into themed sections and non-highlighted fixes collapse under a spoiler — `tools/release/release-notes-blog.mjs`), and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
|
||||
- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
|
||||
- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
|
||||
- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`.
|
||||
@@ -41,7 +41,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.
|
||||
- `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication.
|
||||
- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.
|
||||
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image.
|
||||
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. Website guide screenshots use the same script: manifest shots with `"group": "guides"` are captured only by `pnpm release:screenshots --group guides` and land in `apps/website/public/blog/guides/screenshots/`.
|
||||
- Final task summaries should state whether a release note was added or why it was skipped.
|
||||
|
||||
## Regression Prevention And Test Updates
|
||||
@@ -85,6 +85,17 @@ pnpm nx show projects
|
||||
`patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling
|
||||
resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`
|
||||
after related dependency updates.
|
||||
- `app-builder-lib` `26.15.7` (electron-builder's macOS signing) is patched in
|
||||
`patches/app-builder-lib@26.15.7.patch` with the upstream backport
|
||||
electron-userland/electron-builder#10172: `security set-key-partition-list -k`
|
||||
must receive the temporary keychain's own password, not the `.p12` import
|
||||
password. macOS runner images since `macos-26-arm64` 20260831 verify that
|
||||
password, and `Build on macos arm64` failed with `SecKeychainUnlock: The user
|
||||
name or passphrase you entered is not correct`. Keep the patch until
|
||||
electron-builder resolves an `app-builder-lib` containing the fix (26.16.1+),
|
||||
and run `pnpm run deps:electron-builder:test` after related dependency
|
||||
updates — the test fails when the patched version no longer matches the
|
||||
installed one.
|
||||
- A directory holding files consumed by other projects must be an Nx project.
|
||||
Nx builds its graph from TypeScript imports only, so a relative SCSS `@use`
|
||||
across project roots creates no edge and the imported file lands in no task
|
||||
@@ -187,7 +198,7 @@ Useful narrower flags:
|
||||
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events
|
||||
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity, bounded Embedded MPV runtime-probe stderr, and embedded MPV session status transitions (the input of the reconnect policy; never the stream URL)
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
|
||||
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
|
||||
@@ -305,6 +316,17 @@ The target still reports success either way, so a broken glob hides missing
|
||||
coverage instead of failing. After changing such a target, compare the linted
|
||||
file count against `find <project> -name '*.ts' | wc -l`.
|
||||
|
||||
## Legacy Desktop Profile Migration
|
||||
|
||||
`electron-profile-bootstrap.ts` selects the known v0.19 `electron-backend`
|
||||
profile before eager main-process imports only when current Chromium storage
|
||||
is unused. Existing profiles retain their settings and offer explicit recovery
|
||||
of missing sources from a disposable legacy snapshot. Playlist rows and a
|
||||
completion receipt commit atomically in the DB worker; original IndexedDB is
|
||||
retained, current payload rows are preserved, and completed imports never
|
||||
replay deleted sources. Contract and recovery limits:
|
||||
`docs/architecture/m3u-playlist-module.md` (Desktop upgrades from legacy profiles).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure (Nx Workspace)
|
||||
@@ -319,7 +341,7 @@ This is an Nx monorepo with the following structure:
|
||||
- **apps/electron-backend-e2e** - Playwright E2E tests against the Electron app
|
||||
- **apps/stalker-mock-server** - Mock Stalker/Ministra portal for dev and E2E
|
||||
- **apps/xtream-mock-server** - Mock Xtream Codes API for dev and E2E
|
||||
- **apps/website** - Astro + Tailwind landing page and blog
|
||||
- **apps/website** - Astro + Tailwind landing page, blog (guides carry `faq:` frontmatter → FAQPage JSON-LD; tags are a closed vocabulary in `src/lib/blog-tags.ts` enforced by the collection schema, each with a `/blog/tag/<tag>/` hub), per-OS download landing pages (`/download/`, `/download/{windows,macos,linux}/`) plus the Docker page (`/download/docker/`) feature landing pages (`/features/`, registry in `src/lib/features.ts`) and comparison pages (`/compare/`, registry in `src/lib/comparisons.ts`, comparing IPTVnator's own options rather than other products); direct asset links are resolved at build time from the GitHub Releases API with a `package.json` fallback (`src/lib/downloads.ts`, see `apps/website/README.md`)
|
||||
- **libs/** - Shared libraries:
|
||||
- **epg/data-access** - EPG services, runtime bridge, program normalization
|
||||
- **m3u-state** - NgRx state management for M3U playlists
|
||||
@@ -334,10 +356,10 @@ This is an Nx monorepo with the following structure:
|
||||
- **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`)
|
||||
- **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`)
|
||||
- **shared/logging** - Dependency-free structured redaction for diagnostic logs
|
||||
- **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance
|
||||
- **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance. Monotonic admission ids with per-endpoint failure boundaries distinguish parallel failures from later attempts even within one clock tick (#1438)
|
||||
- **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend)
|
||||
- **shared/m3u-utils** - M3U playlist utilities
|
||||
- **shared/marketing-fixtures** - Provider-neutral fictional movie metadata shared by the Xtream and Stalker marketing mocks
|
||||
- **shared/marketing-fixtures** - Provider-neutral fictional movie metadata, live channel list and the generated channel-logo SVG renderer shared by the Xtream and Stalker marketing mocks (both serve `/assets/marketing/logo/<slug>.svg`)
|
||||
- **shared/testing** - Shared test helpers
|
||||
- **ui/components** - Reusable UI components (incl. channel list)
|
||||
- **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs)
|
||||
@@ -568,7 +590,7 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation.
|
||||
`/workspace/xtreams/:id/downloads/:downloadId` and
|
||||
`/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide
|
||||
the workspace context panel.
|
||||
- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography
|
||||
- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The `general` section's "Window on startup" select (`Settings.startupWindowMode`: `normal` / `maximized` / `fullscreen`, Electron only, gated on `RuntimeCapabilitiesService.supportsStartupWindowMode`) is mirrored into the main-process config by the `SETTINGS_UPDATE` handler and read synchronously at the next window creation — the renderer's IndexedDB is unreachable then, so it is the same pattern as `embeddedMpvFrameCopy`; `iptvnator --fullscreen` forces one fullscreen launch without persisting it, and F11 (`WINDOW:TOGGLE_FULLSCREEN`, bound in `WorkspaceKeyboardShortcutsService`, skipped while the player owns `document.fullscreenElement`) is the exit path on Windows/Linux, where the title bar is hidden (contract: `docs/architecture/workspace-shell.md`, "Startup window mode"). The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography
|
||||
|
||||
**Service Architecture** (Factory Pattern):
|
||||
|
||||
@@ -702,6 +724,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
|
||||
- Bootstraps Electron app and initializes database
|
||||
- Registers event handlers for IPC communication
|
||||
- Creates the main window per the startup window mode (`app/app.ts` `initMainWindow`, resolver in `app/services/startup-window-mode.ts`): the electron-conf `STARTUP_WINDOW_MODE` mirror or the one-shot `--fullscreen` switch (consumed by the first window, so a window the macOS Dock re-creates in the same process follows the stored setting); `fullscreen: true` is a constructor option that Windows/Linux honour before the first paint, while macOS ignores it on a hidden window, so `ready-to-show` repeats the request after `show()` only when `isFullScreen()` is still false, through the same tracker the F11 toggle uses (`app/services/native-fullscreen-transitions.ts`: per-window fullscreen state seeded once at creation via `trackNativeFullScreen` and fed only by the enter/leave events afterwards, plus a pending record holding the latest target, cleared when an event lands on it, kept when an event lands on the other state, and ignored after 2 s; the tracker only observes and never issues a request itself, since a "repeat on mismatch" cannot be told apart from reversing the user's own green-button action — a toggle is never decided against `isFullScreen()`, which is stale mid-transition and, on Windows, even during the event), so F11 during the startup animation exits instead of re-requesting; `maximize()` waits for `ready-to-show` too (it would show a hidden window early). `attachWindowStateEvents` tracks native and HTML-element fullscreen as two flags OR-ed into `WINDOW:STATE_CHANGED`, because Electron leaves only the HTML state when the window was already natively fullscreen
|
||||
- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. The guard also forwards that launch's argv and working directory, so `iptvnator playlist.m3u` against a running app opens the playlist instead of being discarded.
|
||||
|
||||
**Database**:
|
||||
@@ -737,7 +760,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- `database.events.ts` - Database CRUD operations
|
||||
- `playlist.events.ts` - Playlist import/update
|
||||
- `playlist-open.events.ts` - Playlist files handed over by the OS (argv, file association, macOS `open-file`); the queue itself lives in `services/playlist-open-request.ts`
|
||||
- `epg.events.ts` - EPG IPC registration; freshness/fetch orchestration lives in `epg-fetch.service.ts`, manual channel-mapping resolution and CRUD in `epg-mapping.service.ts`, worker lifecycle in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts`
|
||||
- `epg.events.ts` - EPG IPC registration; freshness/fetch orchestration lives in `epg-fetch.service.ts`, manual channel-mapping resolution and CRUD in `epg-mapping.service.ts`, source orchestration in `epg-worker.service.ts`, per-import lifecycle in `epg-fetch-operation.ts`, worker bootstrap/shutdown and clear protocol in `epg-worker-runtime.ts`, DB lookups in `epg-query.service.ts`
|
||||
- `xtream.events.ts` - Xtream Codes API
|
||||
- `stalker.events.ts` - Stalker portal API
|
||||
- `connectivity-guard.events.ts` - `CONNECTIVITY_GUARD_RESET`: forgets the connection failures recorded for a portal host. Both portal handlers above run every request through the per-host circuit breaker (rules in `@iptvnator/shared/host-health`, process-wide instance in `util/host-connectivity-guard.ts`; the web backend runs the same breaker over its proxy routes) — after 2 consecutive connection-level failures (no HTTP response; `ETIMEDOUT`/`ENOTFOUND`/`ECONNREFUSED`/… but never `ECONNRESET`) requests to that endpoint fail immediately for 30 s. The key is `URL.origin`, not `URL.host`, which would give `http://panel` and `https://panel` one shared record and let a dead TLS listener fast-fail the working HTTP one instead of hanging the full 30 s/15 s axios timeout again, with one half-open trial request afterwards. Any HTTP response (4xx and 5xx included) clears the record. The refusal is a real `Error` whose wording is a renderer contract (`buildHostConnectivityFastFailMessage` in `libs/shared/interfaces`): it must carry no `HTTP Error <code>`, no timeout wording and none of the auth phrases, or Stalker endpoint discovery misclassifies it and lazy portal repair fires against a host just declared dead. Discovery probes are exempt via the `skipConnectionGuard` payload flag (bypass + no failure counting, but successes still clear the record). Every user-driven retry/refresh that issues portal requests must reset BEFORE its first request, or the affordance fast-fails and looks broken; automatic and first-load paths deliberately do not reset. Current senders: Xtream content-gate Retry, Stalker catalog append retry (`retryContentPage`), Stalker search-page retry, `StalkerItvCacheService.refresh()` (Live TV refresh), both account-info dialogs' Retry, the destructive Xtream refresh (`XtreamRefreshFlowService`, before it deletes the cached catalog — one flow shared by both entry points, `PlaylistRefreshActionService.refreshXtream()` and `RecentPlaylistsComponent.refreshXtreamPlaylist()`, which supply only a progress reporter), `StalkerPortalDiscoveryService.discover()`, and `PortalStatusService` on `skipCache`. Kill switch: `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1`. Contract: `docs/architecture/host-connectivity-guard.md`
|
||||
@@ -748,11 +771,30 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
**Workers** (`apps/electron-backend/src/app/workers/`):
|
||||
|
||||
- EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts`
|
||||
- Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`)
|
||||
- Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`). Catalog deletes and inserts commit in row-budgeted transactions of ~5,000 rows (`database/operations/catalog-deletion.ts`: per-category row counts → category groups → set-based `DELETE`s scoped to the captured category ids, never playlist-wide, since the worker interleaves requests between commits and a newer import's categories must survive an older refresh; never 100-row autocommit batches, which flush FTS5 segments and re-append index pages to the WAL on every commit, and never one giant transaction, which would starve the main-process and EPG-worker connections past their 5 s `busy_timeout`). Progress events are throttled to one per 100 ms per operation with summed `increment`s (`operation-progress-throttle.ts`); phase starts, totals reached and terminal events are never held back
|
||||
- Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`)
|
||||
|
||||
### Xtream Category Management
|
||||
|
||||
The Electron Live TV, Movies, and Series category dialog applies Select/Deselect
|
||||
to search results while a filter is active and to the whole type otherwise.
|
||||
Button states use the matching group; "Total selected" counts the whole catalog.
|
||||
Save persists the complete draft, Close discards it, and refresh restores hidden
|
||||
categories by provider ID and type. See `docs/architecture/category-management.md`.
|
||||
|
||||
### Key Features
|
||||
|
||||
#### M3U URL User-Agent
|
||||
|
||||
- The URL import form accepts an optional User-Agent and stores it as
|
||||
`Playlist.userAgent`. Electron sends it on initial download, manual refresh,
|
||||
and startup auto-update. The self-hosted PWA sends it through the registered
|
||||
target `/parse` backend proxy for import and refresh; a matching backend is
|
||||
required, and browser playback-header restrictions still apply.
|
||||
- Reuse the existing source editor and channel-over-playlist playback header
|
||||
precedence. Contract: `docs/architecture/m3u-playlist-module.md`
|
||||
("User-Agent for URL sources").
|
||||
|
||||
**Playlist Support**:
|
||||
|
||||
- M3U/M3U8 files (local or URL)
|
||||
@@ -817,7 +859,21 @@ app as a real argument, so it is not an option.
|
||||
|
||||
**Video Players**:
|
||||
|
||||
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
|
||||
- The Embedded MPV native-view dock follows app theme tokens as a solid app
|
||||
surface, including Material icon-button disabled states. Over-video loading,
|
||||
stalled and feedback overlays keep a paired light-on-dark palette. Video
|
||||
viewports remain black in windowed and fullscreen modes. Shared EPG panels
|
||||
use the library-local app-token palette in `libs/ui/epg/src/lib/_epg-theme.scss`.
|
||||
Theme/contrast contract: `docs/architecture/iptvnator-ui-guidelines.md`.
|
||||
|
||||
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer. The HTML5
|
||||
player and ArtPlayer pick their source engine from one URL rule,
|
||||
`resolvePlaybackUrlSourceKind()` in `libs/playback/util` (`mpd` → Shaka,
|
||||
`m3u8`/`m3u` → hls.js, `ts`/`m2ts`/extension-less → mpegts.js, every other
|
||||
container → native `<video>`), so hls.js never receives an `.mkv`/`.webm`
|
||||
file. The HTML5 native `<source>` carries a `video/mp4` hint only for
|
||||
MP4-family files (`resolveNativeSourceMimeType`); a hint `canPlayType()`
|
||||
rejects would make the browser skip the source.
|
||||
- mpegts.js `1.8.1` errors from all three built-in players cross one
|
||||
version-locked structured evidence boundary in `libs/playback/util`. It
|
||||
retains only exact public type/detail pairs, pair-derived stage/failure,
|
||||
@@ -991,7 +1047,7 @@ app as a real argument, so it is not an option.
|
||||
API. Radio's `<audio>` deliberately never blocks display sleep. Embedded
|
||||
MPV holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC
|
||||
inhibit the screensaver themselves.
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes and polls (500 ms, drift-gated) for position-only layout shifts that `ResizeObserver` cannot observe. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. Two per-session knobs are captured at session creation from the main-process settings mirror (`readEmbeddedMpvSessionOptions()`, read in the `EMBEDDED_MPV_CREATE_SESSION` handler — never inside the service, whose specs would otherwise construct electron-conf): `Settings.embeddedMpvExtraOptions` (free-form `key=value` libmpv lines, forbidden embed-critical keys refused by the form, network defaults `network-timeout=10` + ffmpeg `reconnect` prepended, applied on every engine after its built-ins — Windows/macOS via `mpv_set_option_string`, Linux through a user-only `--include` config file, frame-copy as the helper's first stdin line — never on a command line, where `ps` could read a credential-bearing header option) and `Settings.embeddedMpvAutoReconnect` (default on: `EmbeddedMpvReconnectCoordinator` in `embedded-mpv-reconnect.ts` reloads the last playback on `error`, or `ended` for live, only if it had played, with 2 s→30 s backoff, six attempts per outage, budget reset after 30 s stable playing, cancelled by user loads/pause/dispose; a recording running at the drop is finalized as an interrupted partial when the reload actually replaces the stream (a stream that recovers on its own keeps recording) and restarted into a new file once that reload plays, and an external subtitle file added through `sub-add` is re-added once the reload plays unless the user picked another track or loaded something else since; the renderer only displays `EmbeddedMpvSession.reconnect`). Contract: `docs/architecture/embedded-mpv-native.md` ("Session Options", "Network Auto-Reconnect"). macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes and polls (500 ms, drift-gated) for position-only layout shifts that `ResizeObserver` cannot observe. Arrow-key and ±10 s button steps go through the relative `seekEmbeddedMpvBy` IPC (mpv `seek <delta> relative+exact`; addon export `seekBy`, helper stdin command `seek-by`, Linux JSON IPC), never an absolute target computed from the renderer's whole-second, 500 ms-polled `positionSeconds` — that stale base collapsed rapid presses onto one target (about 1 s of progress per press); only the timeline scrub commits an absolute `seek`. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux
|
||||
x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy
|
||||
engine` (restart required) or
|
||||
@@ -1124,13 +1180,13 @@ engine` (restart required) or
|
||||
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
|
||||
contracts: `docs/architecture/embedded-mpv-native.md` and
|
||||
`tools/embedded-mpv/README.md`.
|
||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync — its owner is the `app-web-player-view` host (`WebPlayerViewComponent.fullscreenSurface`, passed to every engine as `fullscreenTarget`), not the engine shell, because the view remounts the engine component per playback application and the Fullscreen API exits when its element leaves the document; that is what keeps fullscreen across episode/channel/alternative-source switches (the vendor-chrome opt-out still loses it) — and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guardLine truncated
|
||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Only keyboard-originated focus pins the bar open: Chromium also focuses a clicked `<button>`, so `ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent `pointerdown` inside the focused element and such focus reveals without blocking auto-hide (otherwise the fullscreen button left the controls on screen until a click-to-pause on the viewport); the press record is discarded on the first bar focus event or any `keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a `keydown` bubbling out of a bar control re-pins it, since operating a focused control produces no focus event. A completed pointer click then releases the focus it left on the control (`onBarClick` → `ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`: non-empty click `pointerType`, else a recent press inside the clicked element): a focused control captures the keyboard — Space and Enter re-activated the clicked button and `ControlsShortcuts` yields to any interactive element in the key's path, so after a click on fullscreen Space left fullscreen instead of pausing. Keyboard activation (empty `pointerType`) keeps focus, only buttons and range sliders are released, Chromium keeps its sequential-focus starting point at the blurred control so Tab continues from it, and the volume popover ignores the release's `focusout` (`wasPointerFocusRelease`). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the Line truncated
|
||||
- Shared web picture-in-picture stays inside that default-on rollout.
|
||||
`PlayerController` exposes capability `pictureInPicture`, state
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
`togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard
|
||||
element PiP from the adapter's attached video; shared ArtPlayer keeps vendor
|
||||
`pip: false`, while preference-off native/vendor paths remain unchanged. The
|
||||
`pip: false`, while preference-off native/vendor controls keep their own UI. The
|
||||
capability-gated button sits before fullscreen and uses active enter/exit
|
||||
semantics; entry is disabled until metadata, and the action is disabled while
|
||||
an operation is pending. Embedded MPV reports capability/state false with a
|
||||
@@ -1143,7 +1199,14 @@ engine` (restart required) or
|
||||
serialized, and binding generation plus exact video identity protects
|
||||
replacement and teardown from stale completion. Video.js Tech reset and
|
||||
ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a
|
||||
retained target preserve PiP.
|
||||
retained target preserve PiP. Legacy HTML5/ArtPlayer teardown and Video.js
|
||||
Tech replacement also release exact-owned PiP through
|
||||
`web-video-picture-in-picture-lifecycle.ts`, independent of the controls
|
||||
preference. A one-shot listener on the retired video closes late native/vendor
|
||||
entries without retaining the host or touching another video's PiP. Legacy
|
||||
WebKit presentation-mode PiP also returns the retired video to inline; its
|
||||
presentation-change listener ignores fullscreen/inline events until a late
|
||||
PiP entry consumes it.
|
||||
Standard PiP shows the browser/OS video surface without Angular control
|
||||
chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
|
||||
keyboard shortcut, and Embedded MPV popup/native support are out of scope.
|
||||
@@ -1374,7 +1437,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
|
||||
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
|
||||
- Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse `get_vod_info` data keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve from `movie_data` or the catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the normal SQLite route category through all persisted categories, including hidden ones, while also accepting the provider `xtream_id` carried by cross-portal Similar links; ambiguous numeric matches keep local-id precedence, deduplicate provider candidates, and try the next candidate when the exact VOD is absent. PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, preserves concurrent metadata enrichment, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back through `info`, `movie_data`, then catalog fields.
|
||||
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
|
||||
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Lazy VOD episode tracking IDs scope the parent series, provider episode, season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match any stored season/episode coordinates. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite.
|
||||
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Single-season title markers correct both displayed and playback season coordinates; lazy VOD retains the original provider season key/number for stable IDs and old progress. Lazy VOD episode tracking IDs scope the parent series, provider episode, original season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match the episode and either its resolved or retained original provider season. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite. Season resources ignore metadata-only selection patches, and episode responses belong to the exact loading VM so navigation cannot mix episode lists.
|
||||
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
|
||||
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker)
|
||||
- The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. A series-level counterpart sits in a `⋮` menu at the end of the header row (`SeasonWatchPresenter` owns both scopes' state math; `buildSeriesWatchToggleRequest` flattens every loaded season; the direction is always the one the label advertised). It reuses the same host machinery per portal (Xtream: scope-parameterized `SerialDetailsSeasonWatchService`; Stalker: shared `runWatchToggleBatch` core). Stalker lazy-VOD hydrates unloaded seasons sequentially first (abort with zero writes on a failed fetch; a well-formed EMPTY portal answer marks the season loaded-and-empty via `VodSeriesSeasonVm.episodesLoaded` rather than eternally pending, while `fetchVodSeriesEpisodes` rejects malformed envelopes and answers without recognizable episodes; `loadEpisodesForSeason` is single-flight per season so concurrent callers join one request), re-runs the position reconcile synchronously so hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction — the `hasUnloadedSeasons` container input blocks the unwatch verdict and the count label until everything is loaded. Contract: `docs/architecture/embedded-inline-playback.md`
|
||||
@@ -1410,6 +1473,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
|
||||
- XMLTV format support
|
||||
- Background parsing in worker thread
|
||||
- Stored in database for quick lookup
|
||||
- Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and multi-EPG overlay read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset")
|
||||
- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/`
|
||||
|
||||
**TMDB Metadata Enrichment** (opt-in):
|
||||
@@ -1607,3 +1671,57 @@ No formal migration system yet. Schema changes are applied via raw SQL in the `c
|
||||
- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax
|
||||
|
||||
<!-- nx configuration end-->
|
||||
|
||||
## XMLTV Source Removal
|
||||
|
||||
Saving Settings → EPG reconciles cached XMLTV with committed global URLs and
|
||||
all enabled M3U playlist sources. Startup runs the same reconciliation after
|
||||
settings load and playlist migration. Ordinary saves skip unchanged normalized
|
||||
source sets; an explicitly edited EPG field can retry a failed cleanup.
|
||||
A cleanup failure after persistence still mirrors committed settings to Electron;
|
||||
the form stays dirty for retry. Failed storage writes never mirror to main.
|
||||
Failed settings reads and incomplete playlist migration never authorize pruning.
|
||||
Removed sources retire queued/running imports and dismiss retained error rows
|
||||
before worker-owned deletion. Retry waits for reconciliation and rechecks its
|
||||
error row, including after trust-setting writes. Shared channel IDs survive while
|
||||
another source has programmes or per-source channel metadata. The additive
|
||||
`epg_channel_sources` table preserves each imported source's name, logo, URL and
|
||||
timestamp plus transaction-ordered `write_order`, so removal restores the latest
|
||||
surviving snapshot even when import timestamps tie; ambiguous legacy metadata
|
||||
falls back to the XMLTV ID until reimport. Manual mappings remain user preferences, but no
|
||||
longer resolve deleted data. Renderer lookup generations, Xtream previews and Stalker mapping-cache
|
||||
invalidation prevent late results from restoring removed programmes. Provider
|
||||
EPG is independent. See `docs/architecture/m3u-playlist-module.md`
|
||||
("XMLTV source lifecycle").
|
||||
|
||||
## Portal Connectivity Preference
|
||||
|
||||
- Half-open trial slots follow the complete request lifetime with no elapsed-time
|
||||
expiry. All four Electron/web-backend portal handlers release in `finally`,
|
||||
independently of outcome reporting; cleanup preserves trial/epoch ownership
|
||||
and works while the environment override is disabled. Contract:
|
||||
`docs/architecture/host-connectivity-guard.md` ("Trial ownership follows the
|
||||
request lifetime").
|
||||
- Desktop Settings > General > Portal connections exposes default-on
|
||||
`Settings.portalConnectivityGuard`. Only explicit false opts out. Save mirrors
|
||||
the value to Electron `PORTAL_CONNECTIVITY_GUARD` and applies it without restart;
|
||||
settings bootstrap restores it before the renderer loads. It controls Xtream
|
||||
and Stalker together. Preference transitions clear cooldowns and invalidate old
|
||||
request completions; unchanged saves preserve evidence. The environment switch
|
||||
`IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` remains authoritative. PWA clients do not
|
||||
control the shared backend's guard.
|
||||
- Both account-info dialogs explain guard refusals with localized paused-request
|
||||
copy and Retry now; Stalker preserves cached account data on a failed refresh.
|
||||
Contract: `docs/architecture/host-connectivity-guard.md`.
|
||||
|
||||
## Channel and Detail Keyboard Scrolling
|
||||
|
||||
Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection
|
||||
focuses the viewport, native scrolling survives virtual row recycling, and
|
||||
row Enter/Space activation stays separate from focus movement. Portal Live TV
|
||||
uses ArrowRight from the selected category and ArrowLeft from the channels
|
||||
pane to move between columns. Shared live sidebars reserve scrollbar space
|
||||
beside the resize handle. `PortalDetailShellComponent` owns a visible native
|
||||
scrollbar and guarded initial page focus. Contracts:
|
||||
`docs/architecture/iptvnator-ui-guidelines.md` and
|
||||
`docs/architecture/portal-detail-navigation.md`.
|
||||
@@ -79,6 +79,7 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list.
|
||||
| Global | `Ctrl/Cmd+F` | Open global search in the desktop app |
|
||||
| Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app |
|
||||
| Global | `Enter` in workspace search | Submit the current search |
|
||||
| Global | `F11` | Toggle app window fullscreen in the desktop app |
|
||||
| Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar |
|
||||
| Navigation | `0-9` | Select an M3U channel by number |
|
||||
| Playback | `Space` / `K` | Play or pause playback |
|
||||
@@ -90,6 +91,11 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list.
|
||||
| Dialogs and lists | `Enter` | Run the selected command or open a focused item |
|
||||
| Dialogs and lists | `Escape` | Close dialogs and dismiss overlays |
|
||||
|
||||
The desktop app can also open at its last size, maximized, or fullscreen on
|
||||
every launch (Settings → General → "Window on startup"), and `iptvnator
|
||||
--fullscreen` forces a single fullscreen launch for TV or HTPC autostart
|
||||
scripts without changing that setting.
|
||||
|
||||
## Screenshots:
|
||||
|
||||
| Dashboard with recently watched content | Live channels with inline player and EPG |
|
||||
|
||||
@@ -16,6 +16,111 @@ import {
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
test.describe('Electron Xtream Category Management', () => {
|
||||
for (const section of ['Live TV', 'Movies', 'Series']) {
|
||||
test(`bulk edits only filtered ${section} categories and saves or discards the draft`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: `Filtered ${section}`,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, section);
|
||||
let dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
const rows = dialog.locator('.category-item');
|
||||
const names = (
|
||||
await rows.locator('.category-name').allTextContents()
|
||||
).map((name) => name.trim());
|
||||
// The default mock catalog has several matches and nonmatches in every type.
|
||||
const matched = names.filter((name) =>
|
||||
name.toLowerCase().includes('a')
|
||||
);
|
||||
const outside = names.filter(
|
||||
(name) => !name.toLowerCase().includes('a')
|
||||
);
|
||||
expect(matched.length).toBeGreaterThan(1);
|
||||
expect(outside.length).toBeGreaterThan(1);
|
||||
const checkbox = (name: string) =>
|
||||
dialog.getByRole('checkbox', { name, exact: true });
|
||||
await checkbox(outside[0]).uncheck();
|
||||
await checkbox(matched[0]).uncheck();
|
||||
const search = () => dialog.locator('input[type="search"]');
|
||||
await search().fill('a');
|
||||
const select = () =>
|
||||
dialog.getByRole('button', {
|
||||
name: 'Select Filtered',
|
||||
exact: true,
|
||||
});
|
||||
const deselect = () =>
|
||||
dialog.getByRole('button', {
|
||||
name: 'Deselect Filtered',
|
||||
exact: true,
|
||||
});
|
||||
await expect(rows).toHaveCount(matched.length);
|
||||
await expect(select()).toBeEnabled();
|
||||
await expect(deselect()).toBeEnabled();
|
||||
await select().click();
|
||||
await expect(select()).toBeDisabled();
|
||||
await expect(dialog.locator('.selection-info')).toHaveText(
|
||||
`Total selected: ${names.length - 1} / ${names.length}`
|
||||
);
|
||||
await deselect().click();
|
||||
await expect(deselect()).toBeDisabled();
|
||||
await expect(dialog.locator('.selection-info')).toHaveText(
|
||||
`Total selected: ${outside.length - 1} / ${names.length}`
|
||||
);
|
||||
await search().fill('no matching mock category');
|
||||
await expect(select()).toBeDisabled();
|
||||
await expect(deselect()).toBeDisabled();
|
||||
await expect(dialog.locator('.empty-message')).toHaveText(
|
||||
'No matching categories found'
|
||||
);
|
||||
await search().fill('');
|
||||
await expect(checkbox(outside[0])).not.toBeChecked();
|
||||
await expect(checkbox(outside[1])).toBeChecked();
|
||||
for (const name of matched)
|
||||
await expect(checkbox(name)).not.toBeChecked();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save', exact: true })
|
||||
.click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
await expect(checkbox(outside[0])).not.toBeChecked();
|
||||
await expect(checkbox(outside[1])).toBeChecked();
|
||||
for (const name of matched)
|
||||
await expect(checkbox(name)).not.toBeChecked();
|
||||
await search().fill('A');
|
||||
await select().click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
for (const name of matched)
|
||||
await expect(checkbox(name)).not.toBeChecked();
|
||||
await search().fill('a');
|
||||
await select().click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save', exact: true })
|
||||
.click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
for (const name of matched)
|
||||
await expect(checkbox(name)).toBeChecked();
|
||||
await expect(checkbox(outside[0])).not.toBeChecked();
|
||||
await expect(checkbox(outside[1])).toBeChecked();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('hides and restores categories, supports search and bulk actions, and persists hidden selections after refresh', async ({
|
||||
dataDir,
|
||||
request,
|
||||
|
||||
@@ -10,12 +10,16 @@ import {
|
||||
channelItemByTitle,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
LaunchedElectronApp,
|
||||
openAddPlaylistDialog,
|
||||
openSourceEditor,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
openSources,
|
||||
saveSourceDialog,
|
||||
saveSettings,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
updateSourceDialog,
|
||||
@@ -375,6 +379,123 @@ async function releasePlaybackRecommendationCapture(
|
||||
});
|
||||
}
|
||||
|
||||
for (const player of ['mpv', 'vlc']) {
|
||||
test(`@electron @dash fullscreen panel keeps DASH inline with ${player} configured`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const fixtureServer = await startDashFixtureServer();
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
try {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'playback');
|
||||
await page.getByTestId('select-video-player').click();
|
||||
await page.getByTestId(player).click();
|
||||
await saveSettings(page);
|
||||
await goToDashboard(page);
|
||||
const playlist = [
|
||||
buildDashPlaylist(fixtureServer.origin),
|
||||
'#EXTINF:-1 group-title="DASH",Next ClearKey DASH',
|
||||
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
|
||||
`#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_KID}:${CLEARKEY_KEY}`,
|
||||
`${fixtureServer.origin}/clearkey.mpd?next=1`,
|
||||
].join('\n');
|
||||
await importDashPlaylistFromText(app, playlist);
|
||||
await channelItemByTitle(page, 'ClearKey DASH').first().click();
|
||||
const video = page.locator('app-web-player-view video').first();
|
||||
await expect(video).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
video.evaluate((el: HTMLVideoElement) => el.currentTime)
|
||||
)
|
||||
.toBeGreaterThan(0.5);
|
||||
await page
|
||||
.getByRole('button', { name: 'Enter fullscreen' })
|
||||
.click();
|
||||
const isFullscreen = () =>
|
||||
page.evaluate(() => document.fullscreenElement !== null);
|
||||
await expect.poll(isFullscreen).toBe(true);
|
||||
await page.getByTestId('fullscreen-channel-panel-hot-zone').hover();
|
||||
const panel = page.getByTestId('fullscreen-channel-panel');
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(panel).toHaveCSS(
|
||||
'transform',
|
||||
'matrix(1, 0, 0, 1, 0, 0)'
|
||||
);
|
||||
await expect(
|
||||
panel.getByText('Unsupported MKV', { exact: false })
|
||||
).toHaveCount(0);
|
||||
const nextChannel = panel
|
||||
.getByTestId('channel-item')
|
||||
.filter({ hasText: 'Next ClearKey DASH' });
|
||||
await nextChannel.click();
|
||||
await expect(nextChannel).toHaveClass(/\bactive\b/);
|
||||
await expect.poll(isFullscreen).toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
video.evaluate((el: HTMLVideoElement) => el.currentTime)
|
||||
)
|
||||
.toBeGreaterThan(0.5);
|
||||
expect(await isFullscreen()).toBe(true);
|
||||
|
||||
// A short context menu has no overflow, but still owns paging
|
||||
// keys. Observe the event after the app's document listener.
|
||||
const viewportHeight = await page.evaluate(
|
||||
() => window.innerHeight
|
||||
);
|
||||
await page.mouse.move(1, viewportHeight / 2);
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(panel).toHaveCSS(
|
||||
'transform',
|
||||
'matrix(1, 0, 0, 1, 0, 0)'
|
||||
);
|
||||
await nextChannel.click({ button: 'right' });
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
const pagingConsumed = page.evaluate(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
(event) => resolve(event.defaultPrevented),
|
||||
{ once: true }
|
||||
);
|
||||
})
|
||||
);
|
||||
await menu.getByRole('menuitem').first().press('PageDown');
|
||||
expect(await pagingConsumed).toBe(false);
|
||||
await expect(nextChannel).toHaveClass(/\bactive\b/);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(menu).toBeHidden();
|
||||
await page.mouse.move(600, viewportHeight / 2);
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'true');
|
||||
// Original channel 3 is the excluded MKV. Numeric selection
|
||||
// must preserve fullscreen without renumbering the catalog.
|
||||
await page.keyboard.press('3');
|
||||
await expect(page.locator('.channel-number-overlay')).toHaveCount(
|
||||
1
|
||||
);
|
||||
await expect(page.locator('.channel-number-overlay')).toHaveCount(
|
||||
0
|
||||
);
|
||||
expect(await isFullscreen()).toBe(true);
|
||||
await expect(nextChannel).toHaveClass(/\bactive\b/);
|
||||
|
||||
await page.keyboard.press('PageDown');
|
||||
const firstChannel = panel.getByTestId('channel-item').filter({
|
||||
hasText: 'ClearKey DASH',
|
||||
hasNotText: 'Next ClearKey DASH',
|
||||
});
|
||||
await expect(firstChannel).toHaveClass(/\bactive\b/);
|
||||
expect(await isFullscreen()).toBe(true);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await fixtureServer.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('@electron @dash ClearKey DASH filters DRM fallback and reports external launch states', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
|
||||
@@ -1079,7 +1079,8 @@ export async function restartElectronApp(
|
||||
|
||||
export async function importM3uPlaylistFromUrl(
|
||||
page: Page,
|
||||
playlistUrl: string
|
||||
playlistUrl: string,
|
||||
userAgent?: string
|
||||
): Promise<void> {
|
||||
await openAddPlaylistDialog(page);
|
||||
const dialog = await getActiveDialog(page);
|
||||
@@ -1094,6 +1095,9 @@ export async function importM3uPlaylistFromUrl(
|
||||
dialog.locator('input[formcontrolname="playlistUrl"]'),
|
||||
playlistUrl
|
||||
);
|
||||
if (userAgent !== undefined) {
|
||||
await setInputValue(dialog.getByRole('textbox', { name: 'User agent', exact: true }), userAgent);
|
||||
}
|
||||
await dialog.getByRole('button', { name: /Add playlist/i }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
}
|
||||
@@ -1186,6 +1190,7 @@ export async function createMutableTextServer(
|
||||
options: {
|
||||
contentType?: string;
|
||||
resourcePath?: string;
|
||||
requiredUserAgent?: string;
|
||||
} = {}
|
||||
): Promise<MutableTextServer> {
|
||||
const {
|
||||
@@ -1205,6 +1210,12 @@ export async function createMutableTextServer(
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.requiredUserAgent && req.headers['user-agent'] !== options.requiredUserAgent) {
|
||||
res.writeHead(403);
|
||||
res.end('User-Agent required');
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(body);
|
||||
});
|
||||
|
||||
@@ -27,11 +27,19 @@ export type LocalMediaServer = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
function createTwoSecondY4mFixture(): Buffer {
|
||||
/**
|
||||
* Length of the generated Y4M clip. Long enough for the relative-seek burst
|
||||
* in the packaged smoke (three +2 s steps land at 6 s) plus the playing
|
||||
* section that follows it without hitting EOF; at 64x36 / 10 fps this is
|
||||
* still well under half a megabyte.
|
||||
*/
|
||||
export const Y4M_FIXTURE_DURATION_SECONDS = 12;
|
||||
|
||||
function createY4mFixture(): Buffer {
|
||||
const width = 64;
|
||||
const height = 36;
|
||||
const framesPerSecond = 10;
|
||||
const frameCount = framesPerSecond * 2;
|
||||
const frameCount = framesPerSecond * Y4M_FIXTURE_DURATION_SECONDS;
|
||||
const yPlaneBytes = width * height;
|
||||
const chromaPlaneBytes = (width / 2) * (height / 2);
|
||||
const chunks: Buffer[] = [
|
||||
@@ -84,7 +92,7 @@ async function closeServer(server: Server): Promise<void> {
|
||||
}
|
||||
|
||||
export async function createLocalMediaServer(): Promise<LocalMediaServer> {
|
||||
const body = createTwoSecondY4mFixture();
|
||||
const body = createY4mFixture();
|
||||
const resourcePath = '/embedded-mpv-frame-copy-smoke.y4m';
|
||||
const server = createServer((request, response) => {
|
||||
const pathname = (request.url ?? '').split('?')[0];
|
||||
|
||||
@@ -134,7 +134,7 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
|
||||
await window.electron.setEmbeddedMpvPaused(sessionId, true);
|
||||
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
|
||||
streamUrl,
|
||||
title: 'Two-second generated Y4M fixture',
|
||||
title: 'Generated Y4M fixture',
|
||||
isLive: false,
|
||||
});
|
||||
},
|
||||
@@ -180,6 +180,59 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Relative seek steps (arrow keys, ±10 s buttons) must reach mpv
|
||||
// as `seek <delta> relative+exact` through the real preload →
|
||||
// main-process → helper `seek-by` path, and a burst issued without
|
||||
// waiting for snapshots has to accumulate. Absolute targets
|
||||
// computed from the stale renderer snapshot collapsed such bursts
|
||||
// onto one target (about one second of progress per press).
|
||||
await launchedFrameCopyApp.mainWindow.evaluate(
|
||||
async (sessionId) => {
|
||||
const seekBy = window.electron.seekEmbeddedMpvBy;
|
||||
if (!seekBy) {
|
||||
throw new Error(
|
||||
'seekEmbeddedMpvBy is missing from the preload bridge'
|
||||
);
|
||||
}
|
||||
await Promise.all([
|
||||
seekBy(sessionId, 2),
|
||||
seekBy(sessionId, 2),
|
||||
seekBy(sessionId, 2),
|
||||
]);
|
||||
},
|
||||
created.id
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await getLatestSession(
|
||||
launchedFrameCopyApp,
|
||||
created.id
|
||||
)
|
||||
)?.positionSeconds,
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.toBe(6);
|
||||
// Stepping back past the start clamps at zero instead of failing.
|
||||
await launchedFrameCopyApp.mainWindow.evaluate(
|
||||
(sessionId) =>
|
||||
window.electron.seekEmbeddedMpvBy?.(sessionId, -60),
|
||||
created.id
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await getLatestSession(
|
||||
launchedFrameCopyApp,
|
||||
created.id
|
||||
)
|
||||
)?.positionSeconds,
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.toBe(0);
|
||||
|
||||
await launchedFrameCopyApp.mainWindow.evaluate(
|
||||
(sessionId) =>
|
||||
window.electron.setEmbeddedMpvPaused(sessionId, false),
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import {
|
||||
addXtreamPortal,
|
||||
addStalkerPortal,
|
||||
waitForStalkerCatalog,
|
||||
channelItemByTitle,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamEpgFixture } from './portal-mock-fixtures';
|
||||
import {
|
||||
fetchXtreamEpgFixture,
|
||||
fetchStalkerCategoryFixture,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
import {
|
||||
applyTheme,
|
||||
expectTextContrast,
|
||||
expectThemeSurface,
|
||||
expectSkeletonContrast,
|
||||
} from './theme-contrast';
|
||||
|
||||
const epgCredentials = {
|
||||
username: 'epg',
|
||||
@@ -32,6 +47,13 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
|
||||
const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } });
|
||||
|
||||
try {
|
||||
await app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
BrowserWindow.getAllWindows()[0].setSize(1800, 1000);
|
||||
});
|
||||
// No external demo stream is needed for guide interaction/contrast.
|
||||
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
|
||||
// Keep the external demo request pending; guide data is local.
|
||||
});
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Xtream Timeline Interaction',
|
||||
username: epgCredentials.username,
|
||||
@@ -76,6 +98,40 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
|
||||
const maxZoomWidth = await blockWidthAt('max');
|
||||
expect(maxZoomWidth).toBeGreaterThan(minZoomWidth);
|
||||
|
||||
for (const theme of ['light', 'dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
await expectThemeSurface(timeline, theme);
|
||||
await expectTextContrast(
|
||||
timeline.locator('.epg-timeline__heading b')
|
||||
);
|
||||
await expectTextContrast(
|
||||
nowBlock.locator('.epg-timeline__block-title')
|
||||
);
|
||||
await expectTextContrast(
|
||||
nowBlock.locator('.epg-timeline__block-time')
|
||||
);
|
||||
// Exercise the worst-case overlapping current/playing tints,
|
||||
// including the sibling progress fill behind the small live label.
|
||||
for (const playing of [false, true]) {
|
||||
await nowBlock.evaluate((element, active) => {
|
||||
element.classList.toggle('is-playing', active);
|
||||
}, playing);
|
||||
await expectTextContrast(
|
||||
nowBlock.locator('.epg-timeline__tag.is-now'),
|
||||
4.5,
|
||||
'.epg-timeline__fill--live'
|
||||
);
|
||||
}
|
||||
await nowBlock.evaluate((element) =>
|
||||
element.classList.remove('is-playing')
|
||||
);
|
||||
await nowBlock.hover();
|
||||
await expectTextContrast(
|
||||
nowBlock.locator('.epg-timeline__info'),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
// At max zoom the on-air block is wide enough to expose the info
|
||||
// affordance (hidden on narrow/micro tiers), which opens the shared
|
||||
// programme-details dialog with the programme metadata.
|
||||
@@ -87,14 +143,113 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
|
||||
currentProgram.title
|
||||
);
|
||||
// An on-air programme offers "watch live" as the primary action.
|
||||
await expect(
|
||||
dialog.locator('.epg-dialog__btn--primary')
|
||||
).toBeVisible();
|
||||
await expect(dialog.locator('.epg-dialog__btn--primary')).toBeVisible();
|
||||
|
||||
for (const theme of ['light', 'dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
await expectThemeSurface(dialog, theme);
|
||||
await expectTextContrast(dialog.locator('.epg-dialog__title'));
|
||||
await expectTextContrast(dialog.locator('.epg-dialog__desc'));
|
||||
await expectTextContrast(dialog.locator('.epg-dialog__close'), 3);
|
||||
}
|
||||
await app.mainWindow.screenshot({
|
||||
path: test.info().outputPath('epg-light.png'),
|
||||
});
|
||||
await dialog.locator('.epg-dialog__close').click();
|
||||
await app.mainWindow.waitForSelector('.epg-dialog', {
|
||||
state: 'detached',
|
||||
});
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow.getByTestId('epg-view-mode-list').click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
await channelItemByTitle(app.mainWindow, fixture.stream.name ?? '')
|
||||
.first()
|
||||
.click();
|
||||
const guide = app.mainWindow.locator('app-epg-list-view');
|
||||
await expect(guide).toBeVisible();
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
await expectThemeSurface(guide, theme);
|
||||
await expectTextContrast(
|
||||
guide.locator('[data-when="now"] .time').first()
|
||||
);
|
||||
await expectTextContrast(
|
||||
guide.locator('[data-when="now"] .title').first()
|
||||
);
|
||||
await expectTextContrast(
|
||||
guide.locator('[data-when="now"] .desc').first()
|
||||
);
|
||||
}
|
||||
// Keep a fresh channel's EPG IPC pending so the real list loading
|
||||
// template stays mounted through both theme changes.
|
||||
await app.electronApp.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('XTREAM_REQUEST');
|
||||
ipcMain.handle(
|
||||
'XTREAM_REQUEST',
|
||||
() =>
|
||||
new Promise(() => {
|
||||
// Released when this isolated Electron test app closes.
|
||||
})
|
||||
);
|
||||
});
|
||||
await app.mainWindow
|
||||
.locator('[data-test-id="channel-item"]')
|
||||
.nth(1)
|
||||
.click();
|
||||
const skeleton = guide.locator('.sk-time').first();
|
||||
await expect(skeleton).toBeVisible();
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
await expectSkeletonContrast(skeleton, guide);
|
||||
await expectSkeletonContrast(
|
||||
guide.locator('.sk-title').first(),
|
||||
guide
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @stalker @theme @electron applies live themes to the shared Stalker guide', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const fixture = await fetchStalkerCategoryFixture(request, 'itv');
|
||||
const item = fixture.items[0];
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
BrowserWindow.getAllWindows()[0].setSize(1800, 1000);
|
||||
});
|
||||
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
|
||||
// Keep the external demo request pending; guide data is local.
|
||||
});
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: 'Stalker Theme Fixture',
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
await channelItemByTitle(app.mainWindow, item.o_name || item.name || '')
|
||||
.first()
|
||||
.click();
|
||||
const timeline = app.mainWindow.locator('app-epg-timeline');
|
||||
await expect(timeline).toBeVisible();
|
||||
for (const theme of ['light', 'dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
await expectThemeSurface(timeline, theme);
|
||||
await expectTextContrast(
|
||||
timeline.locator('.epg-timeline__heading b')
|
||||
);
|
||||
await app.mainWindow.screenshot({
|
||||
path: test.info().outputPath(`stalker-${theme}.png`),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
buildM3uContent,
|
||||
@@ -5,6 +7,7 @@ import {
|
||||
closeElectronApp,
|
||||
createMutableTextServer,
|
||||
expect,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromUrl,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
@@ -33,7 +36,8 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
function createCurrentXmltvFixture(
|
||||
channelId: string,
|
||||
channelName: string,
|
||||
programTitle: string
|
||||
programTitle: string,
|
||||
iconUrl?: string
|
||||
): string {
|
||||
const start = new Date(Date.now() - 15 * 60 * 1000);
|
||||
const stop = new Date(Date.now() + 45 * 60 * 1000);
|
||||
@@ -42,6 +46,7 @@ function createCurrentXmltvFixture(
|
||||
<tv>
|
||||
<channel id="${channelId}">
|
||||
<display-name>${channelName}</display-name>
|
||||
${iconUrl ? `<icon src="${iconUrl}"/>` : ''}
|
||||
</channel>
|
||||
<programme start="${formatXmltvDate(start)} +0000" stop="${formatXmltvDate(stop)} +0000" channel="${channelId}">
|
||||
<title>${programTitle}</title>
|
||||
@@ -65,7 +70,159 @@ function formatXmltvDate(date: Date): string {
|
||||
}
|
||||
|
||||
test.describe('Electron EPG', () => {
|
||||
test('@epg @electron adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', async ({
|
||||
test('@epg @electron saves unrelated settings when EPG reconciliation is unavailable', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'general');
|
||||
// After startup, make any unexpected reconciliation fail.
|
||||
await app.electronApp.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
|
||||
ipcMain.handle('EPG_RECONCILE_SOURCES', () => {
|
||||
throw new Error('Synthetic migration failure');
|
||||
});
|
||||
});
|
||||
const guard = app.mainWindow
|
||||
.getByTestId('portal-connectivity-toggle')
|
||||
.locator('input');
|
||||
await guard.uncheck();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).toBeVisible();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).not.toBeVisible();
|
||||
await goToDashboard(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('unsaved-dialog-save')
|
||||
).not.toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron mirrors saved desktop settings when source cleanup fails', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await app.electronApp.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
|
||||
ipcMain.handle('EPG_RECONCILE_SOURCES', () => {
|
||||
throw new Error('Synthetic cleanup failure');
|
||||
});
|
||||
});
|
||||
await openSettingsSection(app.mainWindow, 'general');
|
||||
await app.mainWindow
|
||||
.getByTestId('portal-connectivity-toggle')
|
||||
.locator('input')
|
||||
.uncheck();
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.fill('https://example.invalid/guide.xml');
|
||||
await app.mainWindow
|
||||
.getByTestId('save-settings')
|
||||
.click({ noWaitAfter: true });
|
||||
await expect(
|
||||
app.mainWindow.getByText(
|
||||
'Failed to clear EPG data. Please try again.',
|
||||
{ exact: true }
|
||||
)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const config = JSON.parse(
|
||||
await readFile(
|
||||
join(dataDir, 'config', 'config.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
return config.PORTAL_CONNECTIVITY_GUARD;
|
||||
})
|
||||
.toBe(false);
|
||||
// Restore cleanup so the explicit retry can finish and close normally.
|
||||
await app.electronApp.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
|
||||
ipcMain.handle('EPG_RECONCILE_SOURCES', () => ({
|
||||
success: true,
|
||||
removedUrls: [],
|
||||
}));
|
||||
});
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).not.toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron omits source secrets from main and parser-worker diagnostics', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const server = await createMutableTextServer(epgFixtureXml, {
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
resourcePath: '/epg-path-secret/guide.xml',
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const sourceUrl = `${server.resourceUrl}?custom=epg-query-secret`;
|
||||
let diagnostics = '';
|
||||
const capture = (chunk: Buffer) => {
|
||||
diagnostics += chunk.toString();
|
||||
};
|
||||
const process = app.electronApp.process();
|
||||
process.stdout?.on('data', capture);
|
||||
process.stderr?.on('data', capture);
|
||||
try {
|
||||
const imported = await app.mainWindow.evaluate(async (url) => {
|
||||
const result = await window.electron.forceFetchEpg(url);
|
||||
await window.electron.clearEpgDataForSource(url);
|
||||
return result;
|
||||
}, sourceUrl);
|
||||
expect(imported.success).toBe(true);
|
||||
await expect
|
||||
.poll(() => diagnostics)
|
||||
.toContain('EPG data cleared for source via worker');
|
||||
const failed = await app.mainWindow.evaluate(async (url) => {
|
||||
const missing = new URL(url);
|
||||
missing.pathname += '/missing';
|
||||
return window.electron.forceFetchEpg(missing.toString());
|
||||
}, sourceUrl);
|
||||
expect(failed.success).toBe(false);
|
||||
await expect
|
||||
.poll(() => diagnostics)
|
||||
.toContain('Request failed with status code 404');
|
||||
// Worker stdout can lose its final buffered line on termination;
|
||||
// the awaited import result and main-process completion are authoritative.
|
||||
expect(diagnostics).toContain('[EPG Worker] Fetching EPG source');
|
||||
expect(diagnostics).toContain('EPG parsing complete:');
|
||||
expect(diagnostics).not.toContain('epg-path-secret');
|
||||
expect(diagnostics).not.toContain('epg-query-secret');
|
||||
expect(await getEpgChannelCount(app.mainWindow)).toBe(0);
|
||||
} finally {
|
||||
process.stdout?.off('data', capture);
|
||||
process.stderr?.off('data', capture);
|
||||
await closeElectronApp(app);
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const epgServer = await createMutableTextServer(epgFixtureXml, {
|
||||
@@ -103,6 +260,8 @@ test.describe('Electron EPG', () => {
|
||||
.first()
|
||||
).toBeVisible();
|
||||
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
@@ -116,18 +275,6 @@ test.describe('Electron EPG', () => {
|
||||
// would block the close and leak the Electron process.
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Clear EPG data' })
|
||||
.click();
|
||||
const dialog = app.mainWindow.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Yes', exact: true })
|
||||
.click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => getEpgChannelCount(app.mainWindow), {
|
||||
timeout: 20000,
|
||||
@@ -139,9 +286,249 @@ test.describe('Electron EPG', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron removes a retained error row after its import worker has finished', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const server = await createMutableTextServer(epgFixtureXml, {
|
||||
contentType: 'application/xml',
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
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')
|
||||
.fill(server.resourceUrl);
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'.epg-progress-panel .import-item.status-complete'
|
||||
)
|
||||
).toHaveCount(1, { timeout: 30000 });
|
||||
// Capture the actual generation; a force-fetch also awaits termination.
|
||||
const generation = await app.mainWindow.evaluate(async (url) => {
|
||||
await window.electron.clearEpgDataForSource(url);
|
||||
const completed = new Promise<number>((resolve) => {
|
||||
window.electron.onEpgProgress?.((progress) => {
|
||||
if (
|
||||
progress.url === url &&
|
||||
progress.status === 'complete'
|
||||
)
|
||||
resolve(progress.generation ?? 0);
|
||||
});
|
||||
});
|
||||
await window.electron.forceFetchEpg(url);
|
||||
return completed;
|
||||
}, server.resourceUrl);
|
||||
await app.electronApp.evaluate(
|
||||
({ BrowserWindow }, { url, generation }) => {
|
||||
BrowserWindow.getAllWindows()[0].webContents.send(
|
||||
'EPG_PROGRESS_UPDATE',
|
||||
{
|
||||
url,
|
||||
status: 'error',
|
||||
generation,
|
||||
errorCode: 'invalid-tls-certificate',
|
||||
error: 'Synthetic retained TLS error',
|
||||
}
|
||||
);
|
||||
},
|
||||
{ url: server.resourceUrl, generation }
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'.epg-progress-panel .import-item.status-error'
|
||||
)
|
||||
).toHaveCount(1);
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.locator('.epg-progress-panel .import-item')
|
||||
).toHaveCount(0);
|
||||
await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron removes only the saved source, preserves shared IDs and mappings, and repairs old orphan data on restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const first = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'shared-news',
|
||||
'Shared News',
|
||||
'Removed Bulletin'
|
||||
),
|
||||
{ contentType: 'application/xml', resourcePath: '/first.xml' }
|
||||
);
|
||||
const second = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'shared-news',
|
||||
'Shared News',
|
||||
'Retained Bulletin'
|
||||
),
|
||||
{ contentType: 'application/xml', resourcePath: '/second.xml' }
|
||||
);
|
||||
let app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
for (const [index, url] of [
|
||||
first.resourceUrl,
|
||||
second.resourceUrl,
|
||||
].entries()) {
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
const inputs = app.mainWindow.locator('.epg-source-row input');
|
||||
await expect(inputs).toHaveCount(index + 1);
|
||||
await inputs.nth(index).fill(url);
|
||||
}
|
||||
await saveSettings(app.mainWindow);
|
||||
const programs = () =>
|
||||
app.mainWindow.evaluate(async () =>
|
||||
(
|
||||
await window.electron.getChannelPrograms('mapped-news')
|
||||
).map((p) => p.title)
|
||||
);
|
||||
await app.mainWindow.evaluate(() =>
|
||||
window.electron.setEpgMapping('mapped-news', 'shared-news')
|
||||
);
|
||||
await expect
|
||||
.poll(programs, { timeout: 30000 })
|
||||
.toEqual(['Removed Bulletin', 'Retained Bulletin']);
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row')
|
||||
.first()
|
||||
.locator('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
// A staged removal must not delete data before Save.
|
||||
expect(await programs()).toContain('Removed Bulletin');
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect.poll(programs).toEqual(['Retained Bulletin']);
|
||||
await closeElectronApp(app);
|
||||
app = await launchElectronApp(dataDir);
|
||||
await expect.poll(programs).toEqual(['Retained Bulletin']);
|
||||
// Simulate a cache left by 0.23: import a source absent from settings.
|
||||
await app.mainWindow.evaluate(
|
||||
(url) => window.electron.forceFetchEpg(url),
|
||||
first.resourceUrl
|
||||
);
|
||||
await expect.poll(programs).toContain('Removed Bulletin');
|
||||
await closeElectronApp(app);
|
||||
app = await launchElectronApp(dataDir);
|
||||
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 saveSettings(app.mainWindow);
|
||||
await expect.poll(programs).toEqual([]);
|
||||
await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0);
|
||||
expect(
|
||||
await app.mainWindow.evaluate(() =>
|
||||
window.electron.getEpgMapping('mapped-news')
|
||||
)
|
||||
).toMatchObject({ epgChannelId: 'shared-news' });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await first.close();
|
||||
await second.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron restores the first source name and logo after removing the last metadata writer', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const first = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'shared-logo',
|
||||
'First News',
|
||||
'First Bulletin',
|
||||
'https://example.com/first.png'
|
||||
),
|
||||
{ contentType: 'application/xml', resourcePath: '/first.xml' }
|
||||
);
|
||||
const second = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'shared-logo',
|
||||
'Second News',
|
||||
'Second Bulletin',
|
||||
'https://example.com/second.png'
|
||||
),
|
||||
{ contentType: 'application/xml', resourcePath: '/second.xml' }
|
||||
);
|
||||
let app = await launchElectronApp(dataDir);
|
||||
const metadata = () =>
|
||||
app.mainWindow.evaluate(
|
||||
async () =>
|
||||
(
|
||||
await window.electron.getEpgChannelMetadata([
|
||||
'shared-logo',
|
||||
])
|
||||
)['shared-logo']
|
||||
);
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
for (const [index, source] of [first, second].entries()) {
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
const inputs = app.mainWindow.locator('.epg-source-row input');
|
||||
await expect(inputs).toHaveCount(index + 1);
|
||||
await inputs.nth(index).fill(source.resourceUrl);
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect.poll(metadata, { timeout: 30000 }).toMatchObject({
|
||||
displayName: index === 0 ? 'First News' : 'Second News',
|
||||
iconUrl: `https://example.com/${index === 0 ? 'first' : 'second'}.png`,
|
||||
});
|
||||
}
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row')
|
||||
.nth(1)
|
||||
.locator('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
const firstMetadata = {
|
||||
displayName: 'First News',
|
||||
iconUrl: 'https://example.com/first.png',
|
||||
};
|
||||
await expect.poll(metadata).toMatchObject(firstMetadata);
|
||||
await closeElectronApp(app);
|
||||
app = await launchElectronApp(dataDir);
|
||||
await expect.poll(metadata).toMatchObject(firstMetadata);
|
||||
expect(
|
||||
await app.mainWindow.evaluate(async () =>
|
||||
(
|
||||
await window.electron.getChannelPrograms('shared-logo')
|
||||
).map((p) => p.title)
|
||||
)
|
||||
).toEqual(['First Bulletin']);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await first.close();
|
||||
await second.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron imports and renders an EPG source declared by an M3U playlist header', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
const epgServer = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'playlist-guide-news',
|
||||
@@ -212,6 +599,27 @@ test.describe('Electron EPG', () => {
|
||||
'Playlist Scoped Bulletin',
|
||||
{ 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')
|
||||
.fill(epgServer.resourceUrl);
|
||||
await saveSettings(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
// The same URL still belongs to the saved M3U playlist.
|
||||
const retained = await app.mainWindow.evaluate(async () =>
|
||||
window.electron.getChannelPrograms('playlist-guide-news')
|
||||
);
|
||||
expect(retained.map((program) => program.title)).toContain(
|
||||
'Playlist Scoped Bulletin'
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await playlistServer.close();
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
-- Verbatim CREATE statements from v0.19.0 libs/shared/database/src/lib/connection.ts
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
serverUrl TEXT,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
date_created TEXT DEFAULT (datetime('now')),
|
||||
last_updated TEXT,
|
||||
type TEXT NOT NULL CHECK (type IN ('xtream', 'stalker', 'm3u-file', 'm3u-text', 'm3u-url')),
|
||||
userAgent TEXT,
|
||||
origin TEXT,
|
||||
referrer TEXT,
|
||||
filePath TEXT,
|
||||
autoRefresh INTEGER DEFAULT 0,
|
||||
macAddress TEXT,
|
||||
url TEXT,
|
||||
last_usage TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('live', 'movies', 'series')),
|
||||
xtream_id INTEGER NOT NULL,
|
||||
hidden INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS content (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
rating TEXT,
|
||||
added TEXT,
|
||||
poster_url TEXT,
|
||||
xtream_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')),
|
||||
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS recently_viewed (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_id INTEGER NOT NULL,
|
||||
playlist_id TEXT NOT NULL,
|
||||
viewed_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_id INTEGER NOT NULL,
|
||||
playlist_id TEXT NOT NULL,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_type ON content(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_title ON content(title);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at);
|
||||
CREATE TABLE IF NOT EXISTS epg_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
icon_url TEXT,
|
||||
url TEXT,
|
||||
source_url TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS epg_programs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id TEXT NOT NULL,
|
||||
start TEXT NOT NULL,
|
||||
stop TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
icon_url TEXT,
|
||||
rating TEXT,
|
||||
episode_num TEXT,
|
||||
FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channels_source ON epg_channels(source_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5(
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
content='epg_programs',
|
||||
content_rowid='id'
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_ai AFTER INSERT ON epg_programs BEGIN
|
||||
INSERT INTO epg_programs_fts(rowid, title, description, category)
|
||||
VALUES (new.id, new.title, new.description, new.category);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_ad AFTER DELETE ON epg_programs BEGIN
|
||||
INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category)
|
||||
VALUES ('delete', old.id, old.title, old.description, old.category);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_au AFTER UPDATE ON epg_programs BEGIN
|
||||
INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category)
|
||||
VALUES ('delete', old.id, old.title, old.description, old.category);
|
||||
INSERT INTO epg_programs_fts(rowid, title, description, category)
|
||||
VALUES (new.id, new.title, new.description, new.category);
|
||||
END;
|
||||
CREATE TABLE IF NOT EXISTS playback_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL,
|
||||
content_xtream_id INTEGER NOT NULL,
|
||||
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
|
||||
series_xtream_id INTEGER,
|
||||
season_number INTEGER,
|
||||
episode_number INTEGER,
|
||||
position_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
duration_seconds INTEGER,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS playback_positions_content_playlist_unique ON playback_positions(content_xtream_id, playlist_id, content_type);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_playlist_idx ON playback_positions(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_series_idx ON playback_positions(series_xtream_id);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at);
|
||||
CREATE TABLE IF NOT EXISTS downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL,
|
||||
xtream_id INTEGER NOT NULL,
|
||||
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
|
||||
series_xtream_id INTEGER,
|
||||
season_number INTEGER,
|
||||
episode_number INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
file_name TEXT,
|
||||
file_path TEXT,
|
||||
poster_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')),
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type);
|
||||
CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status);
|
||||
@@ -0,0 +1,410 @@
|
||||
import {
|
||||
_electron as electron,
|
||||
ElectronApplication,
|
||||
Page,
|
||||
} from '@playwright/test';
|
||||
import { join } from 'path';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
closeElectronApp,
|
||||
buildElectronLaunchEnvironment,
|
||||
electronMainPath,
|
||||
workspaceRoot,
|
||||
openSources,
|
||||
} from './electron-test-fixtures';
|
||||
import { seedLegacyProfile, legacyPlaylists } from './legacy-profile-fixture';
|
||||
|
||||
const migrationKey = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
const recoveryKey = 'playlists-electron-backend-profile-v1';
|
||||
|
||||
async function sql(
|
||||
app: ElectronApplication,
|
||||
dataDir: string,
|
||||
statement: string
|
||||
) {
|
||||
return app.evaluate(
|
||||
(_electron, { file, dependency, statement }) => {
|
||||
const Database = process
|
||||
.getBuiltinModule('module')
|
||||
.createRequire(dependency)(dependency),
|
||||
db = new Database(file);
|
||||
try {
|
||||
return db.prepare(statement).reader
|
||||
? db.prepare(statement).all()
|
||||
: db.prepare(statement).run();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
{
|
||||
file: join(dataDir, 'databases/iptvnator.db'),
|
||||
dependency: join(workspaceRoot, 'node_modules/better-sqlite3'),
|
||||
statement,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function sourceCount(page: Page) {
|
||||
return page.evaluate(
|
||||
async () => (await window.electron.dbGetAppPlaylists()).length
|
||||
);
|
||||
}
|
||||
|
||||
async function launchWithRecoveryChoice(
|
||||
dataDir: string,
|
||||
response: number,
|
||||
retry = false
|
||||
) {
|
||||
const wrapper = join(dataDir, 'recovery-launch.cjs');
|
||||
await writeFile(
|
||||
wrapper,
|
||||
`const {dialog}=require('electron'); dialog.showMessageBox=async()=>({response:${response},checkboxChecked:false}); require(${JSON.stringify(electronMainPath)});`
|
||||
);
|
||||
const app = await electron.launch({
|
||||
args: [
|
||||
...(process.platform === 'linux' && process.env['CI']
|
||||
? ['--no-sandbox', '--disable-gpu']
|
||||
: []),
|
||||
wrapper,
|
||||
...(retry ? ['--recover-legacy-playlists'] : []),
|
||||
],
|
||||
env: buildElectronLaunchEnvironment(dataDir),
|
||||
});
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForSelector('app-root');
|
||||
await page.waitForFunction(
|
||||
() => typeof window.electron?.dbGetAppPlaylists === 'function'
|
||||
);
|
||||
return { app, page };
|
||||
}
|
||||
|
||||
test.describe('v0.19 profile migration', () => {
|
||||
for (const active of ['xtream-2', 'stalker-60', 'm3u']) {
|
||||
test(`imports all 65 sources with ${active} last active, offline`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
await seedLegacyProfile(dataDir, active);
|
||||
let launched = await launchElectronApp(dataDir);
|
||||
try {
|
||||
const page = launched.mainWindow;
|
||||
await expect.poll(() => sourceCount(page)).toBe(65);
|
||||
expect(
|
||||
await launched.electronApp.evaluate(({ app }) =>
|
||||
app.getPath('userData')
|
||||
)
|
||||
).toBe(join(dataDir, 'electron-backend'));
|
||||
await openSources(page);
|
||||
await expect(
|
||||
page.getByText('Legacy Stalker 0', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Legacy Xtream 0', { exact: true })
|
||||
).toBeVisible();
|
||||
const sources = await page.evaluate(() =>
|
||||
window.electron.dbGetAppPlaylists()
|
||||
);
|
||||
expect(sources.filter((p) => p.macAddress)).toHaveLength(61);
|
||||
expect(sources.filter((p) => p.serverUrl)).toHaveLength(3);
|
||||
expect(sources.find((p) => p._id === active)?.lastUsage).toBe(
|
||||
'2025-12-31T00:00:00.000Z'
|
||||
);
|
||||
expect(sources.find((p) => p._id === 'xtream-2')).toMatchObject(
|
||||
{
|
||||
autoRefresh: true,
|
||||
serverUrl: 'https://xtream-2.invalid',
|
||||
username: 'synthetic-2',
|
||||
password: 'synthetic-only',
|
||||
}
|
||||
);
|
||||
expect(
|
||||
sources.find((p) => p._id === 'stalker-60')
|
||||
).toMatchObject({
|
||||
stalkerSerialNumber: 'synthetic-serial-60',
|
||||
stalkerDeviceId1: 'synthetic-device-60',
|
||||
favorites: legacyPlaylists().find(
|
||||
(p) => p._id === 'stalker-60'
|
||||
)?.['favorites'],
|
||||
});
|
||||
expect(sources.find((p) => p._id === 'm3u')).toMatchObject({
|
||||
favorites: ['channel-1'],
|
||||
userAgent: 'LegacySyntheticAgent',
|
||||
count: 1,
|
||||
});
|
||||
expect(
|
||||
await page.evaluate(() => localStorage.getItem('volume'))
|
||||
).toBe('0.37');
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
const r = indexedDB.open('ngStorage', 1);
|
||||
r.onerror = () => reject(r.error);
|
||||
r.onsuccess = () => {
|
||||
const db = r.result,
|
||||
q = db
|
||||
.transaction('localStorage')
|
||||
.objectStore('localStorage')
|
||||
.get('settings');
|
||||
q.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(q.result);
|
||||
};
|
||||
};
|
||||
})
|
||||
)
|
||||
).toMatchObject({ language: 'en', theme: 'dark' });
|
||||
expect(
|
||||
await sql(
|
||||
launched.electronApp,
|
||||
dataDir,
|
||||
'SELECT count(*) AS count FROM content'
|
||||
)
|
||||
).toEqual([{ count: 1 }]);
|
||||
expect(
|
||||
await sql(
|
||||
launched.electronApp,
|
||||
dataDir,
|
||||
'SELECT count(*) AS count FROM favorites'
|
||||
)
|
||||
).toEqual([{ count: 1 }]);
|
||||
expect(
|
||||
await sql(
|
||||
launched.electronApp,
|
||||
dataDir,
|
||||
'SELECT count(*) AS count FROM recently_viewed'
|
||||
)
|
||||
).toEqual([{ count: 1 }]);
|
||||
// Retained source database is independent of current deletion.
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<number>((resolve, reject) => {
|
||||
const r = indexedDB.open('iptvnator', 1);
|
||||
r.onerror = () => reject(r.error);
|
||||
r.onsuccess = () => {
|
||||
const db = r.result,
|
||||
q = db
|
||||
.transaction('playlists')
|
||||
.objectStore('playlists')
|
||||
.count();
|
||||
q.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(q.result);
|
||||
};
|
||||
};
|
||||
})
|
||||
)
|
||||
).toBe(65);
|
||||
await page.evaluate(() =>
|
||||
window.electron.dbDeletePlaylist('stalker-0')
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(launched);
|
||||
}
|
||||
launched = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await expect
|
||||
.poll(() => sourceCount(launched.mainWindow))
|
||||
.toBe(64);
|
||||
} finally {
|
||||
await closeElectronApp(launched);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('rolls back all rows and the receipt on one failed SQLite write, then retries safely', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const { electronApp: app, mainWindow: page } =
|
||||
await launchElectronApp(dataDir);
|
||||
try {
|
||||
await page.evaluate(
|
||||
async (key) => window.electron.dbSetAppState(key, ''),
|
||||
migrationKey
|
||||
);
|
||||
await sql(
|
||||
app,
|
||||
dataDir,
|
||||
"CREATE TRIGGER synthetic_migration_failure BEFORE INSERT ON playlists WHEN NEW.id = 'xtream-1' BEGIN SELECT RAISE(ABORT, 'synthetic write failure'); END"
|
||||
);
|
||||
const result = await page.evaluate(async (sources) => {
|
||||
try {
|
||||
await window.electron.dbMigrateAppPlaylists(
|
||||
sources as Parameters<
|
||||
typeof window.electron.dbMigrateAppPlaylists
|
||||
>[0]
|
||||
);
|
||||
return 'unexpected success';
|
||||
} catch {
|
||||
return 'failed';
|
||||
}
|
||||
}, legacyPlaylists());
|
||||
expect(result).toBe('failed');
|
||||
expect(await sourceCount(page)).toBe(0);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(key) => window.electron.dbGetAppState(key),
|
||||
migrationKey
|
||||
)
|
||||
).not.toBe('1');
|
||||
await sql(app, dataDir, 'DROP TRIGGER synthetic_migration_failure');
|
||||
await page.evaluate(
|
||||
(sources) =>
|
||||
window.electron.dbMigrateAppPlaylists(
|
||||
sources as Parameters<
|
||||
typeof window.electron.dbMigrateAppPlaylists
|
||||
>[0]
|
||||
),
|
||||
legacyPlaylists()
|
||||
);
|
||||
expect(await sourceCount(page)).toBe(65);
|
||||
await page.evaluate(
|
||||
(sources) =>
|
||||
window.electron.dbMigrateAppPlaylists(
|
||||
sources as Parameters<
|
||||
typeof window.electron.dbMigrateAppPlaylists
|
||||
>[0]
|
||||
),
|
||||
legacyPlaylists()
|
||||
);
|
||||
expect(await sourceCount(page)).toBe(65);
|
||||
} finally {
|
||||
await closeElectronApp({ electronApp: app, mainWindow: page });
|
||||
}
|
||||
});
|
||||
test('requires explicit recovery for an upgraded profile and preserves current sources/settings and deletions', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const initial = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await initial.mainWindow.evaluate(async () => {
|
||||
await window.electron.dbUpsertAppPlaylist({
|
||||
_id: 'xtream-0',
|
||||
title: 'Current edited source',
|
||||
serverUrl: 'https://current.invalid',
|
||||
username: 'current-user',
|
||||
password: 'current-password',
|
||||
count: 0,
|
||||
importDate: '2026-01-01',
|
||||
lastUsage: '2026-01-01',
|
||||
autoRefresh: false,
|
||||
});
|
||||
localStorage.setItem('volume', '0.73');
|
||||
});
|
||||
} finally {
|
||||
await closeElectronApp(initial);
|
||||
}
|
||||
await seedLegacyProfile(dataDir, 'xtream-2', false);
|
||||
let recovered = await launchWithRecoveryChoice(dataDir, 0);
|
||||
try {
|
||||
await expect
|
||||
.poll(() =>
|
||||
recovered.page.evaluate(
|
||||
(key) => window.electron.dbGetAppState(key),
|
||||
recoveryKey
|
||||
)
|
||||
)
|
||||
.toBe('declined');
|
||||
expect(await sourceCount(recovered.page)).toBe(1);
|
||||
expect(
|
||||
await recovered.app.evaluate(({ app }) =>
|
||||
app.getPath('userData')
|
||||
)
|
||||
).toBe(join(dataDir, 'user-data'));
|
||||
} finally {
|
||||
await closeElectronApp({
|
||||
electronApp: recovered.app,
|
||||
mainWindow: recovered.page,
|
||||
});
|
||||
}
|
||||
recovered = await launchWithRecoveryChoice(dataDir, 1, true);
|
||||
try {
|
||||
await expect.poll(() => sourceCount(recovered.page)).toBe(65);
|
||||
expect(
|
||||
await recovered.page.evaluate(() =>
|
||||
window.electron.dbGetAppPlaylist('xtream-0')
|
||||
)
|
||||
).toMatchObject({
|
||||
title: 'Current edited source',
|
||||
serverUrl: 'https://current.invalid',
|
||||
username: 'current-user',
|
||||
});
|
||||
expect(
|
||||
await recovered.page.evaluate(() =>
|
||||
localStorage.getItem('volume')
|
||||
)
|
||||
).toBe('0.73');
|
||||
await recovered.page.evaluate(() =>
|
||||
window.electron.dbDeletePlaylist('stalker-0')
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp({
|
||||
electronApp: recovered.app,
|
||||
mainWindow: recovered.page,
|
||||
});
|
||||
}
|
||||
recovered = await launchWithRecoveryChoice(dataDir, 1, true);
|
||||
try {
|
||||
await expect.poll(() => sourceCount(recovered.page)).toBe(64);
|
||||
expect(
|
||||
await recovered.page.evaluate(() =>
|
||||
window.electron.dbGetAppPlaylist('stalker-0')
|
||||
)
|
||||
).toBeNull();
|
||||
} finally {
|
||||
await closeElectronApp({
|
||||
electronApp: recovered.app,
|
||||
mainWindow: recovered.page,
|
||||
});
|
||||
}
|
||||
});
|
||||
test('restarts a failed real legacy import without losing the original store or cached data', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
await seedLegacyProfile(dataDir, 'xtream-2', true, true);
|
||||
let launched = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await expect(
|
||||
launched.mainWindow.evaluate(
|
||||
(sources) =>
|
||||
window.electron.dbMigrateAppPlaylists(
|
||||
sources as Parameters<
|
||||
typeof window.electron.dbMigrateAppPlaylists
|
||||
>[0]
|
||||
),
|
||||
legacyPlaylists()
|
||||
)
|
||||
).rejects.toThrow('Legacy playlist migration failed');
|
||||
expect(await sourceCount(launched.mainWindow)).toBe(1);
|
||||
expect(
|
||||
await launched.mainWindow.evaluate(
|
||||
(key) => window.electron.dbGetAppState(key),
|
||||
migrationKey
|
||||
)
|
||||
).not.toBe('1');
|
||||
expect(
|
||||
await sql(
|
||||
launched.electronApp,
|
||||
dataDir,
|
||||
'SELECT count(*) AS count FROM favorites'
|
||||
)
|
||||
).toEqual([{ count: 1 }]);
|
||||
await sql(
|
||||
launched.electronApp,
|
||||
dataDir,
|
||||
'DROP TRIGGER synthetic_migration_failure'
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(launched);
|
||||
}
|
||||
launched = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await expect.poll(() => sourceCount(launched.mainWindow)).toBe(65);
|
||||
} finally {
|
||||
await closeElectronApp(launched);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { _electron as electron } from '@playwright/test';
|
||||
import { mkdir, writeFile, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { workspaceRoot } from './electron-test-fixtures';
|
||||
|
||||
interface LegacyPlaylist extends Record<string, unknown> {
|
||||
_id: string;
|
||||
title: string;
|
||||
count: number;
|
||||
importDate: string;
|
||||
lastUsage: string;
|
||||
autoRefresh: boolean;
|
||||
}
|
||||
|
||||
/** v0.19 Playlist shape, with no type discriminator; endpoints never resolve. */
|
||||
export function legacyPlaylists(active = 'xtream-2') {
|
||||
const base = {
|
||||
count: 0,
|
||||
importDate: '2025-01-01T00:00:00.000Z',
|
||||
lastUsage: '2025-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: [],
|
||||
};
|
||||
const portals: LegacyPlaylist[] = Array.from({ length: 61 }, (_, i) => ({
|
||||
...base,
|
||||
_id: `stalker-${i}`,
|
||||
title: `Legacy Stalker ${i}`,
|
||||
portalUrl: `https://stalker-${i}.invalid/stalker_portal/c/`,
|
||||
macAddress: `00:1A:79:00:00:${i.toString(16).padStart(2, '0')}`,
|
||||
stalkerSerialNumber: `synthetic-serial-${i}`,
|
||||
stalkerDeviceId1: `synthetic-device-${i}`,
|
||||
favorites: [
|
||||
{
|
||||
id: '7',
|
||||
name: 'Synthetic favorite',
|
||||
cmd: 'ffmpeg http://synthetic.invalid/7',
|
||||
stream_type: 'live',
|
||||
},
|
||||
],
|
||||
recentlyViewed: [
|
||||
{ id: '8', name: 'Synthetic recent', added_at: '2025-01-02' },
|
||||
],
|
||||
}));
|
||||
for (let i = 0; i < 3; i++)
|
||||
portals.push({
|
||||
...base,
|
||||
_id: `xtream-${i}`,
|
||||
title: `Legacy Xtream ${i}`,
|
||||
serverUrl: `https://xtream-${i}.invalid`,
|
||||
username: `synthetic-${i}`,
|
||||
password: 'synthetic-only',
|
||||
autoRefresh: true,
|
||||
});
|
||||
portals.push({
|
||||
...base,
|
||||
_id: 'm3u',
|
||||
title: 'Legacy M3U',
|
||||
count: 1,
|
||||
favorites: ['channel-1'],
|
||||
userAgent: 'LegacySyntheticAgent',
|
||||
playlist: {
|
||||
header: { raw: '#EXTM3U' },
|
||||
items: [
|
||||
{
|
||||
id: 'channel-1',
|
||||
name: 'Synthetic channel',
|
||||
url: 'https://channel.invalid/live',
|
||||
group: { title: 'Test' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return portals.map((p) =>
|
||||
p._id === active ? { ...p, lastUsage: '2025-12-31T00:00:00.000Z' } : p
|
||||
);
|
||||
}
|
||||
|
||||
export async function seedLegacyProfile(
|
||||
dataDir: string,
|
||||
active = 'xtream-2',
|
||||
seedSqlite = true,
|
||||
failWrite = false
|
||||
) {
|
||||
const fixture = join(dataDir, 'seed');
|
||||
const profile = join(dataDir, 'electron-backend');
|
||||
await mkdir(fixture, { recursive: true });
|
||||
await mkdir(profile, { recursive: true });
|
||||
const index = join(fixture, 'index.html');
|
||||
await writeFile(
|
||||
index,
|
||||
'<!doctype html><title>Synthetic v0.19 profile</title>'
|
||||
);
|
||||
await writeFile(
|
||||
join(fixture, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'electron-backend',
|
||||
version: '0.0.1',
|
||||
main: 'main.cjs',
|
||||
})
|
||||
);
|
||||
await writeFile(
|
||||
join(fixture, 'main.cjs'),
|
||||
`const { app, BrowserWindow } = require('electron');
|
||||
const { join } = require('path');
|
||||
app.setPath('userData', join(__dirname, '..', 'electron-backend'));
|
||||
app.whenReady().then(() => new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: { sandbox: true },
|
||||
}).loadFile(join(__dirname, 'index.html')));`
|
||||
);
|
||||
const app = await electron.launch({
|
||||
args: [
|
||||
...(process.platform === 'linux' && process.env['CI']
|
||||
? ['--no-sandbox', '--disable-gpu']
|
||||
: []),
|
||||
fixture,
|
||||
],
|
||||
env: { ...process.env, NODE_ENV: 'test' },
|
||||
});
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForLoadState();
|
||||
await page.evaluate(async (playlists) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.open('iptvnator', 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const store = req.result.createObjectStore('playlists', {
|
||||
keyPath: '_id',
|
||||
autoIncrement: false,
|
||||
});
|
||||
// Exact v0.19 indexed-db.config.ts schema.
|
||||
for (const field of [
|
||||
'_id',
|
||||
'filename',
|
||||
'title',
|
||||
'count',
|
||||
'playlist',
|
||||
'importDate',
|
||||
'lastUsage',
|
||||
'favorites',
|
||||
'recentlyViewed',
|
||||
'autoRefresh',
|
||||
'url',
|
||||
'filePath',
|
||||
])
|
||||
store.createIndex(field, field, { unique: false });
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result,
|
||||
tx = db.transaction('playlists', 'readwrite');
|
||||
for (const p of playlists)
|
||||
tx.objectStore('playlists').put(p);
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onabort = () => reject(tx.error);
|
||||
};
|
||||
});
|
||||
// ngx-pwa/local-storage v0.19 defaults; unrelated preferences survive.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.open('ngStorage', 1);
|
||||
req.onupgradeneeded = () =>
|
||||
req.result.createObjectStore('localStorage');
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result,
|
||||
tx = db.transaction('localStorage', 'readwrite');
|
||||
tx.objectStore('localStorage').put(
|
||||
{ language: 'en', player: 'html5', theme: 'dark' },
|
||||
'settings'
|
||||
);
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
};
|
||||
});
|
||||
localStorage.setItem('volume', '0.37');
|
||||
}, legacyPlaylists(active));
|
||||
if (seedSqlite) {
|
||||
await mkdir(join(dataDir, 'databases'), { recursive: true });
|
||||
const sql = await readFile(
|
||||
join(
|
||||
workspaceRoot,
|
||||
'apps/electron-backend-e2e/src/fixtures/v019-schema.sql'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
await app.evaluate(
|
||||
(_electron, { path, dependency, sql, failWrite }) => {
|
||||
const Database = process
|
||||
.getBuiltinModule('module')
|
||||
.createRequire(dependency)(dependency);
|
||||
const db = new Database(path);
|
||||
db.exec(sql);
|
||||
db.prepare(
|
||||
'INSERT INTO playlists (id,name,type,serverUrl,username,password) VALUES (?,?,?,?,?,?)'
|
||||
).run(
|
||||
'xtream-2',
|
||||
'Legacy Xtream 2',
|
||||
'xtream',
|
||||
'https://stale-cache.invalid',
|
||||
'stale-user',
|
||||
'stale-password'
|
||||
);
|
||||
db.exec(
|
||||
"INSERT INTO categories (id,playlist_id,name,type,xtream_id) VALUES (1,'xtream-2','Synthetic','live',1); INSERT INTO content (id,category_id,title,xtream_id,type) VALUES (1,1,'Cached channel',7,'live'); INSERT INTO favorites (content_id,playlist_id) VALUES (1,'xtream-2'); INSERT INTO recently_viewed (content_id,playlist_id) VALUES (1,'xtream-2');"
|
||||
);
|
||||
if (failWrite)
|
||||
db.exec(
|
||||
"CREATE TRIGGER synthetic_migration_failure BEFORE INSERT ON playlists WHEN NEW.id = 'xtream-1' BEGIN SELECT RAISE(ABORT, 'synthetic write failure'); END"
|
||||
);
|
||||
db.close();
|
||||
},
|
||||
{
|
||||
path: join(dataDir, 'databases', 'iptvnator.db'),
|
||||
dependency: join(
|
||||
workspaceRoot,
|
||||
'node_modules/better-sqlite3'
|
||||
),
|
||||
sql,
|
||||
failWrite,
|
||||
}
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
channelItemByTitle,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
const streamHost = 'https://pip-fixture.test';
|
||||
const media = readFileSync(
|
||||
join(__dirname, '../../web-e2e/src/fixtures/playback/episode.webm')
|
||||
);
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:-1 tvg-id="pip-one" group-title="News",Channel 1',
|
||||
`${streamHost}/one.webm`,
|
||||
'#EXTINF:-1 tvg-id="pip-two" group-title="News",Positive News TV',
|
||||
`${streamHost}/two.webm`,
|
||||
].join('\n');
|
||||
|
||||
for (const player of ['html5', 'videojs', 'artplayer']) {
|
||||
for (const sharedControls of [false, true]) {
|
||||
test(`@playback @electron closes ${player} PiP on channel change (shared controls: ${sharedControls})`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
try {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'playback');
|
||||
await page.getByTestId('select-video-player').click();
|
||||
await page.getByTestId(player).click();
|
||||
await page
|
||||
.getByTestId('web-player-shared-controls-setting')
|
||||
.locator('input[type="checkbox"]')
|
||||
.setChecked(sharedControls);
|
||||
// A fresh profile defaults to Video.js with shared controls.
|
||||
// For changed settings, await the save control through the
|
||||
// helper; an immediate isVisible() can miss Angular rendering it.
|
||||
if (player !== 'videojs' || !sharedControls) {
|
||||
await saveSettings(page);
|
||||
}
|
||||
const playlistPath = join(dataDir, 'pip.m3u');
|
||||
writeFileSync(playlistPath, playlist);
|
||||
await page.route(`${streamHost}/**`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'video/webm',
|
||||
body: media,
|
||||
})
|
||||
);
|
||||
await goToDashboard(page);
|
||||
await importM3uPlaylistFromNativeDialog(app, playlistPath);
|
||||
await page.waitForURL(/\/workspace\/playlists\/.+/);
|
||||
// Import can auto-select the first channel. Select a distinct
|
||||
// source and wait for its actual media to load before owning PiP;
|
||||
// merely finding a video can capture the retiring initial host.
|
||||
await channelItemByTitle(page, 'Positive News TV')
|
||||
.first()
|
||||
.click();
|
||||
const video = page.locator('app-web-player-view video');
|
||||
await expect(video).toHaveCount(1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
video.evaluate((element: HTMLVideoElement) => ({
|
||||
source: element.currentSrc,
|
||||
loaded:
|
||||
element.readyState >=
|
||||
HTMLMediaElement.HAVE_CURRENT_DATA,
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
source: `${streamHost}/two.webm`,
|
||||
loaded: true,
|
||||
});
|
||||
// Emulate only the OS PiP API, unavailable on headless CI.
|
||||
const oldVideo = await video.elementHandle();
|
||||
if (!oldVideo)
|
||||
throw new Error(
|
||||
'The selected channel has no video element'
|
||||
);
|
||||
await oldVideo.evaluate((element: HTMLVideoElement) => {
|
||||
let owner: Element | null = element;
|
||||
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||
configurable: true,
|
||||
get: () => owner,
|
||||
set: (value: Element | null) => {
|
||||
owner = value;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(document, 'exitPictureInPicture', {
|
||||
configurable: true,
|
||||
value: async () => {
|
||||
const previous = owner;
|
||||
owner = null;
|
||||
previous?.dispatchEvent(
|
||||
new Event('leavepictureinpicture')
|
||||
);
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(new Event('enterpictureinpicture'));
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => !!document.pictureInPictureElement)
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await channelItemByTitle(page, 'Channel 1').first().click();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
oldVideo.evaluate((element) => element.isConnected)
|
||||
)
|
||||
.toBe(false);
|
||||
await expect(video).toHaveCount(1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => document.pictureInPictureElement === null
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
// Native/vendor controls can finish a pending entry after the
|
||||
// host is gone. The retired element must close that entry too.
|
||||
if (!sharedControls) {
|
||||
await oldVideo.evaluate((element) => {
|
||||
Reflect.set(
|
||||
document,
|
||||
'pictureInPictureElement',
|
||||
element
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new Event('enterpictureinpicture')
|
||||
);
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => document.pictureInPictureElement === null
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
await oldVideo.dispose();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import {
|
||||
channelItemByTitle,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openGlobalFavorites,
|
||||
openGlobalRecent,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
test,
|
||||
workspaceRoot,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
createLocalMediaServer,
|
||||
installEmbeddedMpvSessionCapture,
|
||||
} from './embedded-mpv-frame-copy-packaged-fixtures';
|
||||
import {
|
||||
applyTheme,
|
||||
expectTextContrast,
|
||||
expectThemeSurface,
|
||||
expectOverlayContrastOnWhite,
|
||||
} from './theme-contrast';
|
||||
|
||||
for (const engine of [
|
||||
'native',
|
||||
'frame-copy',
|
||||
'html5',
|
||||
'videojs',
|
||||
'artplayer',
|
||||
] as const) {
|
||||
test(`@playback @theme @electron ${engine} keeps controls readable across live theme and fullscreen changes`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const embedded = engine === 'native' || engine === 'frame-copy';
|
||||
const media = embedded ? await createLocalMediaServer() : undefined;
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY:
|
||||
engine === 'frame-copy' ? '1' : '0',
|
||||
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
|
||||
},
|
||||
});
|
||||
try {
|
||||
if (embedded) {
|
||||
const support = await app.mainWindow.evaluate(() =>
|
||||
window.electron.getEmbeddedMpvSupport()
|
||||
);
|
||||
test.skip(
|
||||
!support.supported || support.engine !== engine,
|
||||
`Requested ${engine} runtime unavailable: ${JSON.stringify(support)}`
|
||||
);
|
||||
}
|
||||
await openSettings(app.mainWindow);
|
||||
await app.mainWindow.getByTestId('LIGHT_THEME').click();
|
||||
await openSettingsSection(app.mainWindow, 'playback');
|
||||
await app.mainWindow.getByTestId('select-video-player').click();
|
||||
await app.mainWindow
|
||||
.getByTestId(embedded ? 'embedded-mpv' : engine)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await goToDashboard(app.mainWindow);
|
||||
const playlist = join(dataDir, 'theme.m3u');
|
||||
const url =
|
||||
media?.url ??
|
||||
pathToFileURL(
|
||||
join(
|
||||
workspaceRoot,
|
||||
'apps/web-e2e/src/fixtures/playback/episode.webm'
|
||||
)
|
||||
).href;
|
||||
writeFileSync(
|
||||
playlist,
|
||||
`#EXTM3U\n#EXTINF:-1,Theme fixture\n${url}\n`
|
||||
);
|
||||
if (embedded) await installEmbeddedMpvSessionCapture(app);
|
||||
await importM3uPlaylistFromNativeDialog(app, playlist);
|
||||
if (engine === 'html5') {
|
||||
await channelItemByTitle(app.mainWindow, 'Theme fixture')
|
||||
.first()
|
||||
.locator('.favorite-button')
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
await channelItemByTitle(app.mainWindow, 'Theme fixture')
|
||||
.first()
|
||||
.click();
|
||||
if (engine === 'frame-copy') {
|
||||
// The experimental local runtime can fail its first frame-view
|
||||
// initialization. Cover the themed error UI and one user Retry;
|
||||
// successful decoding is still required below.
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.mainWindow.evaluate(
|
||||
() =>
|
||||
(window.__packagedEmbeddedMpvSessions?.at(-1)
|
||||
?.positionSeconds ?? 0) > 0 ||
|
||||
!!document.querySelector(
|
||||
'.embedded-mpv-player__stalled'
|
||||
)
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
const stalled = app.mainWindow.locator(
|
||||
'.embedded-mpv-player__stalled'
|
||||
);
|
||||
if (await stalled.isVisible()) {
|
||||
test.info().annotations.push({
|
||||
type: 'runtime-retry',
|
||||
description:
|
||||
'Frame-copy initialization required one user Retry before theme checks.',
|
||||
});
|
||||
for (const theme of ['dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
await expectTextContrast(stalled.locator('p'));
|
||||
await expectTextContrast(
|
||||
stalled.getByRole('button', { name: 'Retry' })
|
||||
);
|
||||
}
|
||||
await stalled
|
||||
.getByRole('button', { name: 'Retry' })
|
||||
.click();
|
||||
}
|
||||
}
|
||||
if (embedded) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.mainWindow.evaluate(
|
||||
() =>
|
||||
window.__packagedEmbeddedMpvSessions?.at(-1)
|
||||
?.positionSeconds ?? 0
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
} else {
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.mainWindow
|
||||
.locator('app-web-player-view video')
|
||||
.evaluate(
|
||||
(video: HTMLVideoElement) => video.currentTime
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
const controls = app.mainWindow.locator(
|
||||
engine === 'native'
|
||||
? '.embedded-mpv-player__controls'
|
||||
: 'app-player-controls'
|
||||
);
|
||||
const pause = controls.getByRole('button', {
|
||||
name: 'Pause',
|
||||
exact: true,
|
||||
});
|
||||
await expect(pause).toBeEnabled({ timeout: 20000 });
|
||||
await pause.click();
|
||||
const play = controls.getByRole('button', {
|
||||
name: 'Play',
|
||||
exact: true,
|
||||
});
|
||||
await expect(play).toBeVisible();
|
||||
for (const fullscreen of [false, true]) {
|
||||
if (fullscreen) {
|
||||
await controls
|
||||
.getByRole('button', {
|
||||
name: 'Enter fullscreen',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
}
|
||||
for (const theme of ['light', 'dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
await play.hover();
|
||||
if (engine === 'native') await expectTextContrast(play, 3);
|
||||
else await expectOverlayContrastOnWhite(controls, play);
|
||||
// Leave pointer hover, then Tab back into the control to
|
||||
// exercise a real keyboard focus-visible state.
|
||||
await play.press('Tab');
|
||||
await app.mainWindow.keyboard.press('Shift+Tab');
|
||||
await expect(play).toBeFocused();
|
||||
if (engine === 'native') await expectTextContrast(play, 3);
|
||||
else await expectOverlayContrastOnWhite(controls, play);
|
||||
if (!fullscreen) {
|
||||
const timeline =
|
||||
app.mainWindow.locator('app-epg-timeline');
|
||||
await expectThemeSurface(timeline, theme);
|
||||
await expectTextContrast(
|
||||
timeline.locator('.epg-empty__title')
|
||||
);
|
||||
await expectTextContrast(
|
||||
timeline.locator('.epg-empty__sub')
|
||||
);
|
||||
}
|
||||
if (engine === 'native') {
|
||||
await expectThemeSurface(controls, theme);
|
||||
const record = controls.locator(
|
||||
'.embedded-mpv-player__record-button'
|
||||
);
|
||||
await expect(record).toBeVisible();
|
||||
// Recording behavior has its own IPC coverage; exercise
|
||||
// the state class here without creating a recording file.
|
||||
await record.evaluate((element) =>
|
||||
element.classList.add(
|
||||
'embedded-mpv-player__record-button--active'
|
||||
)
|
||||
);
|
||||
const liveColor = await record.evaluate((element) =>
|
||||
getComputedStyle(element)
|
||||
.getPropertyValue('--app-live-color')
|
||||
.trim()
|
||||
);
|
||||
await expect(record.locator('mat-icon')).toHaveCSS(
|
||||
'color',
|
||||
await record.evaluate((element, color) => {
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = color;
|
||||
element.append(probe);
|
||||
const resolved = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return resolved;
|
||||
}, liveColor)
|
||||
);
|
||||
await expectTextContrast(record, 3);
|
||||
await record.evaluate((element) =>
|
||||
element.classList.remove(
|
||||
'embedded-mpv-player__record-button--active'
|
||||
)
|
||||
);
|
||||
await expect(play).toHaveCSS('outline-width', '2px');
|
||||
const disabled = controls
|
||||
.locator(
|
||||
'.embedded-mpv-player__transport button:disabled'
|
||||
)
|
||||
.first();
|
||||
await expect(disabled).toBeDisabled();
|
||||
await expectTextContrast(disabled, 1.5);
|
||||
expect(
|
||||
await disabled.evaluate(
|
||||
(el) => getComputedStyle(el).color
|
||||
)
|
||||
).not.toEqual(
|
||||
await play.evaluate(
|
||||
(el) => getComputedStyle(el).color
|
||||
)
|
||||
);
|
||||
await expectTextContrast(
|
||||
controls.locator('.embedded-mpv-player__time')
|
||||
);
|
||||
}
|
||||
if (engine === 'native') {
|
||||
await controls
|
||||
.locator('[data-embedded-mpv-menu-button="speed"]')
|
||||
.click();
|
||||
const panel = controls.locator(
|
||||
'app-embedded-mpv-dock-panel'
|
||||
);
|
||||
await expectTextContrast(
|
||||
panel.locator('.embedded-mpv-dock-panel__title')
|
||||
);
|
||||
const selected = panel.locator(
|
||||
'.embedded-mpv-dock-panel__chip--selected'
|
||||
);
|
||||
await expectTextContrast(selected);
|
||||
await selected.hover();
|
||||
await expectTextContrast(selected);
|
||||
await panel
|
||||
.locator('.embedded-mpv-dock-panel__back')
|
||||
.click();
|
||||
}
|
||||
const controlBox = await controls.boundingBox();
|
||||
const playBox = await play.boundingBox();
|
||||
expect(playBox!.x).toBeGreaterThanOrEqual(controlBox!.x);
|
||||
expect(playBox!.y + playBox!.height).toBeLessThanOrEqual(
|
||||
controlBox!.y + controlBox!.height + 1
|
||||
);
|
||||
await app.mainWindow.screenshot({
|
||||
path: test
|
||||
.info()
|
||||
.outputPath(
|
||||
`${engine}-${theme}-${fullscreen ? 'fullscreen' : 'window'}.png`
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
await controls
|
||||
.getByRole('button', { name: 'Exit fullscreen', exact: true })
|
||||
.click();
|
||||
if (engine === 'html5') {
|
||||
for (const openCollection of [
|
||||
openGlobalFavorites,
|
||||
openGlobalRecent,
|
||||
]) {
|
||||
await openCollection(app.mainWindow);
|
||||
await channelItemByTitle(app.mainWindow, 'Theme fixture')
|
||||
.first()
|
||||
.click();
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
const timeline =
|
||||
app.mainWindow.locator('app-epg-timeline');
|
||||
await expect(timeline).toBeVisible();
|
||||
await expectThemeSurface(timeline, theme);
|
||||
await expectTextContrast(
|
||||
timeline.locator('.epg-timeline__heading b')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await media?.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
openSourceEditor,
|
||||
openSources,
|
||||
restartElectronApp,
|
||||
refreshSource,
|
||||
saveSourceDialog,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
@@ -38,6 +39,83 @@ async function openAutoRefreshPlaylistCatalog(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
test.describe('Electron startup playlist auto-refresh', () => {
|
||||
test('imports a protected URL with User-Agent and reuses it for manual and startup refresh', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const userAgent = 'IPTVnator-Test/1.0';
|
||||
const urlServer = await createMutableTextServer(
|
||||
buildAutoRefreshM3u('Original UA Channel'),
|
||||
{
|
||||
resourcePath: `/${autoRefreshSourceName}`,
|
||||
requiredUserAgent: userAgent,
|
||||
}
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
expect((await fetch(urlServer.resourceUrl)).status).toBe(403);
|
||||
await importM3uPlaylistFromUrl(
|
||||
app.mainWindow,
|
||||
urlServer.resourceUrl,
|
||||
` ${userAgent} `
|
||||
);
|
||||
await openSources(app.mainWindow);
|
||||
const dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
autoRefreshSourceName
|
||||
);
|
||||
await expect(
|
||||
dialog.getByRole('textbox', { name: 'User agent', exact: true })
|
||||
).toHaveValue(userAgent);
|
||||
await updateSourceDialog(dialog, { autoRefresh: true });
|
||||
await saveSourceDialog(app.mainWindow, dialog);
|
||||
|
||||
urlServer.setBody(buildAutoRefreshM3u('Manual UA Channel'));
|
||||
await refreshSource(app.mainWindow, autoRefreshSourceName);
|
||||
await openAutoRefreshPlaylistCatalog(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.getByTestId('channel-item')
|
||||
.filter({ hasText: 'Manual UA Channel' })
|
||||
).toHaveCount(1);
|
||||
|
||||
// A refresh result must keep the in-memory default as well as SQLite metadata.
|
||||
await openSources(app.mainWindow);
|
||||
urlServer.setBody(buildAutoRefreshM3u('Second UA Channel'));
|
||||
await refreshSource(app.mainWindow, autoRefreshSourceName);
|
||||
await openAutoRefreshPlaylistCatalog(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.getByTestId('channel-item')
|
||||
.filter({ hasText: 'Second UA Channel' })
|
||||
).toHaveCount(1);
|
||||
|
||||
urlServer.setBody(buildAutoRefreshM3u('Startup UA Channel'));
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
await openAutoRefreshPlaylistCatalog(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.getByTestId('channel-item')
|
||||
.filter({ hasText: 'Startup UA Channel' })
|
||||
).toHaveCount(1);
|
||||
await openSources(app.mainWindow);
|
||||
const savedDialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
autoRefreshSourceName
|
||||
);
|
||||
await expect(
|
||||
savedDialog.getByRole('textbox', {
|
||||
name: 'User agent',
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue(userAgent);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await urlServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('reports failed playlists instead of an unconditional success toast', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
|
||||
@@ -292,6 +292,9 @@ test.describe('Electron Playlist Switcher', () => {
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
|
||||
// Keep the slowed-down import in 100-row commits so the switch
|
||||
// below still happens mid-import (see xtream-responsiveness).
|
||||
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION: '100',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { applyTheme, expectTextContrast } from './theme-contrast';
|
||||
|
||||
import {
|
||||
addStalkerPortal,
|
||||
@@ -20,6 +21,55 @@ import {
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
test.describe('Electron Provider Smoke Tests', () => {
|
||||
test('@xtream @theme @electron keeps sync overlay text readable in both themes', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
// Hold the cache read so the real local-library overlay stays
|
||||
// mounted while its theme and Material interaction states change.
|
||||
await app.electronApp.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('DB_GET_CATEGORIES');
|
||||
ipcMain.handle(
|
||||
'DB_GET_CATEGORIES',
|
||||
() => new Promise(() => {
|
||||
// Intentionally pending until this isolated app closes.
|
||||
})
|
||||
);
|
||||
});
|
||||
await addXtreamPortal(app.mainWindow);
|
||||
const overlay = app.mainWindow.locator(
|
||||
'app-workspace-shell-import-overlay'
|
||||
);
|
||||
await expect(overlay).toContainText('Loading the saved catalog');
|
||||
const action = overlay.getByRole('button', { name: 'Stop sync' });
|
||||
for (const theme of ['light', 'dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
for (const selector of [
|
||||
'h3',
|
||||
'.workspace-loading-overlay__badge',
|
||||
'.workspace-loading-overlay__phase',
|
||||
'.workspace-loading-overlay__detail',
|
||||
]) {
|
||||
await expectTextContrast(overlay.locator(selector));
|
||||
}
|
||||
// Leave headroom for Material's translucent hover/focus layer.
|
||||
await expectTextContrast(action, 6);
|
||||
await action.hover();
|
||||
await expectTextContrast(action);
|
||||
await action.focus();
|
||||
await expectTextContrast(action);
|
||||
await app.mainWindow.screenshot({
|
||||
path: test.info().outputPath(`sync-overlay-${theme}.png`),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@xtream @electron loads Xtream content through the Electron IPC path', async ({
|
||||
dataDir,
|
||||
request,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { createServer } from 'node:net';
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
const pausedMessage = 'skipped after repeated connection failures';
|
||||
// Xtream currently rejects network failures with a plain object, whose fields
|
||||
// Electron discards. Stalker preserves the error code in its Error message.
|
||||
const xtreamNetworkFailure =
|
||||
"Error invoking remote method 'XTREAM_REQUEST': [object Object]";
|
||||
|
||||
async function unusedLocalOrigin(): Promise<string> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string')
|
||||
throw new Error('No test port');
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
);
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
/** Real IPC/network failure: both protocols must share the saved policy. */
|
||||
async function requestFailure(page: Page, origin: string, stalker = false) {
|
||||
return page.evaluate(
|
||||
async ({ origin, stalker }) => {
|
||||
try {
|
||||
if (stalker) {
|
||||
await window.electron.stalkerRequest({
|
||||
url: `${origin}/portal.php`,
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
params: { type: 'stb', action: 'handshake' },
|
||||
});
|
||||
} else {
|
||||
await window.electron.xtreamRequest({
|
||||
url: origin,
|
||||
params: {
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
action: 'get_account_info',
|
||||
},
|
||||
});
|
||||
}
|
||||
return 'unexpected success';
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
{ origin, stalker }
|
||||
);
|
||||
}
|
||||
|
||||
test('@settings @electron saves the portal cooldown preference and applies it without restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const origin = await unusedLocalOrigin();
|
||||
let app = await launchElectronApp(dataDir, {
|
||||
omitEnvKeys: ['IPTVNATOR_DISABLE_CONNECTIVITY_GUARD'],
|
||||
});
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'general');
|
||||
let checkbox = app.mainWindow
|
||||
.getByTestId('portal-connectivity-toggle')
|
||||
.getByRole('checkbox');
|
||||
await expect(checkbox).toBeChecked();
|
||||
await requestFailure(app.mainWindow, origin);
|
||||
await requestFailure(app.mainWindow, origin);
|
||||
expect(await requestFailure(app.mainWindow, origin, true)).toContain(
|
||||
pausedMessage
|
||||
);
|
||||
|
||||
await checkbox.uncheck();
|
||||
// An unsaved edit cannot alter backend policy.
|
||||
expect(await requestFailure(app.mainWindow, origin)).toContain(
|
||||
pausedMessage
|
||||
);
|
||||
await saveSettings(app.mainWindow);
|
||||
for (const stalker of [false, true, false]) {
|
||||
expect(
|
||||
await requestFailure(app.mainWindow, origin, stalker)
|
||||
).toContain(stalker ? 'ECONNREFUSED' : xtreamNetworkFailure);
|
||||
}
|
||||
await expect(
|
||||
app.mainWindow.locator('mat-snack-bar-container')
|
||||
).toBeHidden();
|
||||
await app.mainWindow
|
||||
.getByTestId('portal-connectivity-setting')
|
||||
.screenshot({
|
||||
path: test.info().outputPath('portal-connections.png'),
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
app = await launchElectronApp(dataDir, {
|
||||
omitEnvKeys: ['IPTVNATOR_DISABLE_CONNECTIVITY_GUARD'],
|
||||
});
|
||||
// Check the cold-start mirror before opening Settings or saving again.
|
||||
for (let index = 0; index < 3; index++) {
|
||||
expect(await requestFailure(app.mainWindow, origin)).toContain(
|
||||
xtreamNetworkFailure
|
||||
);
|
||||
}
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'general');
|
||||
checkbox = app.mainWindow
|
||||
.getByTestId('portal-connectivity-toggle')
|
||||
.getByRole('checkbox');
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
await checkbox.check();
|
||||
await saveSettings(app.mainWindow);
|
||||
expect(await requestFailure(app.mainWindow, origin)).toContain(
|
||||
xtreamNetworkFailure
|
||||
);
|
||||
expect(await requestFailure(app.mainWindow, origin, true)).toContain(
|
||||
'ECONNREFUSED'
|
||||
);
|
||||
expect(await requestFailure(app.mainWindow, origin)).toContain(
|
||||
pausedMessage
|
||||
);
|
||||
await app.mainWindow.getByTestId('DARK_THEME').click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.locator('mat-snack-bar-container')
|
||||
).toBeHidden();
|
||||
await app.mainWindow
|
||||
.getByTestId('portal-connectivity-setting')
|
||||
.screenshot({
|
||||
path: test.info().outputPath('portal-connections-dark.png'),
|
||||
});
|
||||
// Unrelated saves (such as theme changes) must preserve the cooldown.
|
||||
expect(await requestFailure(app.mainWindow, origin)).toContain(
|
||||
pausedMessage
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -581,6 +581,39 @@ test.describe('Electron Settings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @persistence @electron persists the EPG display offset across app restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const firstLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(firstLaunch.mainWindow);
|
||||
await openSettingsSection(firstLaunch.mainWindow, 'epg');
|
||||
const offsetInput = firstLaunch.mainWindow.locator(
|
||||
'[data-test-id="epg-offset-minutes"]'
|
||||
);
|
||||
await expect(offsetInput).toHaveValue('0');
|
||||
await offsetInput.fill('-90');
|
||||
await saveSettings(firstLaunch.mainWindow);
|
||||
} finally {
|
||||
await closeElectronApp(firstLaunch);
|
||||
}
|
||||
|
||||
const secondLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(secondLaunch.mainWindow);
|
||||
await openSettingsSection(secondLaunch.mainWindow, 'epg');
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator(
|
||||
'[data-test-id="epg-offset-minutes"]'
|
||||
)
|
||||
).toHaveValue('-90');
|
||||
} finally {
|
||||
await closeElectronApp(secondLaunch);
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @electron starts on sources when dashboard is disabled', async ({ dataDir }) => {
|
||||
const firstLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
@@ -837,8 +870,129 @@ test.describe('Electron Settings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @persistence @electron launches fullscreen once the startup window mode is saved and toggles it with F11', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
let app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('startup-window-mode-setting')
|
||||
).toBeVisible();
|
||||
await selectSettingsOption(
|
||||
app.mainWindow,
|
||||
'select-startup-window-mode',
|
||||
'startup-window-mode-fullscreen'
|
||||
);
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
// The mode is read when the window is created, so saving it
|
||||
// leaves the running window alone.
|
||||
expect(await isMainWindowFullScreen(app)).toBe(false);
|
||||
} finally {
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
|
||||
.toBe(true);
|
||||
await openSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('select-startup-window-mode')
|
||||
).toContainText(/Fullscreen/);
|
||||
|
||||
// F11 is the way out on Windows/Linux, where the title bar is
|
||||
// hidden and the custom window controls hide while fullscreen.
|
||||
await app.mainWindow.keyboard.press('F11');
|
||||
await expect
|
||||
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
|
||||
.toBe(false);
|
||||
|
||||
await app.mainWindow.keyboard.press('F11');
|
||||
await expect
|
||||
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
|
||||
.toBe(true);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @electron --fullscreen forces one fullscreen launch without persisting it', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
let app = await launchElectronApp(dataDir, {
|
||||
appArgs: ['--fullscreen'],
|
||||
});
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
|
||||
.toBe(true);
|
||||
} finally {
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
}
|
||||
|
||||
try {
|
||||
// The switch is a one-shot: the next plain launch follows the
|
||||
// stored setting, which is still the default.
|
||||
expect(await isMainWindowFullScreen(app)).toBe(false);
|
||||
await openSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('select-startup-window-mode')
|
||||
).toContainText(/Last window size/);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @persistence @electron launches maximized when the startup window mode says so', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
test.skip(
|
||||
process.platform === 'linux' && !!process.env['CI'],
|
||||
'xvfb on Linux CI has no window manager, so maximize never takes effect'
|
||||
);
|
||||
let app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await selectSettingsOption(
|
||||
app.mainWindow,
|
||||
'select-startup-window-mode',
|
||||
'startup-window-mode-maximized'
|
||||
);
|
||||
await saveSettings(app.mainWindow);
|
||||
} finally {
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
return mainWindow ? mainWindow.isMaximized() : false;
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
expect(await isMainWindowFullScreen(app)).toBe(false);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function isMainWindowFullScreen(app: LaunchedElectronApp): Promise<boolean> {
|
||||
return app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
return mainWindow ? mainWindow.isFullScreen() : false;
|
||||
});
|
||||
}
|
||||
|
||||
async function selectSettingsOption(
|
||||
page: Page,
|
||||
selectTestId: string,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
import sharp = require('sharp');
|
||||
|
||||
/** Exercise the same live CSS boundary as SettingsService without navigating
|
||||
* away from (and therefore destroying) the player being measured. */
|
||||
export async function applyTheme(page: Page, theme: 'light' | 'dark') {
|
||||
await page.evaluate((dark) => {
|
||||
document.body.classList.toggle('dark-theme', dark);
|
||||
}, theme === 'dark');
|
||||
}
|
||||
|
||||
/** Contrast for app panels with solid/alpha CSS background colors. Gradients,
|
||||
* video and Material state layers use the raster check below instead. */
|
||||
export async function expectTextContrast(
|
||||
locator: Locator,
|
||||
minimum = 4.5,
|
||||
siblingFillSelector?: string
|
||||
) {
|
||||
await expect(locator).toBeVisible();
|
||||
const measure = () =>
|
||||
locator.evaluate((element, fillSelector) => {
|
||||
type Color = [number, number, number, number];
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const parse = (css: string): Color => {
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = css;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return [r, g, b, a / 255];
|
||||
};
|
||||
const over = (front: Color, back: Color): Color => {
|
||||
const alpha = front[3] + back[3] * (1 - front[3]);
|
||||
return [0, 1, 2]
|
||||
.map((i) =>
|
||||
alpha
|
||||
? (front[i] * front[3] +
|
||||
back[i] * back[3] * (1 - front[3])) /
|
||||
alpha
|
||||
: 0
|
||||
)
|
||||
.concat(alpha) as Color;
|
||||
};
|
||||
let foreground = parse(getComputedStyle(element).color);
|
||||
let background: Color = [0, 0, 0, 0];
|
||||
for (
|
||||
let node: Element | null = element;
|
||||
node;
|
||||
node = node.parentElement
|
||||
) {
|
||||
const style = getComputedStyle(node);
|
||||
// A timeline progress fill is a sibling behind the text, not
|
||||
// an ancestor. Include it before its owning card background.
|
||||
const siblingFill = fillSelector
|
||||
? node.querySelector(`:scope > ${fillSelector}`)
|
||||
: null;
|
||||
if (siblingFill) {
|
||||
const layer = parse(
|
||||
getComputedStyle(siblingFill).backgroundColor
|
||||
);
|
||||
foreground = over(foreground, layer);
|
||||
background = over(background, layer);
|
||||
}
|
||||
const fill = parse(style.backgroundColor);
|
||||
foreground = over(foreground, fill);
|
||||
background = over(background, fill);
|
||||
foreground[3] *= Number(style.opacity);
|
||||
background[3] *= Number(style.opacity);
|
||||
}
|
||||
const white: Color = [255, 255, 255, 1];
|
||||
const luminance = (color: Color) => {
|
||||
const rgb = color.slice(0, 3).map((value) => {
|
||||
const s = value / 255;
|
||||
return s <= 0.04045
|
||||
? s / 12.92
|
||||
: ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722;
|
||||
};
|
||||
const a = luminance(over(foreground, white));
|
||||
const b = luminance(over(background, white));
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}, siblingFillSelector);
|
||||
await expect
|
||||
.poll(measure, { message: `Rendered contrast of ${locator}` })
|
||||
.toBeGreaterThanOrEqual(minimum);
|
||||
}
|
||||
|
||||
export async function expectThemeSurface(
|
||||
locator: Locator,
|
||||
theme: 'light' | 'dark'
|
||||
) {
|
||||
const brightness = await locator.evaluate((element) => {
|
||||
const color = getComputedStyle(element).backgroundColor;
|
||||
const channels =
|
||||
color
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number) ?? [];
|
||||
return channels.reduce((sum, value) => sum + value, 0) / 3;
|
||||
});
|
||||
if (theme === 'light') expect(brightness).toBeGreaterThan(220);
|
||||
else expect(brightness).toBeLessThan(70);
|
||||
}
|
||||
|
||||
/** Put a worst-case white video frame below the real shared controls. Rasterize
|
||||
* the actual icon with its gradient scrim and Material hover/focus layers, so
|
||||
* losing the scrim cannot pass because a black video ancestor masks the bug. */
|
||||
export async function expectOverlayContrastOnWhite(
|
||||
controls: Locator,
|
||||
button: Locator
|
||||
) {
|
||||
await controls.evaluate((element) => {
|
||||
const backing = document.createElement('div');
|
||||
backing.dataset['themeContrastBacking'] = '';
|
||||
Object.assign(backing.style, {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
background: '#fff',
|
||||
zIndex: '-1',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
element.append(backing);
|
||||
});
|
||||
try {
|
||||
const icon = button.locator('mat-icon');
|
||||
const buffer = await icon.screenshot({ animations: 'disabled' });
|
||||
const { data, info } = await sharp(buffer)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const luminances: number[] = [];
|
||||
for (let i = 0; i < data.length; i += info.channels) {
|
||||
const linear = [data[i], data[i + 1], data[i + 2]].map((value) => {
|
||||
const s = value / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
luminances.push(
|
||||
linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
|
||||
);
|
||||
}
|
||||
luminances.sort((a, b) => a - b);
|
||||
// Interior background and filled glyph, excluding antialiased edges.
|
||||
const background = luminances[Math.floor(luminances.length * 0.1)];
|
||||
const foreground = luminances[Math.floor(luminances.length * 0.9)];
|
||||
expect(
|
||||
(foreground + 0.05) / (background + 0.05)
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
} finally {
|
||||
await controls.evaluate((element) =>
|
||||
element.querySelector('[data-theme-contrast-backing]')?.remove()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Skeletons are decorative, but their faintest gradient stop must remain
|
||||
* distinguishable from the guide's background in either theme. */
|
||||
export async function expectSkeletonContrast(
|
||||
skeleton: Locator,
|
||||
guide: Locator
|
||||
) {
|
||||
const background = await guide.evaluate((element) =>
|
||||
getComputedStyle(element)
|
||||
.backgroundColor.match(/[\d.]+/g)!
|
||||
.slice(0, 3)
|
||||
.map(Number)
|
||||
);
|
||||
const { data, info } = await sharp(
|
||||
await skeleton.screenshot({ animations: 'disabled' })
|
||||
)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const luminance = (rgb: number[]) =>
|
||||
rgb
|
||||
.map((value) => {
|
||||
const s = value / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
})
|
||||
.reduce(
|
||||
(sum, value, index) =>
|
||||
sum + value * [0.2126, 0.7152, 0.0722][index],
|
||||
0
|
||||
);
|
||||
const bg = luminance(background);
|
||||
// Middle row avoids the rounded transparent corners.
|
||||
for (let x = 8; x < info.width - 8; x++) {
|
||||
const offset =
|
||||
(Math.floor(info.height / 2) * info.width + x) * info.channels;
|
||||
const fg = luminance([
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
]);
|
||||
expect(
|
||||
(Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05)
|
||||
).toBeGreaterThanOrEqual(1.3);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
channelItemByTitle,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
createMutableTextServer,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
@@ -28,6 +29,189 @@ const epgCredentials = {
|
||||
password: 'epg',
|
||||
};
|
||||
|
||||
test('@epg @xtream @electron removes uploaded guide data and restores provider EPG without restarting', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
|
||||
const id = fixture.stream.epg_channel_id;
|
||||
expect(id).toBeTruthy();
|
||||
const stamp = (date: Date) =>
|
||||
date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
const xml = `<tv><channel id="${id}"><display-name>Uploaded Guide</display-name></channel>
|
||||
<programme channel="${id}" start="${stamp(new Date(Date.now() - 600000))} +0000" stop="${stamp(new Date(Date.now() + 3600000))} +0000"><title>Temporary XMLTV Bulletin</title></programme></tv>`;
|
||||
const source = await createMutableTextServer(xml, {
|
||||
contentType: 'application/xml',
|
||||
resourcePath: '/temporary.xml',
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: epgPortalName,
|
||||
...epgCredentials,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
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')
|
||||
.fill(source.resourceUrl);
|
||||
await app.mainWindow
|
||||
.getByTestId('toggle-prefer-uploaded-epg')
|
||||
.locator('input')
|
||||
.check();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
app.mainWindow.evaluate(
|
||||
async (channelId) =>
|
||||
(
|
||||
await window.electron.getChannelPrograms(
|
||||
channelId!
|
||||
)
|
||||
).map((p) => p.title),
|
||||
id
|
||||
),
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
.toContain('Temporary XMLTV Bulletin');
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
const row = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
fixture.stream.name ?? ''
|
||||
).first();
|
||||
await expect(row.locator('.epg-title')).toHaveText(
|
||||
'Temporary XMLTV Bulletin'
|
||||
);
|
||||
await row.click();
|
||||
await expect
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.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 saveSettings(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
await expect(row.locator('.epg-title')).toHaveText(
|
||||
fixture.shortEpg[0].title
|
||||
);
|
||||
await row.click();
|
||||
await expect
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.not.toContain('Temporary XMLTV Bulletin');
|
||||
await expect
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.toContain(fixture.fullEpg[0].title);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await source.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @stalker @electron invalidates a loaded manual XMLTV mapping after source removal', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const fixture = await fetchStalkerCategoryFixture(request, 'itv');
|
||||
const item = fixture.items[0];
|
||||
const stamp = (date: Date) =>
|
||||
date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
const source = await createMutableTextServer(
|
||||
`<tv><channel id="stalker-mapped"><display-name>Mapped Guide</display-name></channel>
|
||||
<programme channel="stalker-mapped" start="${stamp(new Date(Date.now() - 600000))} +0000" stop="${stamp(new Date(Date.now() + 3600000))} +0000"><title>Retired Stalker Bulletin</title></programme></tv>`,
|
||||
{
|
||||
contentType: 'application/xml',
|
||||
resourcePath: '/stalker.xml',
|
||||
}
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
|
||||
// Keep external media pending while the local guide is exercised.
|
||||
});
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: 'Stalker XMLTV Removal',
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
const playlistId = new URL(app.mainWindow.url()).pathname.match(
|
||||
/\/stalker\/([^/]+)/
|
||||
)?.[1];
|
||||
expect(playlistId).toBeTruthy();
|
||||
const key = `stalker:${decodeURIComponent(playlistId!)}:${String(item.id).trim()}`;
|
||||
await app.mainWindow.evaluate(
|
||||
(mappingKey) =>
|
||||
window.electron.setEpgMapping(mappingKey, 'stalker-mapped'),
|
||||
key
|
||||
);
|
||||
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')
|
||||
.fill(source.resourceUrl);
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
app.mainWindow.evaluate(async () =>
|
||||
(
|
||||
await window.electron.getChannelPrograms(
|
||||
'stalker-mapped'
|
||||
)
|
||||
).map((p) => p.title)
|
||||
),
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
.toContain('Retired Stalker Bulletin');
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
const row = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
item.o_name || item.name || ''
|
||||
).first();
|
||||
await expect(row.locator('.epg-title')).toHaveText(
|
||||
'Retired Stalker Bulletin'
|
||||
);
|
||||
await row.click();
|
||||
await expect
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.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 saveSettings(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
await expect(row).toBeVisible();
|
||||
await row.click();
|
||||
await expect
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.not.toContain('Retired Stalker Bulletin');
|
||||
await expect(row.locator('.epg-title')).toHaveCount(0);
|
||||
expect(
|
||||
await app.mainWindow.evaluate(
|
||||
(mappingKey) => window.electron.getEpgMapping(mappingKey),
|
||||
key
|
||||
)
|
||||
).toMatchObject({ epgChannelId: 'stalker-mapped' });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await source.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
test(`@epg @xtream @electron renders Xtream EPG previews and the timeline schedule in ${timeZone}`, async ({
|
||||
dataDir,
|
||||
@@ -66,9 +250,7 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
|
||||
// Provider-declared catch-up (tv_archive=1 in the fixture) is
|
||||
// surfaced as a badge on the sidebar row (#1128).
|
||||
await expect(
|
||||
channelRow.getByTestId('catchup-badge')
|
||||
).toBeVisible();
|
||||
await expect(channelRow.getByTestId('catchup-badge')).toBeVisible();
|
||||
|
||||
// Sidebar channel list shows the per-channel "now" programme line.
|
||||
await expect
|
||||
@@ -263,10 +445,7 @@ test('@radio @stalker @electron keeps radio rows compact at narrow widths', asyn
|
||||
await expect(categoryButton).toBeVisible();
|
||||
await categoryButton.click();
|
||||
|
||||
const radioRow = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
firstTitle
|
||||
).first();
|
||||
const radioRow = channelItemByTitle(app.mainWindow, firstTitle).first();
|
||||
await expect(radioRow).toBeVisible({ timeout: 20000 });
|
||||
await expect(radioRow).toHaveClass(/compact/);
|
||||
await expect(radioRow.locator('.epg-placeholder')).toHaveCount(0);
|
||||
@@ -291,6 +470,95 @@ test('@radio @stalker @electron keeps radio rows compact at narrow widths', asyn
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @xtream @electron shifts the sidebar preview and the timeline "now" block by the EPG display offset', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
|
||||
const offsetMinutes = 60;
|
||||
// The guide runs an hour ahead of the real schedule, so the programme
|
||||
// actually on air is the one the provider files under "an hour ago" —
|
||||
// and every surface must agree on that same programme.
|
||||
const providerNowSeconds =
|
||||
Math.floor(Date.now() / 1000) - offsetMinutes * 60;
|
||||
const expectedProgram = fixture.fullEpg.find(
|
||||
(listing) =>
|
||||
listing.startTimestamp <= providerNowSeconds &&
|
||||
providerNowSeconds < listing.stopTimestamp
|
||||
);
|
||||
if (!expectedProgram) {
|
||||
throw new Error(
|
||||
'Expected the Xtream EPG fixture to cover the shifted current time.'
|
||||
);
|
||||
}
|
||||
const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } });
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.locator('[data-test-id="epg-offset-minutes"]')
|
||||
.fill(String(offsetMinutes));
|
||||
await saveSettings(app.mainWindow);
|
||||
await goToDashboard(app.mainWindow);
|
||||
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: `${epgPortalName} Offset`,
|
||||
username: epgCredentials.username,
|
||||
password: epgCredentials.password,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
const channelRow = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
fixture.stream.name ?? ''
|
||||
).first();
|
||||
await expect(channelRow).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Sidebar preview: the shifted programme, with shifted times.
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(
|
||||
(await channelRow.locator('.epg-title').textContent()) ?? ''
|
||||
).trim()
|
||||
)
|
||||
.toBe(expectedProgram.title);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await channelRow.locator('.epg-time').allInnerTexts()).map(
|
||||
(value) => value.trim()
|
||||
)
|
||||
)
|
||||
.toEqual([
|
||||
formatTimeInZone(
|
||||
expectedProgram.startTimestamp + offsetMinutes * 60,
|
||||
'UTC'
|
||||
),
|
||||
formatTimeInZone(
|
||||
expectedProgram.stopTimestamp + offsetMinutes * 60,
|
||||
'UTC'
|
||||
),
|
||||
]);
|
||||
|
||||
// Timeline: the same programme is the highlighted "now" block.
|
||||
await channelRow.click();
|
||||
await expect(app.mainWindow.locator('app-epg-timeline')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.locator(
|
||||
'app-epg-timeline .epg-timeline__block.is-now .epg-timeline__block-title'
|
||||
)
|
||||
.first()
|
||||
).toHaveText(expectedProgram.title);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @xtream @electron renders the vertical list view when the setting is "list"', async ({
|
||||
dataDir,
|
||||
request,
|
||||
|
||||
@@ -16,6 +16,11 @@ const stressPortalName = 'Stress Xtream Portal';
|
||||
const stressXtreamUsername = 'stress';
|
||||
const stressXtreamPassword = 'stress';
|
||||
const dbWorkerBatchDelayMs = '20';
|
||||
// The stress catalog is 1,920 rows per type; at the production budget of
|
||||
// 5,000 rows per commit that is one commit per type, too few checkpoints and
|
||||
// progress events to observe the import mid-flight. Shrinking the budget
|
||||
// keeps the batches this suite slows down and watches.
|
||||
const dbWorkerRowsPerTransaction = '100';
|
||||
|
||||
function playlistRowByName(name: string) {
|
||||
return `app-playlist-item:has-text("${name}")`;
|
||||
@@ -43,6 +48,8 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
|
||||
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION:
|
||||
dbWorkerRowsPerTransaction,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,6 +128,8 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
|
||||
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION:
|
||||
dbWorkerRowsPerTransaction,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -257,6 +266,8 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
|
||||
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION:
|
||||
dbWorkerRowsPerTransaction,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -318,3 +329,88 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps Live TV scrolling, row actions and resizing independently accessible', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamCatalog(page);
|
||||
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
|
||||
const categories = page.locator('.context-panel .category-item');
|
||||
const category = categories.first();
|
||||
await category.click();
|
||||
const viewport = page.locator('.scroll-viewport-portals');
|
||||
await expect(viewport).toBeVisible();
|
||||
await category.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(viewport).toBeFocused();
|
||||
await expect(page.locator('app-web-player-view')).toHaveCount(0);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(
|
||||
viewport.locator('button.channel-content').first()
|
||||
).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(viewport).toBeFocused();
|
||||
await viewport.locator('.channel-name').first().click();
|
||||
await expect(viewport).toBeFocused();
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(100);
|
||||
const position = await viewport.evaluate((el) => el.scrollTop);
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(position);
|
||||
await page.keyboard.press('Home');
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBe(0);
|
||||
const before = (await viewport.boundingBox())!;
|
||||
const handle = page.locator(
|
||||
'app-live-stream-layout .sidebar > .resize-handle'
|
||||
);
|
||||
const resize = (await handle.boundingBox())!;
|
||||
expect(before.x + before.width).toBeLessThanOrEqual(resize.x);
|
||||
expect(
|
||||
await handle.evaluate((el) =>
|
||||
getComputedStyle(el).getPropertyValue('app-region')
|
||||
)
|
||||
).toBe('no-drag');
|
||||
// Start inside the thumb, below the native Windows up-arrow button.
|
||||
// The 40-channel mock leaves a thumb taller than 100 px here.
|
||||
await page.mouse.move(before.x + before.width - 3, before.y + 40);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(before.x + before.width - 3, before.y + 160, {
|
||||
steps: 10,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(100);
|
||||
expect((await viewport.boundingBox())!.width).toBe(before.width);
|
||||
await page.mouse.move(resize.x + 2, resize.y + 100);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(resize.x + 62, resize.y + 100, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => (await viewport.boundingBox())!.width)
|
||||
.toBeGreaterThan(before.width + 30);
|
||||
await viewport.focus();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(category).toBeFocused();
|
||||
await categories.nth(1).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(categories.nth(1)).toBeFocused();
|
||||
await expect(categories.nth(1)).toHaveAttribute('aria-current', 'true');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(viewport).toBeFocused();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -133,3 +133,67 @@ test.describe('Xtream VOD Details', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
test(`supports keyboard and mouse scrolling in portal details (${theme})`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await page.setViewportSize({ width: 1200, height: 540 });
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
for (const section of ['Movies', 'Series']) {
|
||||
await page
|
||||
.getByRole('link', { name: section, exact: true })
|
||||
.click();
|
||||
await page.locator('app-grid-list mat-card').first().click();
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
await expect(shell).toBeFocused();
|
||||
await page.evaluate(
|
||||
(dark) =>
|
||||
document.body.classList.toggle('dark-theme', dark),
|
||||
theme === 'dark'
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
shell.evaluate(
|
||||
(el) => el.scrollHeight - el.clientHeight
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
expect(
|
||||
await shell.evaluate(
|
||||
(el) => getComputedStyle(el).scrollbarWidth
|
||||
)
|
||||
).not.toBe('none');
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => shell.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
await page.keyboard.press('Home');
|
||||
await expect
|
||||
.poll(() => shell.evaluate((el) => el.scrollTop))
|
||||
.toBe(0);
|
||||
const box = (await shell.boundingBox())!;
|
||||
await page.mouse.move(
|
||||
box.x + box.width / 2,
|
||||
box.y + box.height / 2
|
||||
);
|
||||
await page.mouse.wheel(0, 300);
|
||||
await expect
|
||||
.poll(() => shell.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(shell.locator('.hero__back-button')).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(shell).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
* Usage:
|
||||
* iptvnator_mpv_helper --shm-base /impv-<id> --width 1280 --height 720
|
||||
* [--volume 0..1] [--hwdec auto]
|
||||
* [--mpv-options-stdin]
|
||||
*/
|
||||
#include <mpv/client.h>
|
||||
|
||||
@@ -72,6 +73,9 @@ struct SnapshotState {
|
||||
std::string recordingStartedAt;
|
||||
std::string recordingError;
|
||||
std::string error;
|
||||
/* True when `error` is the engine's own failure (a fatal libmpv log)
|
||||
* rather than the stream's: the app must not reload media for it. */
|
||||
bool engineError = false;
|
||||
};
|
||||
|
||||
struct HelperState {
|
||||
@@ -173,7 +177,10 @@ std::string composeSnapshotLocked() {
|
||||
recording.str("startedAt", s.recordingStartedAt);
|
||||
if (!s.recordingError.empty()) recording.str("error", s.recordingError);
|
||||
writer.raw("recording", recording.finish());
|
||||
if (!s.error.empty()) writer.str("error", s.error);
|
||||
if (!s.error.empty()) {
|
||||
writer.str("error", s.error);
|
||||
if (s.engineError) writer.str("errorOrigin", "engine");
|
||||
}
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
@@ -382,6 +389,7 @@ void runMpvEventLoop() {
|
||||
case MPV_EVENT_START_FILE:
|
||||
s.status = "loading";
|
||||
s.error.clear();
|
||||
s.engineError = false;
|
||||
s.audioTracks.clear();
|
||||
s.selectedAudioTrackId = -1;
|
||||
s.subtitleTracks.clear();
|
||||
@@ -413,6 +421,7 @@ void runMpvEventLoop() {
|
||||
g_state.running.load()) {
|
||||
s.status = "loading";
|
||||
s.error.clear();
|
||||
s.engineError = false;
|
||||
g_state.loadedPath = false;
|
||||
} else if (g_state.running.load()) {
|
||||
s.status = "idle";
|
||||
@@ -440,6 +449,7 @@ void runMpvEventLoop() {
|
||||
}
|
||||
if (level == "fatal") {
|
||||
s.status = "error";
|
||||
s.engineError = true;
|
||||
}
|
||||
emitLine(JsonWriter()
|
||||
.str("event", "log")
|
||||
@@ -486,6 +496,7 @@ void handleLoadCommand(const Command& command) {
|
||||
}
|
||||
g_state.snapshot.streamUrl = url;
|
||||
g_state.snapshot.error.clear();
|
||||
g_state.snapshot.engineError = false;
|
||||
g_state.snapshot.status = "loading";
|
||||
g_state.dirty = true;
|
||||
}
|
||||
@@ -561,6 +572,17 @@ void handleCommand(const Command& command) {
|
||||
const std::string value = std::to_string(seconds);
|
||||
const char* args[] = {"seek", value.c_str(), "absolute", nullptr};
|
||||
mpv_command(g_state.mpv, args);
|
||||
} else if (command.name == "seek-by") {
|
||||
/* Relative step (arrow keys, ±10 s buttons). mpv resolves the delta
|
||||
* against its own playback position and merges relative seeks that
|
||||
* are still queued, so a burst of presses accumulates instead of
|
||||
* collapsing onto one target computed from the renderer's stale
|
||||
* snapshot. */
|
||||
const double delta = command.getDouble("seconds", 0);
|
||||
const std::string value = std::to_string(delta);
|
||||
const char* args[] = {"seek", value.c_str(), "relative+exact",
|
||||
nullptr};
|
||||
mpv_command(g_state.mpv, args);
|
||||
} else if (command.name == "volume") {
|
||||
double percent =
|
||||
std::clamp(command.getDouble("value", 1) * 100.0, 0.0, 100.0);
|
||||
@@ -629,6 +651,12 @@ struct HelperArgs {
|
||||
std::string shmBase = "/impv";
|
||||
std::string hwdec = "auto";
|
||||
std::string audioDelay; /* seconds, mpv audio-delay passthrough */
|
||||
/* Read the session's "key=value" libmpv options (network defaults plus
|
||||
* the user's Settings > Playback lines) from the first stdin line and
|
||||
* apply them after the built-in block so they override it. They never
|
||||
* travel on argv: a credential-bearing option must not be readable
|
||||
* through `ps` or /proc/<pid>/cmdline. */
|
||||
bool mpvOptionsOnStdin = false;
|
||||
int width = 1280;
|
||||
int height = 720;
|
||||
double volume = 1;
|
||||
@@ -648,6 +676,7 @@ HelperArgs parseArgs(int argc, char** argv) {
|
||||
else if (arg == "--volume") args.volume = std::atof(next().c_str());
|
||||
else if (arg == "--hwdec") args.hwdec = next();
|
||||
else if (arg == "--audio-delay") args.audioDelay = next();
|
||||
else if (arg == "--mpv-options-stdin") args.mpvOptionsOnStdin = true;
|
||||
else if (arg == "--runtime-probe") args.runtimeProbe = true;
|
||||
}
|
||||
args.width = std::max(16, args.width);
|
||||
@@ -805,12 +834,65 @@ int main(int argc, char** argv) {
|
||||
mpv_set_option_string(g_state.mpv, "idle", "yes");
|
||||
mpv_set_option_string(g_state.mpv, "input-default-bindings", "no");
|
||||
mpv_set_option_string(g_state.mpv, "osc", "no");
|
||||
/* Keep parity with the native-view addons and the external MPV launch
|
||||
* path: with mpv's youtube-dl hook enabled, a refused HTTP open falls
|
||||
* through to yt-dlp and the session error reads "youtube-dl failed"
|
||||
* instead of the load error. A user `ytdl=yes` session option
|
||||
* (applied below, before mpv_initialize) still overrides it. */
|
||||
mpv_set_option_string(g_state.mpv, "ytdl", "no");
|
||||
if (!args.audioDelay.empty()) {
|
||||
/* Compensates the frame-copy video-path latency to restore
|
||||
* lip-sync; calibrated per platform, see the architecture doc. */
|
||||
mpv_set_option_string(g_state.mpv, "audio-delay",
|
||||
args.audioDelay.c_str());
|
||||
}
|
||||
if (args.mpvOptionsOnStdin) {
|
||||
/* `mpv-options\to000=<key=value>\to001=...` in the regular command
|
||||
* encoding; zero-padded keys keep the application order (network
|
||||
* defaults first, then the user's lines) through the field map. */
|
||||
std::string preamble;
|
||||
if (!std::getline(std::cin, preamble)) {
|
||||
emitLine(JsonWriter()
|
||||
.str("event", "fatal")
|
||||
.str("error", "mpv-options preamble missing on stdin")
|
||||
.finish());
|
||||
return 1;
|
||||
}
|
||||
const frame_helper::Command preambleCommand =
|
||||
frame_helper::parseCommandLine(preamble);
|
||||
if (preambleCommand.name != "mpv-options") {
|
||||
emitLine(JsonWriter()
|
||||
.str("event", "fatal")
|
||||
.str("error", "expected the mpv-options preamble on stdin")
|
||||
.finish());
|
||||
return 1;
|
||||
}
|
||||
std::vector<std::pair<std::string, std::string>> entries(
|
||||
preambleCommand.args.begin(), preambleCommand.args.end());
|
||||
std::sort(entries.begin(), entries.end());
|
||||
for (const auto& entry : entries) {
|
||||
const std::string& option = entry.second;
|
||||
const size_t separator = option.find('=');
|
||||
if (separator == std::string::npos || separator == 0 ||
|
||||
separator + 1 >= option.size()) {
|
||||
continue;
|
||||
}
|
||||
const std::string key = option.substr(0, separator);
|
||||
const std::string value = option.substr(separator + 1);
|
||||
const int optionResult = mpv_set_option_string(
|
||||
g_state.mpv, key.c_str(), value.c_str());
|
||||
if (optionResult < 0) {
|
||||
emitLine(JsonWriter()
|
||||
.str("event", "log")
|
||||
.str("level", "warn")
|
||||
.str("prefix", "iptvnator")
|
||||
.str("text", "rejected session option " + key +
|
||||
": " +
|
||||
mpv_error_string(optionResult))
|
||||
.finish());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mpv_initialize(g_state.mpv) < 0) {
|
||||
emitLine(JsonWriter()
|
||||
.str("event", "fatal")
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
#include <mpv/render.h>
|
||||
#include <mpv/render_gl.h>
|
||||
|
||||
#include "embedded_mpv_extra_options.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
@@ -96,6 +98,12 @@ struct SessionSnapshot {
|
||||
double volumePercent = 100.0;
|
||||
std::string streamUrl;
|
||||
std::string error;
|
||||
// True when `error` is the engine's own failure (render context, fatal
|
||||
// libmpv log) rather than the stream's: the app must not reload for it.
|
||||
bool engineError = false;
|
||||
// Keys of session options libmpv refused at creation (names only, never
|
||||
// values: a value may carry credentials). Reported once by the service.
|
||||
std::vector<std::string> rejectedOptionKeys;
|
||||
std::vector<AudioTrack> audioTracks;
|
||||
int64_t selectedAudioTrackId = -1;
|
||||
std::vector<AudioTrack> subtitleTracks;
|
||||
@@ -203,7 +211,11 @@ std::shared_ptr<Session> getSessionOrThrow(
|
||||
|
||||
void scheduleRender(const std::shared_ptr<Session>& session);
|
||||
void requestRender(const std::shared_ptr<Session>& session);
|
||||
void updateSessionError(const std::shared_ptr<Session>& session, const std::string& error);
|
||||
void updateSessionError(
|
||||
const std::shared_ptr<Session>& session,
|
||||
const std::string& error,
|
||||
bool engineError = false
|
||||
);
|
||||
|
||||
bool isEmbeddedMpvTraceEnabled()
|
||||
{
|
||||
@@ -550,7 +562,8 @@ void renderSoftwareFrame(const std::shared_ptr<Session>& session)
|
||||
if (result < 0) {
|
||||
updateSessionError(
|
||||
session,
|
||||
std::string("Failed to render frame: ") + mpv_error_string(result)
|
||||
std::string("Failed to render frame: ") + mpv_error_string(result),
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -670,7 +683,8 @@ void renderOpenGLFrame(const std::shared_ptr<Session>& session)
|
||||
updateSessionError(
|
||||
session,
|
||||
std::string("Failed to render OpenGL frame: ") +
|
||||
mpv_error_string(result)
|
||||
mpv_error_string(result),
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1035,11 +1049,16 @@ void updateSubtitleTracksFromNode(SessionSnapshot& snapshot, const mpv_node& nod
|
||||
);
|
||||
}
|
||||
|
||||
void updateSessionError(const std::shared_ptr<Session>& session, const std::string& error)
|
||||
void updateSessionError(
|
||||
const std::shared_ptr<Session>& session,
|
||||
const std::string& error,
|
||||
bool engineError
|
||||
)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->snapshot.status = SessionStatus::Error;
|
||||
session->snapshot.error = error;
|
||||
session->snapshot.engineError = engineError;
|
||||
}
|
||||
|
||||
uint64_t nextAsyncRequestId()
|
||||
@@ -1154,6 +1173,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
|
||||
case MPV_EVENT_START_FILE:
|
||||
session->snapshot.status = SessionStatus::Loading;
|
||||
session->snapshot.error.clear();
|
||||
session->snapshot.engineError = false;
|
||||
session->snapshot.audioTracks.clear();
|
||||
session->snapshot.selectedAudioTrackId = -1;
|
||||
session->snapshot.subtitleTracks.clear();
|
||||
@@ -1186,6 +1206,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
|
||||
session->running.load()) {
|
||||
session->snapshot.status = SessionStatus::Loading;
|
||||
session->snapshot.error.clear();
|
||||
session->snapshot.engineError = false;
|
||||
session->loadedPath = false;
|
||||
} else if (session->running.load()) {
|
||||
session->snapshot.status = SessionStatus::Idle;
|
||||
@@ -1353,6 +1374,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
|
||||
}
|
||||
if (level == "fatal") {
|
||||
session->snapshot.status = SessionStatus::Error;
|
||||
session->snapshot.engineError = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1657,6 +1679,25 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
|
||||
std::to_string(session->snapshot.volumePercent);
|
||||
mpv_set_option_string(session->handle, "volume", initialVolume.c_str());
|
||||
|
||||
// Session options from Settings (network defaults first, then the
|
||||
// user's lines). Applied last so a user value overrides a built-in one,
|
||||
// and before mpv_initialize because most of them are init-only.
|
||||
for (const auto& option : iptvnator::readEmbeddedMpvExtraOptions(info, 4)) {
|
||||
const int optionResult = mpv_set_option_string(
|
||||
session->handle,
|
||||
option.first.c_str(),
|
||||
option.second.c_str()
|
||||
);
|
||||
if (optionResult < 0) {
|
||||
traceEmbeddedMpv(
|
||||
session,
|
||||
"rejected session option " + option.first + ": " +
|
||||
mpv_error_string(optionResult)
|
||||
);
|
||||
session->snapshot.rejectedOptionKeys.push_back(option.first);
|
||||
}
|
||||
}
|
||||
|
||||
mpv_request_log_messages(session->handle, "warn");
|
||||
|
||||
const int initializeResult = mpv_initialize(session->handle);
|
||||
@@ -1920,6 +1961,7 @@ Napi::Value LoadPlayback(const Napi::CallbackInfo& info)
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->snapshot.streamUrl = streamUrl;
|
||||
session->snapshot.error.clear();
|
||||
session->snapshot.engineError = false;
|
||||
session->snapshot.status = SessionStatus::Loading;
|
||||
session->snapshot.recordingActive = false;
|
||||
session->snapshot.recordingTargetPath.clear();
|
||||
@@ -2000,9 +2042,13 @@ Napi::Value SetPaused(const Napi::CallbackInfo& info)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->paused = paused != 0;
|
||||
// Optimistic only while playing/paused: a load in flight keeps
|
||||
// `loading` until MPV_EVENT_FILE_LOADED, otherwise a reconnect's
|
||||
// unpause right after loadfile would report the replacement stream
|
||||
// as playing before it opened (and clear the attempt indicator).
|
||||
if (session->loadedPath &&
|
||||
session->snapshot.status != SessionStatus::Ended &&
|
||||
session->snapshot.status != SessionStatus::Error) {
|
||||
(session->snapshot.status == SessionStatus::Playing ||
|
||||
session->snapshot.status == SessionStatus::Paused)) {
|
||||
session->snapshot.status = session->paused
|
||||
? SessionStatus::Paused
|
||||
: SessionStatus::Playing;
|
||||
@@ -2051,6 +2097,50 @@ Napi::Value Seek(const Napi::CallbackInfo& info)
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
Napi::Value SeekBy(const Napi::CallbackInfo& info)
|
||||
{
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
|
||||
throw Napi::TypeError::New(env, "Expected session id and seek delta.");
|
||||
}
|
||||
|
||||
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
|
||||
const auto session = getSessionOrThrow(env, sessionId);
|
||||
const auto delta = info[1].As<Napi::Number>().DoubleValue();
|
||||
const std::string deltaValue = std::to_string(delta);
|
||||
// Relative step (arrow keys, ±10 s buttons): mpv resolves the delta
|
||||
// against its own playback position and merges relative seeks that are
|
||||
// still queued, so a burst of presses accumulates instead of collapsing
|
||||
// onto one target computed from the renderer's stale snapshot.
|
||||
//
|
||||
// Unlike the absolute Seek above, the snapshot is deliberately NOT
|
||||
// advanced here: the event thread may already have stored the observed
|
||||
// post-seek `time-pos` under the same mutex, and adding the delta on top
|
||||
// of that would count the step twice with nothing to correct it while
|
||||
// paused. Only the observed `time-pos` updates the position.
|
||||
const char* command[] = {
|
||||
"seek",
|
||||
deltaValue.c_str(),
|
||||
"relative+exact",
|
||||
nullptr,
|
||||
};
|
||||
const int result = mpv_command_async(
|
||||
session->handle,
|
||||
nextAsyncRequestId(),
|
||||
command
|
||||
);
|
||||
|
||||
if (result < 0) {
|
||||
throw Napi::Error::New(
|
||||
env,
|
||||
std::string("Failed to seek playback: ") +
|
||||
mpv_error_string(result)
|
||||
);
|
||||
}
|
||||
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
Napi::Value SetVolume(const Napi::CallbackInfo& info)
|
||||
{
|
||||
Napi::Env env = info.Env();
|
||||
@@ -2503,6 +2593,20 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
|
||||
|
||||
if (!snapshot.error.empty()) {
|
||||
result.Set("error", Napi::String::New(env, snapshot.error));
|
||||
if (snapshot.engineError) {
|
||||
result.Set("errorOrigin", Napi::String::New(env, "engine"));
|
||||
}
|
||||
}
|
||||
if (!snapshot.rejectedOptionKeys.empty()) {
|
||||
Napi::Array keys =
|
||||
Napi::Array::New(env, snapshot.rejectedOptionKeys.size());
|
||||
for (size_t i = 0; i < snapshot.rejectedOptionKeys.size(); ++i) {
|
||||
keys.Set(
|
||||
static_cast<uint32_t>(i),
|
||||
Napi::String::New(env, snapshot.rejectedOptionKeys[i])
|
||||
);
|
||||
}
|
||||
result.Set("rejectedOptionKeys", keys);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -2540,6 +2644,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports)
|
||||
exports.Set("setBounds", Napi::Function::New(env, SetBounds));
|
||||
exports.Set("setPaused", Napi::Function::New(env, SetPaused));
|
||||
exports.Set("seek", Napi::Function::New(env, Seek));
|
||||
exports.Set("seekBy", Napi::Function::New(env, SeekBy));
|
||||
exports.Set("setVolume", Napi::Function::New(env, SetVolume));
|
||||
exports.Set("setAudioTrack", Napi::Function::New(env, SetAudioTrack));
|
||||
exports.Set(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <napi.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace iptvnator {
|
||||
|
||||
/** One libmpv option name/value pair handed to a session at creation. */
|
||||
using EmbeddedMpvExtraOption = std::pair<std::string, std::string>;
|
||||
|
||||
/**
|
||||
* Reads the optional `key=value` string array `createSession()` receives at
|
||||
* `index`. The Electron side already validated and ordered the lines
|
||||
* (network defaults first, then the user's options minus the keys the embed
|
||||
* depends on — see `resolveEmbeddedMpvSessionOptionArguments`), so this only
|
||||
* splits on the first `=` and skips anything that is not a non-empty pair.
|
||||
* No libmpv call lives here: each engine applies the pairs itself, because
|
||||
* on Linux the libmpv symbols are macros over dynamically resolved pointers.
|
||||
*/
|
||||
inline std::vector<EmbeddedMpvExtraOption> readEmbeddedMpvExtraOptions(
|
||||
const Napi::CallbackInfo& info,
|
||||
size_t index
|
||||
)
|
||||
{
|
||||
std::vector<EmbeddedMpvExtraOption> options;
|
||||
if (info.Length() <= index || !info[index].IsArray()) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const auto entries = info[index].As<Napi::Array>();
|
||||
for (uint32_t i = 0; i < entries.Length(); ++i) {
|
||||
const Napi::Value entry = entries.Get(i);
|
||||
if (!entry.IsString()) {
|
||||
continue;
|
||||
}
|
||||
const std::string line = entry.As<Napi::String>().Utf8Value();
|
||||
const auto separator = line.find('=');
|
||||
if (
|
||||
separator == std::string::npos ||
|
||||
separator == 0 ||
|
||||
separator + 1 >= line.size()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
options.emplace_back(
|
||||
line.substr(0, separator),
|
||||
line.substr(separator + 1)
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
} // namespace iptvnator
|
||||
@@ -5,6 +5,9 @@
|
||||
#include <napi.h>
|
||||
|
||||
#include <mpv/client.h>
|
||||
#include <string>
|
||||
|
||||
#include "embedded_mpv_extra_options.h"
|
||||
|
||||
#ifdef IPTVNATOR_DYNAMIC_LIBMPV
|
||||
#ifndef IPTVNATOR_MPV_SELECTANY
|
||||
@@ -116,6 +119,8 @@ struct SessionSnapshot {
|
||||
double volumePercent = 100.0;
|
||||
std::string streamUrl;
|
||||
std::string error;
|
||||
/** Keys of session options libmpv refused at creation (names only). */
|
||||
std::vector<std::string> rejectedOptionKeys;
|
||||
std::vector<AudioTrack> audioTracks;
|
||||
int64_t selectedAudioTrackId = -1;
|
||||
std::vector<AudioTrack> subtitleTracks;
|
||||
@@ -142,6 +147,8 @@ struct Session {
|
||||
std::string pendingRecordingStartedAt;
|
||||
std::string pendingRecordingStopStartedAt;
|
||||
uint64_t pendingPlaybackLoadRequestId = 0;
|
||||
/** Session options from Settings (network defaults + user lines). */
|
||||
std::vector<iptvnator::EmbeddedMpvExtraOption> extraOptions;
|
||||
#ifdef __linux__
|
||||
pid_t mpvProcessId = -1;
|
||||
std::string mpvIpcSocketPath;
|
||||
@@ -607,6 +614,12 @@ std::vector<std::string> buildLinuxMpvArguments(
|
||||
arguments.push_back("--really-quiet");
|
||||
}
|
||||
|
||||
// Session options come before the per-playback ones below, so a
|
||||
// playlist's own user-agent/headers still win over a global line.
|
||||
for (const auto& option : session->extraOptions) {
|
||||
arguments.push_back("--" + option.first + "=" + option.second);
|
||||
}
|
||||
|
||||
appendLinuxMpvOption(arguments, "force-media-title", title);
|
||||
appendLinuxMpvOption(arguments, "user-agent", userAgent);
|
||||
appendLinuxMpvOption(arguments, "referrer", referer);
|
||||
@@ -983,6 +996,7 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
|
||||
const auto duration = queryLinuxMpvNumber(socketPath, "duration");
|
||||
const auto volume = queryLinuxMpvNumber(socketPath, "volume");
|
||||
const auto paused = queryLinuxMpvBoolean(socketPath, "pause");
|
||||
const auto eofReached = queryLinuxMpvBoolean(socketPath, "eof-reached");
|
||||
const auto path = queryLinuxMpvString(socketPath, "path");
|
||||
const auto trackCount =
|
||||
queryLinuxMpvInteger(socketPath, "track-list/count");
|
||||
@@ -1018,10 +1032,24 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
|
||||
std::max(0.0, std::min(100.0, *volume));
|
||||
}
|
||||
if (paused) {
|
||||
session->snapshot.status =
|
||||
*paused ? SessionStatus::Paused : SessionStatus::Playing;
|
||||
if (*paused) {
|
||||
session->snapshot.status = SessionStatus::Paused;
|
||||
} else if (
|
||||
position ||
|
||||
session->snapshot.status != SessionStatus::Loading
|
||||
) {
|
||||
// `time-pos` only answers once the media is decoding; until then
|
||||
// an unpaused freshly spawned process is still loading.
|
||||
session->snapshot.status = SessionStatus::Playing;
|
||||
}
|
||||
session->snapshot.error.clear();
|
||||
}
|
||||
if (eofReached && *eofReached) {
|
||||
// keep-open=yes parks mpv paused at the end of the stream. Report
|
||||
// that as `ended` like the in-process engines do, so the reconnect
|
||||
// policy can tell a finished live stream from a user pause.
|
||||
session->snapshot.status = SessionStatus::Ended;
|
||||
}
|
||||
if (path && !path->empty()) {
|
||||
session->snapshot.streamUrl = *path;
|
||||
}
|
||||
@@ -1149,7 +1177,10 @@ void loadLinuxProcessPlayback(
|
||||
session->snapshot.durationSeconds = -1.0;
|
||||
session->snapshot.streamUrl = streamUrl;
|
||||
session->snapshot.error.clear();
|
||||
session->snapshot.status = SessionStatus::Playing;
|
||||
// A spawned process has not opened the URL yet: stay `loading` until
|
||||
// the poller sees a decoded position, so a URL that never plays is
|
||||
// never mistaken for one that did (the reconnect policy keys on it).
|
||||
session->snapshot.status = SessionStatus::Loading;
|
||||
session->snapshot.audioTracks.clear();
|
||||
session->snapshot.selectedAudioTrackId = -1;
|
||||
session->snapshot.subtitleTracks.clear();
|
||||
@@ -1654,6 +1685,7 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
|
||||
throw Napi::Error::New(env, NativeVideoHost::lastError());
|
||||
}
|
||||
traceMpvCommon("native video host created");
|
||||
session->extraOptions = iptvnator::readEmbeddedMpvExtraOptions(info, 4);
|
||||
|
||||
#ifdef __linux__
|
||||
session->host.setBounds(bounds);
|
||||
@@ -1705,6 +1737,24 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
|
||||
session->snapshot.volumePercent
|
||||
);
|
||||
mpv_set_option_string(session->handle, "volume", initialVolume.c_str());
|
||||
|
||||
// Session options from Settings (network defaults first, then the
|
||||
// user's lines). Applied last so a user value overrides a built-in one,
|
||||
// and before mpv_initialize because most of them are init-only.
|
||||
for (const auto& option : session->extraOptions) {
|
||||
const int optionResult = mpv_set_option_string(
|
||||
session->handle,
|
||||
option.first.c_str(),
|
||||
option.second.c_str()
|
||||
);
|
||||
if (optionResult < 0) {
|
||||
traceMpvCommon(
|
||||
"rejected session option " + option.first + ": " +
|
||||
mpv_error_string(optionResult)
|
||||
);
|
||||
session->snapshot.rejectedOptionKeys.push_back(option.first);
|
||||
}
|
||||
}
|
||||
mpv_request_log_messages(session->handle, "warn");
|
||||
|
||||
traceMpvCommon("initializing libmpv");
|
||||
@@ -1936,8 +1986,16 @@ Napi::Value SetPaused(const Napi::CallbackInfo& info)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
socketPath = session->mpvIpcSocketPath;
|
||||
session->snapshot.status =
|
||||
paused ? SessionStatus::Paused : SessionStatus::Playing;
|
||||
// Optimistic only while playing/paused: a load in flight or a
|
||||
// finished/failed stream keeps its status until the poller says
|
||||
// otherwise, like the in-process engines' pause handling.
|
||||
if (
|
||||
session->snapshot.status == SessionStatus::Playing ||
|
||||
session->snapshot.status == SessionStatus::Paused
|
||||
) {
|
||||
session->snapshot.status =
|
||||
paused ? SessionStatus::Paused : SessionStatus::Playing;
|
||||
}
|
||||
}
|
||||
if (!socketPath.empty()) {
|
||||
sendLinuxMpvCommand(
|
||||
@@ -2000,6 +2058,72 @@ Napi::Value Seek(const Napi::CallbackInfo& info)
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
Napi::Value SeekBy(const Napi::CallbackInfo& info)
|
||||
{
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
|
||||
throw Napi::TypeError::New(env, "Expected session id and seek delta.");
|
||||
}
|
||||
const auto session =
|
||||
getSessionOrThrow(env, info[0].As<Napi::String>().Utf8Value());
|
||||
const double delta = info[1].As<Napi::Number>().DoubleValue();
|
||||
const std::string seconds = formatInvariantDouble(delta);
|
||||
// Relative step (arrow keys, ±10 s buttons): mpv resolves the delta
|
||||
// against its own playback position and merges relative seeks that are
|
||||
// still queued, so a burst of presses accumulates instead of collapsing
|
||||
// onto one target computed from the renderer's stale snapshot.
|
||||
//
|
||||
// Unlike the absolute Seek above, the snapshot is deliberately NOT
|
||||
// advanced here: the observer (event thread or Linux IPC poll) may
|
||||
// already have stored the post-seek `time-pos`, and adding the delta on
|
||||
// top of that would count the step twice with nothing to correct it
|
||||
// while paused; on Linux it would also advertise a position that a
|
||||
// failed socket delivery never reached. Only the observed `time-pos`
|
||||
// updates the position.
|
||||
//
|
||||
// A step that cannot be delivered rejects the IPC, like a failed
|
||||
// `mpv_command_async` on the in-process engines: the renderer swallows
|
||||
// the rejection and resyncs from the next snapshot, and the main process
|
||||
// logs it, instead of a silent no-op that looks like a seek still
|
||||
// awaiting observation.
|
||||
#ifdef __linux__
|
||||
std::string socketPath;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
socketPath = session->mpvIpcSocketPath;
|
||||
}
|
||||
if (socketPath.empty()) {
|
||||
throw Napi::Error::New(
|
||||
env,
|
||||
"Failed to seek playback: the mpv IPC socket is not available yet."
|
||||
);
|
||||
}
|
||||
if (!sendLinuxMpvCommand(
|
||||
socketPath,
|
||||
"{\"command\":[\"seek\"," + seconds + ",\"relative+exact\"]}\n"
|
||||
)) {
|
||||
throw Napi::Error::New(
|
||||
env,
|
||||
"Failed to seek playback: the mpv IPC socket did not accept the "
|
||||
"seek command."
|
||||
);
|
||||
}
|
||||
return env.Undefined();
|
||||
#endif
|
||||
const char* command[] = {
|
||||
"seek", seconds.c_str(), "relative+exact", nullptr
|
||||
};
|
||||
const int result = mpv_command_async(
|
||||
session->handle,
|
||||
nextAsyncRequestId(),
|
||||
command
|
||||
);
|
||||
if (result < 0) {
|
||||
throw Napi::Error::New(env, mpv_error_string(result));
|
||||
}
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
Napi::Value SetVolume(const Napi::CallbackInfo& info)
|
||||
{
|
||||
Napi::Env env = info.Env();
|
||||
@@ -2326,6 +2450,17 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
|
||||
if (!snapshot.error.empty()) {
|
||||
result.Set("error", Napi::String::New(env, snapshot.error));
|
||||
}
|
||||
if (!snapshot.rejectedOptionKeys.empty()) {
|
||||
Napi::Array keys =
|
||||
Napi::Array::New(env, snapshot.rejectedOptionKeys.size());
|
||||
for (size_t i = 0; i < snapshot.rejectedOptionKeys.size(); ++i) {
|
||||
keys.Set(
|
||||
static_cast<uint32_t>(i),
|
||||
Napi::String::New(env, snapshot.rejectedOptionKeys[i])
|
||||
);
|
||||
}
|
||||
result.Set("rejectedOptionKeys", keys);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2360,6 +2495,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports)
|
||||
exports.Set("setBounds", Napi::Function::New(env, SetBounds));
|
||||
exports.Set("setPaused", Napi::Function::New(env, SetPaused));
|
||||
exports.Set("seek", Napi::Function::New(env, Seek));
|
||||
exports.Set("seekBy", Napi::Function::New(env, SeekBy));
|
||||
exports.Set("setVolume", Napi::Function::New(env, SetVolume));
|
||||
exports.Set("setAudioTrack", Napi::Function::New(env, SetAudioTrack));
|
||||
#ifndef __linux__
|
||||
|
||||
@@ -82,6 +82,18 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
|
||||
channel: 'DB_UPSERT_APP_PLAYLIST',
|
||||
forwardedArgs: [playlist],
|
||||
},
|
||||
{
|
||||
method: 'dbRecoverLegacyPlaylists',
|
||||
args: [],
|
||||
channel: 'DB_RECOVER_LEGACY_PLAYLISTS',
|
||||
forwardedArgs: [],
|
||||
},
|
||||
{
|
||||
method: 'dbMigrateAppPlaylists',
|
||||
args: [playlists],
|
||||
channel: 'DB_MIGRATE_APP_PLAYLISTS',
|
||||
forwardedArgs: [playlists],
|
||||
},
|
||||
{
|
||||
method: 'dbUpsertAppPlaylists',
|
||||
args: [playlists],
|
||||
@@ -524,6 +536,12 @@ export const epgPreloadCases: PreloadInvokeCase[] = [
|
||||
channel: 'EPG_CLEAR_ALL',
|
||||
forwardedArgs: [],
|
||||
},
|
||||
{
|
||||
method: 'reconcileEpgSources',
|
||||
args: [epgUrls],
|
||||
channel: 'EPG_RECONCILE_SOURCES',
|
||||
forwardedArgs: [{ urls: epgUrls }],
|
||||
},
|
||||
{
|
||||
method: 'clearEpgDataForSource',
|
||||
args: ['https://example.com/guide.xml'],
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
ElectronBridgeDbOperationEvent,
|
||||
ElectronBridgeDownloadStartPayload,
|
||||
ElectronBridgeCurrentProgramsOptions,
|
||||
ElectronBridgeEpgLookupOptions,
|
||||
ElectronBridgeEpgProgress,
|
||||
ElectronBridgePlaybackPositionInput,
|
||||
@@ -45,6 +46,7 @@ import type {
|
||||
ElectronBridgeRemoteControlCommand,
|
||||
ElectronBridgeRemoteControlStatus,
|
||||
ElectronBridgeTrustOptions,
|
||||
ElectronBridgePlaylistFetchOptions,
|
||||
ElectronBridgeWindowState,
|
||||
ElectronBridgeXtreamContentStream,
|
||||
ExternalPlayerSession,
|
||||
@@ -80,6 +82,7 @@ const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT';
|
||||
const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT';
|
||||
const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE';
|
||||
const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
|
||||
const WINDOW_TOGGLE_FULLSCREEN = 'WINDOW:TOGGLE_FULLSCREEN';
|
||||
const WINDOW_CLOSE = 'WINDOW:CLOSE';
|
||||
const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
|
||||
const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
|
||||
@@ -414,6 +417,7 @@ const electronApi: ElectronBridgeApi = {
|
||||
},
|
||||
minimizeWindow: () => ipcRenderer.invoke(WINDOW_MINIMIZE),
|
||||
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
|
||||
toggleFullScreenWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_FULLSCREEN),
|
||||
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
|
||||
getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE),
|
||||
onWindowStateChange: (
|
||||
@@ -444,7 +448,7 @@ const electronApi: ElectronBridgeApi = {
|
||||
fetchPlaylistByUrl: (
|
||||
url: string,
|
||||
title?: string,
|
||||
options?: ElectronBridgeTrustOptions
|
||||
options?: ElectronBridgePlaylistFetchOptions
|
||||
) => ipcRenderer.invoke('fetch-playlist-by-url', url, title, options),
|
||||
updatePlaylistFromFilePath: (filePath: string, title: string) =>
|
||||
ipcRenderer.invoke('update-playlist-from-file-path', filePath, title),
|
||||
@@ -569,6 +573,11 @@ const electronApi: ElectronBridgeApi = {
|
||||
seconds: number
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SEEK', sessionId, seconds),
|
||||
seekEmbeddedMpvBy: (
|
||||
sessionId: string,
|
||||
deltaSeconds: number
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SEEK_BY', sessionId, deltaSeconds),
|
||||
setEmbeddedMpvVolume: (
|
||||
sessionId: string,
|
||||
volume: number
|
||||
@@ -606,11 +615,7 @@ const electronApi: ElectronBridgeApi = {
|
||||
sessionId: string,
|
||||
style: EmbeddedMpvSubtitleStyle
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke(
|
||||
'EMBEDDED_MPV_SET_SUBTITLE_STYLE',
|
||||
sessionId,
|
||||
style
|
||||
),
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SET_SUBTITLE_STYLE', sessionId, style),
|
||||
selectEmbeddedMpvSubtitleFile: (): Promise<string | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SELECT_SUBTITLE_FILE'),
|
||||
setEmbeddedMpvSpeed: (
|
||||
@@ -655,7 +660,7 @@ const electronApi: ElectronBridgeApi = {
|
||||
) => ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId, options }),
|
||||
getCurrentProgramsBatch: (
|
||||
channelIds: string[],
|
||||
options?: ElectronBridgeEpgLookupOptions
|
||||
options?: ElectronBridgeCurrentProgramsOptions
|
||||
) =>
|
||||
ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', {
|
||||
channelIds,
|
||||
@@ -672,6 +677,8 @@ const electronApi: ElectronBridgeApi = {
|
||||
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
|
||||
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
|
||||
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
|
||||
reconcileEpgSources: (urls: string[]) =>
|
||||
ipcRenderer.invoke('EPG_RECONCILE_SOURCES', { urls }),
|
||||
clearEpgDataForSource: (sourceUrl: string) =>
|
||||
ipcRenderer.invoke('EPG_CLEAR_SOURCE', { sourceUrl }),
|
||||
checkEpgFreshness: (urls: string[], maxAgeHours?: number) =>
|
||||
@@ -741,6 +748,10 @@ const electronApi: ElectronBridgeApi = {
|
||||
ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId),
|
||||
dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) =>
|
||||
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist),
|
||||
dbMigrateAppPlaylists: (playlists: Playlist[]) =>
|
||||
ipcRenderer.invoke('DB_MIGRATE_APP_PLAYLISTS', playlists),
|
||||
dbRecoverLegacyPlaylists: () =>
|
||||
ipcRenderer.invoke('DB_RECOVER_LEGACY_PLAYLISTS'),
|
||||
dbUpsertAppPlaylists: (playlists: Playlist[]) =>
|
||||
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists),
|
||||
dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'),
|
||||
@@ -1105,8 +1116,7 @@ const electronApi: ElectronBridgeApi = {
|
||||
recordingsUpdatePrograms: (
|
||||
targetPath: string,
|
||||
programs: RecordingProgramSnapshot[]
|
||||
) =>
|
||||
ipcRenderer.invoke('RECORDINGS_UPDATE_PROGRAMS', targetPath, programs),
|
||||
) => ipcRenderer.invoke('RECORDINGS_UPDATE_PROGRAMS', targetPath, programs),
|
||||
recordingsRevealFile: (filePath: string) =>
|
||||
ipcRenderer.invoke('RECORDINGS_REVEAL_FILE', filePath),
|
||||
recordingsPlayFile: (filePath: string) =>
|
||||
|
||||
@@ -201,6 +201,69 @@ describe('window state change pushes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps isFullScreen=true when the player leaves HTML fullscreen inside a natively fullscreen window', () => {
|
||||
const win = createMockStateWindow();
|
||||
// A fullscreen launch (or F11 before attach) — the window is
|
||||
// natively fullscreen when the events are wired up.
|
||||
win.isFullScreen.mockReturnValue(true);
|
||||
attachWindowStateEvents(win);
|
||||
|
||||
// Electron sees the window is already fullscreen, so entering HTML
|
||||
// fullscreen only emits the html variant …
|
||||
fireWindowEvent(win, 'enter-html-full-screen');
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: true }
|
||||
);
|
||||
|
||||
// … and leaving it restores ONLY the HTML state: no
|
||||
// 'leave-full-screen' fires and the window stays fullscreen. A
|
||||
// single flag would un-hide the window controls here.
|
||||
fireWindowEvent(win, 'leave-html-full-screen');
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: true }
|
||||
);
|
||||
|
||||
// F11 afterwards is what actually leaves fullscreen.
|
||||
fireWindowEvent(win, 'leave-full-screen');
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a native fullscreen entered by event across a player HTML fullscreen round-trip', () => {
|
||||
const win = createMockStateWindow();
|
||||
attachWindowStateEvents(win);
|
||||
|
||||
fireWindowEvent(win, 'enter-full-screen');
|
||||
fireWindowEvent(win, 'enter-html-full-screen');
|
||||
fireWindowEvent(win, 'leave-html-full-screen');
|
||||
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('clears isFullScreen when both the native and the HTML state are left', () => {
|
||||
const win = createMockStateWindow();
|
||||
attachWindowStateEvents(win);
|
||||
|
||||
// Windows/Linux HTML fullscreen without prior native fullscreen:
|
||||
// Electron toggles the native window too, so both pairs fire.
|
||||
fireWindowEvent(win, 'enter-full-screen');
|
||||
fireWindowEvent(win, 'enter-html-full-screen');
|
||||
fireWindowEvent(win, 'leave-full-screen');
|
||||
fireWindowEvent(win, 'leave-html-full-screen');
|
||||
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the fullscreen flag when a maximize event arrives mid-transition', () => {
|
||||
const win = createMockStateWindow();
|
||||
win.isFullScreen.mockReturnValue(true);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
const mockClearStorageData = jest.fn();
|
||||
const mockIsFrameCopyRuntimeUsable = jest.fn<boolean, []>();
|
||||
const mockIsEmbeddedMpvFeatureEnabled = jest.fn<boolean, []>();
|
||||
const mockHasSwitch = jest.fn<boolean, [string]>();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
commandLine: {
|
||||
hasSwitch: mockHasSwitch,
|
||||
},
|
||||
getPath: jest.fn(() => '/tmp'),
|
||||
isPackaged: false,
|
||||
isReady: jest.fn(() => false),
|
||||
@@ -31,6 +35,7 @@ jest.mock('./services/store.service', () => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
STARTUP_WINDOW_MODE: 'startupWindowMode',
|
||||
WINDOW_BOUNDS: 'windowBounds',
|
||||
}));
|
||||
|
||||
@@ -53,6 +58,7 @@ import { app as electronApp, BrowserWindow, screen } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { store } from './services/store.service';
|
||||
import { getPendingFullScreenTarget } from './services/native-fullscreen-transitions';
|
||||
|
||||
type MockMainWindow = {
|
||||
center: jest.Mock<void, []>;
|
||||
@@ -64,8 +70,10 @@ type MockMainWindow = {
|
||||
isMaximized: jest.Mock<boolean, []>;
|
||||
loadFile: jest.Mock<Promise<void>, [string]>;
|
||||
loadURL: jest.Mock<Promise<void>, [string]>;
|
||||
maximize: jest.Mock<void, []>;
|
||||
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
once: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
setFullScreen: jest.Mock<void, [boolean]>;
|
||||
setMenu: jest.Mock<void, [unknown]>;
|
||||
show: jest.Mock<void, []>;
|
||||
webContents: {
|
||||
@@ -83,9 +91,11 @@ function createMockMainWindow(): MockMainWindow {
|
||||
isMaximized: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
loadFile: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
|
||||
loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
|
||||
maximize: jest.fn<void, []>(),
|
||||
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
setFullScreen: jest.fn<void, [boolean]>(),
|
||||
setMenu: jest.fn<void, [unknown]>(),
|
||||
show: jest.fn<void, []>(),
|
||||
webContents: {
|
||||
@@ -97,6 +107,7 @@ function createMockMainWindow(): MockMainWindow {
|
||||
}
|
||||
|
||||
type AppInternals = {
|
||||
launchFullscreenSwitchConsumed: boolean;
|
||||
loadedMainWindow: MockMainWindow | null;
|
||||
mainWindow: MockMainWindow | null;
|
||||
mainWindowListeners: Array<(mainWindow: MockMainWindow) => void>;
|
||||
@@ -119,6 +130,7 @@ describe('Electron app security helpers', () => {
|
||||
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(false);
|
||||
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
|
||||
const appInternals = getAppInternals();
|
||||
appInternals.launchFullscreenSwitchConsumed = false;
|
||||
appInternals.loadedMainWindow = null;
|
||||
appInternals.mainWindow = null;
|
||||
appInternals.mainWindowLoadPromise = null;
|
||||
@@ -129,6 +141,7 @@ describe('Electron app security helpers', () => {
|
||||
workAreaSize: { height: 720, width: 1280 },
|
||||
});
|
||||
(store.get as jest.Mock).mockReturnValue(undefined);
|
||||
mockHasSwitch.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('creates an explicitly hardened BrowserWindow webPreferences object', () => {
|
||||
@@ -310,6 +323,170 @@ describe('Electron app security helpers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('startup window mode', () => {
|
||||
function createWindowViaOnReady(): MockMainWindow {
|
||||
const mainWindow = createMockMainWindow();
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
|
||||
getAppInternals().onReady();
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function fireReadyToShow(mainWindow: MockMainWindow): void {
|
||||
const handlers = mainWindow.once.mock.calls
|
||||
.filter(([eventName]) => eventName === 'ready-to-show')
|
||||
.map(([, handler]) => handler);
|
||||
|
||||
expect(handlers).toHaveLength(1);
|
||||
handlers[0]();
|
||||
}
|
||||
|
||||
function storeStartupWindowMode(mode: string): void {
|
||||
(store.get as jest.Mock).mockImplementation((key: string) =>
|
||||
key === 'startupWindowMode' ? mode : undefined
|
||||
);
|
||||
}
|
||||
|
||||
it('creates a plain window when nothing is stored', () => {
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
|
||||
fireReadyToShow(mainWindow);
|
||||
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
expect(mainWindow.setFullScreen).not.toHaveBeenCalled();
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates the window fullscreen when the stored mode says so', () => {
|
||||
storeStartupWindowMode('fullscreen');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
// Windows/Linux honour the constructor option while the window
|
||||
// is still hidden.
|
||||
mainWindow.isFullScreen.mockReturnValue(true);
|
||||
|
||||
// Still created hidden: fullscreen is entered before the first
|
||||
// paint, and ready-to-show reveals it like any other launch.
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fullscreen: true, show: false })
|
||||
);
|
||||
fireReadyToShow(mainWindow);
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
// Already fullscreen: no second request, which would animate a
|
||||
// toggle on top of the one that took.
|
||||
expect(mainWindow.setFullScreen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('repeats the fullscreen request after show() where the constructor option did not take', () => {
|
||||
storeStartupWindowMode('fullscreen');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
// macOS: an NSWindow only toggles fullscreen once it is on
|
||||
// screen, so a hidden window ignores the constructor option.
|
||||
mainWindow.isFullScreen.mockReturnValue(false);
|
||||
|
||||
fireReadyToShow(mainWindow);
|
||||
|
||||
expect(mainWindow.setFullScreen).toHaveBeenCalledTimes(1);
|
||||
expect(mainWindow.setFullScreen).toHaveBeenCalledWith(true);
|
||||
// Registered with the transition tracker, so an F11 pressed
|
||||
// during the animation is decided against this target and
|
||||
// leaves fullscreen instead of requesting it again.
|
||||
expect(
|
||||
getPendingFullScreenTarget(
|
||||
mainWindow as unknown as Electron.BrowserWindow
|
||||
)
|
||||
).toBe(true);
|
||||
expect(mainWindow.show.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mainWindow.setFullScreen.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('maximizes only once the window is ready to show, before revealing it', () => {
|
||||
storeStartupWindowMode('maximized');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
// maximize() on a hidden window shows it — a blank flash if it
|
||||
// ran here.
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
|
||||
fireReadyToShow(mainWindow);
|
||||
|
||||
expect(mainWindow.maximize).toHaveBeenCalledTimes(1);
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mainWindow.maximize.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(mainWindow.show.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('lets --fullscreen override the stored mode for this launch without persisting it', () => {
|
||||
storeStartupWindowMode('maximized');
|
||||
mockHasSwitch.mockImplementation((name) => name === 'fullscreen');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(mainWindow);
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
expect(store.set).not.toHaveBeenCalledWith(
|
||||
'startupWindowMode',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes --fullscreen with the first window so a window re-created from the macOS Dock follows the stored setting', () => {
|
||||
storeStartupWindowMode('maximized');
|
||||
mockHasSwitch.mockImplementation((name) => name === 'fullscreen');
|
||||
|
||||
const firstWindow = createWindowViaOnReady();
|
||||
expect(BrowserWindow).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(firstWindow);
|
||||
|
||||
// The user left fullscreen and closed the only window; the
|
||||
// process survived and the Dock brings the window back.
|
||||
const appInternals = getAppInternals();
|
||||
appInternals.mainWindow = null;
|
||||
const secondWindow = createMockMainWindow();
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(
|
||||
secondWindow
|
||||
);
|
||||
|
||||
App.ensureMainWindow();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledTimes(2);
|
||||
expect(BrowserWindow).toHaveBeenLastCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(secondWindow);
|
||||
expect(secondWindow.setFullScreen).not.toHaveBeenCalled();
|
||||
expect(secondWindow.maximize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats a hand-edited unknown stored mode as a plain window', () => {
|
||||
storeStartupWindowMode('kiosk');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(mainWindow);
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates the main window immediately when Electron is already ready', () => {
|
||||
const mainWindow = createMockMainWindow();
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
|
||||
|
||||
@@ -12,9 +12,21 @@ import {
|
||||
isWindowTraceEnabled,
|
||||
trace,
|
||||
} from './services/debug-trace';
|
||||
import { store, WINDOW_BOUNDS } from './services/store.service';
|
||||
import {
|
||||
STARTUP_WINDOW_MODE,
|
||||
store,
|
||||
WINDOW_BOUNDS,
|
||||
} from './services/store.service';
|
||||
import { isFrameCopyRuntimeUsable } from './services/embedded-mpv-frame-copy-platform.util';
|
||||
import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util';
|
||||
import {
|
||||
FULLSCREEN_LAUNCH_SWITCH,
|
||||
resolveStartupWindowMode,
|
||||
} from './services/startup-window-mode';
|
||||
import {
|
||||
requestFullScreen,
|
||||
trackNativeFullScreen,
|
||||
} from './services/native-fullscreen-transitions';
|
||||
|
||||
const externalBrowserProtocols = new Set(['http:', 'https:']);
|
||||
const trustedDevRendererHosts = new Set([
|
||||
@@ -223,6 +235,13 @@ export default class App {
|
||||
static BrowserWindow;
|
||||
private static loadedMainWindow: Electron.BrowserWindow | null = null;
|
||||
private static mainWindowLoadPromise: Promise<void> | null = null;
|
||||
/**
|
||||
* Whether the `--fullscreen` launch switch has already shaped a window.
|
||||
* See initMainWindow: the switch is consumed by the first window so a
|
||||
* window re-created later in the same process (macOS Dock) follows the
|
||||
* stored setting instead.
|
||||
*/
|
||||
private static launchFullscreenSwitchConsumed = false;
|
||||
private static rendererLoadingEnabled = false;
|
||||
|
||||
private static shouldOpenDevTools() {
|
||||
@@ -384,6 +403,16 @@ export default class App {
|
||||
isFullScreen: win.isFullScreen(),
|
||||
};
|
||||
|
||||
// Native (OS-level) and HTML-element fullscreen are tracked apart
|
||||
// and OR-ed into the pushed flag. Electron remembers when the window
|
||||
// was already natively fullscreen before the player entered HTML
|
||||
// fullscreen and then leaves ONLY the HTML state on exit — no
|
||||
// 'leave-full-screen' fires and the window stays fullscreen. A single
|
||||
// flag cleared by 'leave-html-full-screen' would un-hide the window
|
||||
// controls over a window that is still fullscreen, which a fullscreen
|
||||
// launch or F11 followed by the player's F → Esc makes routine.
|
||||
const fullscreen = { native: state.isFullScreen, html: false };
|
||||
|
||||
const push = (patch: Partial<ElectronBridgeWindowState>) => {
|
||||
Object.assign(state, patch);
|
||||
|
||||
@@ -395,16 +424,30 @@ export default class App {
|
||||
// mutations of the tracked state.
|
||||
win.webContents.send(WINDOW_STATE_CHANGED, { ...state });
|
||||
};
|
||||
const pushFullScreen = () =>
|
||||
push({ isFullScreen: fullscreen.native || fullscreen.html });
|
||||
|
||||
win.on('maximize', () => push({ isMaximized: true }));
|
||||
win.on('unmaximize', () => push({ isMaximized: false }));
|
||||
// The html variants cover HTML-element fullscreen; not every
|
||||
// platform/trigger emits both pairs, and duplicate pushes with the
|
||||
// same payload are harmless.
|
||||
win.on('enter-full-screen', () => push({ isFullScreen: true }));
|
||||
win.on('enter-html-full-screen', () => push({ isFullScreen: true }));
|
||||
win.on('leave-full-screen', () => push({ isFullScreen: false }));
|
||||
win.on('leave-html-full-screen', () => push({ isFullScreen: false }));
|
||||
win.on('enter-full-screen', () => {
|
||||
fullscreen.native = true;
|
||||
pushFullScreen();
|
||||
});
|
||||
win.on('leave-full-screen', () => {
|
||||
fullscreen.native = false;
|
||||
pushFullScreen();
|
||||
});
|
||||
win.on('enter-html-full-screen', () => {
|
||||
fullscreen.html = true;
|
||||
pushFullScreen();
|
||||
});
|
||||
win.on('leave-html-full-screen', () => {
|
||||
fullscreen.html = false;
|
||||
pushFullScreen();
|
||||
});
|
||||
}
|
||||
|
||||
private static initMainWindow() {
|
||||
@@ -413,6 +456,17 @@ export default class App {
|
||||
const height = Math.min(720, workAreaSize.height || 720);
|
||||
|
||||
const savedWindowBounds = store.get(WINDOW_BOUNDS);
|
||||
const startupWindowMode = resolveStartupWindowMode({
|
||||
// One-shot: the switch describes the launch, not every window
|
||||
// this process ever opens. On macOS the process outlives its
|
||||
// last window and the Dock re-creates it through this same
|
||||
// path, which must then follow the stored setting only.
|
||||
cliHasFullscreenSwitch:
|
||||
!App.launchFullscreenSwitchConsumed &&
|
||||
app.commandLine.hasSwitch(FULLSCREEN_LAUNCH_SWITCH),
|
||||
storedMode: store.get(STARTUP_WINDOW_MODE),
|
||||
});
|
||||
App.launchFullscreenSwitchConsumed = true;
|
||||
|
||||
// Create the browser window.
|
||||
App.mainWindow = new BrowserWindow({
|
||||
@@ -422,6 +476,12 @@ export default class App {
|
||||
show: false,
|
||||
webPreferences: getMainWindowWebPreferences(),
|
||||
...savedWindowBounds,
|
||||
// Fullscreen is a constructor option: the window is created
|
||||
// hidden and enters fullscreen before its first paint. The saved
|
||||
// bounds stay spread in above — they are the normal bounds the
|
||||
// window returns to, and the close handler keeps persisting
|
||||
// getNormalBounds(), so a fullscreen session never corrupts them.
|
||||
...(startupWindowMode === 'fullscreen' ? { fullscreen: true } : {}),
|
||||
minHeight: 600,
|
||||
minWidth: 900,
|
||||
...App.getPlatformTitleBarOptions(),
|
||||
@@ -429,6 +489,9 @@ export default class App {
|
||||
App.mainWindow.setMenu(null);
|
||||
attachWindowTrace(App.mainWindow);
|
||||
App.attachWindowStateEvents(App.mainWindow);
|
||||
// Seeds the F11 tracker's fullscreen state now, while no transition
|
||||
// can be in flight; from here on it follows the window's events.
|
||||
trackNativeFullScreen(App.mainWindow);
|
||||
App.notifyMainWindowCreated(App.mainWindow);
|
||||
if (!savedWindowBounds) {
|
||||
App.mainWindow.center();
|
||||
@@ -436,7 +499,27 @@ export default class App {
|
||||
|
||||
// if main window is ready to show, close the splash window and show the main window
|
||||
App.mainWindow.once('ready-to-show', () => {
|
||||
// maximize() on a hidden window shows it (Electron docs), so it
|
||||
// has to wait for ready-to-show like show() does — any earlier
|
||||
// and a blank window flashes before the renderer paints.
|
||||
if (startupWindowMode === 'maximized') {
|
||||
App.mainWindow.maximize();
|
||||
}
|
||||
App.mainWindow.show();
|
||||
// macOS ignores the constructor's `fullscreen` while the window
|
||||
// is hidden — an NSWindow can only toggle fullscreen once it is
|
||||
// on screen — so the request is repeated after show() wherever
|
||||
// it has not taken yet. Windows/Linux honoured it at creation
|
||||
// (before the first paint) and are left alone. It goes through
|
||||
// the transition tracker so an F11 pressed during the animation
|
||||
// reads the pending target and leaves fullscreen instead of
|
||||
// asking for it again.
|
||||
if (
|
||||
startupWindowMode === 'fullscreen' &&
|
||||
!App.mainWindow.isFullScreen()
|
||||
) {
|
||||
requestFullScreen(App.mainWindow, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Route target="_blank" / window.open() to the OS default browser
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { mockDrizzle, mockDrizzleOrmModule } from './operations.test-helpers';
|
||||
|
||||
jest.mock('drizzle-orm', () => mockDrizzleOrmModule());
|
||||
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
countContentRowsByCategory,
|
||||
deleteCategoriesWhere,
|
||||
deleteContentByCategoryGroups,
|
||||
groupCategoriesByRowBudget,
|
||||
requireScopedFilter,
|
||||
resolveContentRowsPerTransaction,
|
||||
sumCategoryRowCounts,
|
||||
} from './catalog-deletion';
|
||||
|
||||
function createDeleteDb(changesForGroup: (group: number[]) => number) {
|
||||
const groups: number[][] = [];
|
||||
const tables: unknown[] = [];
|
||||
const deleteRows = jest.fn((table: unknown) => {
|
||||
tables.push(table);
|
||||
return {
|
||||
where: jest.fn((condition: { values: number[] }) => {
|
||||
groups.push(condition.values);
|
||||
return {
|
||||
run: jest.fn(() => ({
|
||||
changes: changesForGroup(condition.values),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
const transaction = jest.fn((execute: (tx: unknown) => unknown) =>
|
||||
execute({ delete: deleteRows })
|
||||
);
|
||||
|
||||
return {
|
||||
db: { transaction } as unknown as AppDatabase,
|
||||
groups,
|
||||
tables,
|
||||
transaction,
|
||||
};
|
||||
}
|
||||
|
||||
describe('groupCategoriesByRowBudget', () => {
|
||||
it('packs consecutive categories until the next one would exceed the budget', () => {
|
||||
expect(
|
||||
groupCategoriesByRowBudget(
|
||||
[
|
||||
{ categoryId: 1, rowCount: 3000 },
|
||||
{ categoryId: 2, rowCount: 1500 },
|
||||
{ categoryId: 3, rowCount: 600 },
|
||||
{ categoryId: 4, rowCount: 400 },
|
||||
],
|
||||
5000
|
||||
)
|
||||
).toEqual([[1, 2], [3, 4]]);
|
||||
});
|
||||
|
||||
it('gives a category larger than the budget a group of its own', () => {
|
||||
expect(
|
||||
groupCategoriesByRowBudget(
|
||||
[
|
||||
{ categoryId: 1, rowCount: 10 },
|
||||
{ categoryId: 2, rowCount: 9000 },
|
||||
{ categoryId: 3, rowCount: 10 },
|
||||
],
|
||||
5000
|
||||
)
|
||||
).toEqual([[1], [2], [3]]);
|
||||
});
|
||||
|
||||
it('skips categories without content and returns nothing for none', () => {
|
||||
expect(
|
||||
groupCategoriesByRowBudget(
|
||||
[
|
||||
{ categoryId: 1, rowCount: 0 },
|
||||
{ categoryId: 2, rowCount: 5 },
|
||||
],
|
||||
5000
|
||||
)
|
||||
).toEqual([[2]]);
|
||||
expect(groupCategoriesByRowBudget([], 5000)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveContentRowsPerTransaction', () => {
|
||||
it('defaults to 5,000 rows unless the test knob names a positive integer', () => {
|
||||
expect(resolveContentRowsPerTransaction(undefined)).toBe(5000);
|
||||
expect(resolveContentRowsPerTransaction('')).toBe(5000);
|
||||
expect(resolveContentRowsPerTransaction('0')).toBe(5000);
|
||||
expect(resolveContentRowsPerTransaction('-100')).toBe(5000);
|
||||
expect(resolveContentRowsPerTransaction('abc')).toBe(5000);
|
||||
expect(resolveContentRowsPerTransaction('100')).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireScopedFilter', () => {
|
||||
it('refuses the unbounded filter drizzle returns for an empty and()', () => {
|
||||
expect(() => requireScopedFilter(undefined)).toThrow(
|
||||
'Refusing an unscoped catalog delete'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes a real filter through', () => {
|
||||
const filter = mockDrizzle.eq('a', 'b') as never;
|
||||
expect(requireScopedFilter(filter)).toBe(filter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countContentRowsByCategory', () => {
|
||||
it('groups the joined count by category under the given filter', async () => {
|
||||
const rows = [{ categoryId: 7, rowCount: 3 }];
|
||||
const groupBy = jest.fn().mockResolvedValue(rows);
|
||||
const where = jest.fn(() => ({ groupBy }));
|
||||
const innerJoin = jest.fn(() => ({ where }));
|
||||
const from = jest.fn(() => ({ innerJoin }));
|
||||
const select = jest.fn(() => ({ from }));
|
||||
const filter = mockDrizzle.eq(
|
||||
schema.categories.playlistId,
|
||||
'playlist-1'
|
||||
) as never;
|
||||
|
||||
await expect(
|
||||
countContentRowsByCategory(
|
||||
{ select } as unknown as AppDatabase,
|
||||
filter
|
||||
)
|
||||
).resolves.toBe(rows);
|
||||
|
||||
expect(from).toHaveBeenCalledWith(schema.content);
|
||||
expect(innerJoin).toHaveBeenCalledWith(
|
||||
schema.categories,
|
||||
expect.objectContaining({ kind: 'eq' })
|
||||
);
|
||||
expect(where).toHaveBeenCalledWith(filter);
|
||||
expect(groupBy).toHaveBeenCalledWith(schema.content.categoryId);
|
||||
expect(sumCategoryRowCounts(rows)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteContentByCategoryGroups', () => {
|
||||
const counts = [
|
||||
{ categoryId: 1, rowCount: 3000 },
|
||||
{ categoryId: 2, rowCount: 3000 },
|
||||
{ categoryId: 3, rowCount: 10 },
|
||||
];
|
||||
const rowsIn = (group: number[]) =>
|
||||
group.reduce(
|
||||
(sum, id) =>
|
||||
sum + (counts.find((c) => c.categoryId === id)?.rowCount ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
it('commits one budgeted category group per transaction with a checkpoint before and progress after', async () => {
|
||||
const timeline: string[] = [];
|
||||
const checkpoint = jest.fn(() => {
|
||||
timeline.push('checkpoint');
|
||||
});
|
||||
const onProgress = jest.fn((progress: { current?: number }) => {
|
||||
timeline.push(`progress:${progress.current}`);
|
||||
});
|
||||
const db = createDeleteDb(rowsIn);
|
||||
const runTransaction = db.transaction.getMockImplementation();
|
||||
db.transaction.mockImplementation((execute) => {
|
||||
timeline.push('transaction');
|
||||
return runTransaction?.(execute);
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteContentByCategoryGroups(db.db, counts, {
|
||||
control: { checkpoint, onProgress },
|
||||
phase: 'deleting-content',
|
||||
rowsPerTransaction: 5000,
|
||||
})
|
||||
).resolves.toBe(6010);
|
||||
|
||||
expect(db.groups).toEqual([[1], [2, 3]]);
|
||||
expect(db.tables).toEqual([schema.content, schema.content]);
|
||||
expect(mockDrizzle.inArray).toHaveBeenCalledWith(
|
||||
schema.content.categoryId,
|
||||
[1]
|
||||
);
|
||||
expect(mockDrizzle.inArray).toHaveBeenCalledWith(
|
||||
schema.content.categoryId,
|
||||
[2, 3]
|
||||
);
|
||||
expect(onProgress.mock.calls.map(([value]) => value)).toEqual([
|
||||
{
|
||||
phase: 'deleting-content',
|
||||
current: 3000,
|
||||
total: 6010,
|
||||
increment: 3000,
|
||||
},
|
||||
{
|
||||
phase: 'deleting-content',
|
||||
current: 6010,
|
||||
total: 6010,
|
||||
increment: 3010,
|
||||
},
|
||||
]);
|
||||
expect(timeline).toEqual([
|
||||
'checkpoint',
|
||||
'transaction',
|
||||
'progress:3000',
|
||||
'checkpoint',
|
||||
'checkpoint',
|
||||
'transaction',
|
||||
'progress:6010',
|
||||
'checkpoint',
|
||||
]);
|
||||
});
|
||||
|
||||
it('stops at the next checkpoint when cancelled, keeping the committed group', async () => {
|
||||
const db = createDeleteDb(rowsIn);
|
||||
const cancellation = new Error('cancelled');
|
||||
cancellation.name = 'AbortError';
|
||||
let checkpoints = 0;
|
||||
|
||||
await expect(
|
||||
deleteContentByCategoryGroups(db.db, counts, {
|
||||
control: {
|
||||
checkpoint: () => {
|
||||
checkpoints += 1;
|
||||
if (checkpoints === 2) {
|
||||
throw cancellation;
|
||||
}
|
||||
},
|
||||
},
|
||||
phase: 'deleting-content',
|
||||
rowsPerTransaction: 5000,
|
||||
})
|
||||
).rejects.toBe(cancellation);
|
||||
|
||||
expect(db.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(db.groups).toEqual([[1]]);
|
||||
});
|
||||
|
||||
it('defaults to a 5,000-row budget per transaction', async () => {
|
||||
const budgetCounts = [
|
||||
{ categoryId: 1, rowCount: 3000 },
|
||||
{ categoryId: 2, rowCount: 2000 },
|
||||
{ categoryId: 3, rowCount: 1 },
|
||||
];
|
||||
const db = createDeleteDb((group) =>
|
||||
group.reduce(
|
||||
(sum, id) =>
|
||||
sum +
|
||||
(budgetCounts.find((c) => c.categoryId === id)?.rowCount ??
|
||||
0),
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
deleteContentByCategoryGroups(db.db, budgetCounts, {
|
||||
phase: 'deleting-content',
|
||||
})
|
||||
).resolves.toBe(5001);
|
||||
|
||||
// 3000 + 2000 fill the budget exactly; the next row starts a group.
|
||||
expect(db.groups).toEqual([[1, 2], [3]]);
|
||||
});
|
||||
|
||||
it('does nothing for categories without content', async () => {
|
||||
const db = createDeleteDb(rowsIn);
|
||||
const onProgress = jest.fn();
|
||||
|
||||
await expect(
|
||||
deleteContentByCategoryGroups(db.db, [], {
|
||||
control: { onProgress },
|
||||
phase: 'deleting-content',
|
||||
})
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
expect(onProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCategoriesWhere', () => {
|
||||
it('deletes the filtered categories in one transaction and reports the count', async () => {
|
||||
const where = jest.fn(() => ({ run: () => ({ changes: 4 }) }));
|
||||
const deleteRows = jest.fn(() => ({ where }));
|
||||
const transaction = jest.fn((execute: (tx: unknown) => unknown) =>
|
||||
execute({ delete: deleteRows })
|
||||
);
|
||||
const filter = mockDrizzle.eq(
|
||||
schema.categories.playlistId,
|
||||
'playlist-1'
|
||||
) as never;
|
||||
|
||||
await expect(
|
||||
deleteCategoriesWhere(
|
||||
{ transaction } as unknown as AppDatabase,
|
||||
filter
|
||||
)
|
||||
).resolves.toBe(4);
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(deleteRows).toHaveBeenCalledWith(schema.categories);
|
||||
expect(where).toHaveBeenCalledWith(filter);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { eq, inArray, sql, type SQL } from 'drizzle-orm';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
checkpointOperation,
|
||||
type OperationControl,
|
||||
reportOperationProgress,
|
||||
} from './operation-control';
|
||||
|
||||
/**
|
||||
* Upper bound on the content rows one committed transaction removes or
|
||||
* inserts.
|
||||
*
|
||||
* Catalog writes used to commit every 100 rows (#1292). Each commit flushes
|
||||
* the FTS5 trigram pending buffer into a new segment, appends the dirty pages
|
||||
* of every `content` index to the WAL again, and roughly every 4 MB of WAL
|
||||
* runs an fsync-ing auto-checkpoint. On a 900k-row database that turned a
|
||||
* 3 s set-based delete of one 300k-row playlist into 12–15 s and wrote 2 GB
|
||||
* of WAL where one transaction writes 140 MB; the insert side behaved the
|
||||
* same way. One giant transaction is not the answer either: the worker only
|
||||
* serves other requests between awaits, cancellation is cooperative between
|
||||
* commits, and the main-process and EPG-worker connections give up after
|
||||
* their 5 s `busy_timeout` while a write transaction holds the lock. Around
|
||||
* 5,000 rows keeps a commit near 100 ms on a laptop SSD.
|
||||
*
|
||||
* `IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION` overrides the budget. It is a
|
||||
* test-only knob, the companion of `IPTVNATOR_DB_WORKER_BATCH_DELAY_MS`: the
|
||||
* responsiveness E2E suites slow the worker down and need enough commits in
|
||||
* a few-thousand-row mock catalog to observe progress mid-import.
|
||||
*/
|
||||
export const DEFAULT_CONTENT_ROWS_PER_TRANSACTION = 5000;
|
||||
|
||||
/** The budget behind `CONTENT_ROWS_PER_TRANSACTION`, parsed from one raw env value. */
|
||||
export function resolveContentRowsPerTransaction(
|
||||
rawValue: string | undefined
|
||||
): number {
|
||||
const parsed = Number.parseInt(rawValue ?? '', 10);
|
||||
return Number.isInteger(parsed) && parsed > 0
|
||||
? parsed
|
||||
: DEFAULT_CONTENT_ROWS_PER_TRANSACTION;
|
||||
}
|
||||
|
||||
export const CONTENT_ROWS_PER_TRANSACTION = resolveContentRowsPerTransaction(
|
||||
process.env['IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION']
|
||||
);
|
||||
|
||||
/** How many content rows reference one category. */
|
||||
export interface CategoryRowCount {
|
||||
readonly categoryId: number;
|
||||
readonly rowCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a filter that would drop every row of a table.
|
||||
*
|
||||
* Drizzle's `and()` returns `undefined` for an empty condition list, and
|
||||
* `.where(undefined)` is a full-table delete. The scoped deletes below never
|
||||
* intend that, so an unbounded filter is a bug worth failing on.
|
||||
*/
|
||||
export function requireScopedFilter(filter: SQL | undefined): SQL {
|
||||
if (!filter) {
|
||||
throw new Error('Refusing an unscoped catalog delete');
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content row counts per category for the categories matching
|
||||
* `categoryFilter`, read from two covering indexes without touching a row.
|
||||
*/
|
||||
export async function countContentRowsByCategory(
|
||||
db: AppDatabase,
|
||||
categoryFilter: SQL
|
||||
): Promise<CategoryRowCount[]> {
|
||||
return db
|
||||
.select({
|
||||
categoryId: schema.content.categoryId,
|
||||
rowCount: sql<number>`count(*)`,
|
||||
})
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.where(categoryFilter)
|
||||
.groupBy(schema.content.categoryId);
|
||||
}
|
||||
|
||||
export function sumCategoryRowCounts(
|
||||
counts: readonly CategoryRowCount[]
|
||||
): number {
|
||||
return counts.reduce((total, count) => total + count.rowCount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Packs categories into groups whose combined row count stays within
|
||||
* `rowBudget`, preserving input order. A category larger than the budget
|
||||
* forms a group of its own and is deleted in one statement: bounding it
|
||||
* further would need id-ranged sub-deletes, and the largest real categories
|
||||
* seen so far (~45k rows) still commit in well under a second. Categories
|
||||
* without content are skipped — there is nothing to delete under them.
|
||||
*/
|
||||
export function groupCategoriesByRowBudget(
|
||||
counts: readonly CategoryRowCount[],
|
||||
rowBudget: number
|
||||
): number[][] {
|
||||
const groups: number[][] = [];
|
||||
let group: number[] = [];
|
||||
let groupRows = 0;
|
||||
|
||||
for (const { categoryId, rowCount } of counts) {
|
||||
if (rowCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (group.length > 0 && groupRows + rowCount > rowBudget) {
|
||||
groups.push(group);
|
||||
group = [];
|
||||
groupRows = 0;
|
||||
}
|
||||
group.push(categoryId);
|
||||
groupRows += rowCount;
|
||||
}
|
||||
|
||||
if (group.length > 0) {
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export interface ContentDeletionOptions {
|
||||
readonly control?: OperationControl;
|
||||
/** Progress phase reported after every committed group. */
|
||||
readonly phase: string;
|
||||
readonly rowsPerTransaction?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the content under the counted categories, one budgeted group per
|
||||
* transaction, with a cooperative checkpoint before each commit and a
|
||||
* progress report after it. Returns the number of rows SQLite removed.
|
||||
*/
|
||||
export async function deleteContentByCategoryGroups(
|
||||
db: AppDatabase,
|
||||
counts: readonly CategoryRowCount[],
|
||||
options: ContentDeletionOptions
|
||||
): Promise<number> {
|
||||
const total = sumCategoryRowCounts(counts);
|
||||
const groups = groupCategoriesByRowBudget(
|
||||
counts,
|
||||
options.rowsPerTransaction ?? CONTENT_ROWS_PER_TRANSACTION
|
||||
);
|
||||
let deleted = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
await checkpointOperation(options.control);
|
||||
const changes = await db.transaction(
|
||||
(tx) =>
|
||||
tx
|
||||
.delete(schema.content)
|
||||
.where(inArray(schema.content.categoryId, group))
|
||||
.run().changes
|
||||
);
|
||||
deleted += changes;
|
||||
await reportOperationProgress(options.control, {
|
||||
phase: options.phase,
|
||||
current: deleted,
|
||||
total,
|
||||
increment: changes,
|
||||
});
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes every category matching `categoryFilter` in one transaction and
|
||||
* returns the number of rows removed. Content still referencing one of them
|
||||
* goes with it through `ON DELETE CASCADE`. Callers scope the filter to the
|
||||
* category ids they captured earlier (or, for playlist removal, to the
|
||||
* playlist whose row is about to cascade anyway): the worker serves other
|
||||
* requests between commits, so a playlist-wide predicate in a refresh could
|
||||
* erase categories a newer import of the same playlist just created.
|
||||
*/
|
||||
export async function deleteCategoriesWhere(
|
||||
db: AppDatabase,
|
||||
categoryFilter: SQL | undefined
|
||||
): Promise<number> {
|
||||
const filter = requireScopedFilter(categoryFilter);
|
||||
return db.transaction(
|
||||
(tx) => tx.delete(schema.categories).where(filter).run().changes
|
||||
);
|
||||
}
|
||||
+54
-12
@@ -107,17 +107,15 @@ describe('content import performance phases', () => {
|
||||
)
|
||||
).resolves.toEqual({ success: true, count: 205 });
|
||||
|
||||
expect(harness.transaction).toHaveBeenCalledTimes(3);
|
||||
expect(harness.transactionResults).toEqual([
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
// 205 rows fit one row-budgeted commit made of three 100-row
|
||||
// statements.
|
||||
expect(harness.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(harness.transactionResults).toEqual([undefined]);
|
||||
expect(
|
||||
harness.values.mock.calls.map(([chunk]) => chunk.length)
|
||||
).toEqual([100, 100, 5]);
|
||||
expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([
|
||||
100, 200, 205,
|
||||
205,
|
||||
]);
|
||||
const writeStart = timeline.indexOf(
|
||||
'sqlite.content.write-transactions:start'
|
||||
@@ -164,6 +162,38 @@ describe('content import performance phases', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('commits 5,000 rows per transaction as fifty 100-row statements', async () => {
|
||||
const timeline: string[] = [];
|
||||
const harness = createContentDb(5100, timeline);
|
||||
const checkpoint = jest.fn();
|
||||
const onProgress = jest.fn();
|
||||
|
||||
await expect(
|
||||
saveContent(harness.db, 'playlist-1', harness.streams, 'live', {
|
||||
checkpoint,
|
||||
onProgress,
|
||||
})
|
||||
).resolves.toEqual({ success: true, count: 5100 });
|
||||
|
||||
expect(harness.transaction).toHaveBeenCalledTimes(2);
|
||||
expect(harness.values).toHaveBeenCalledTimes(51);
|
||||
expect(checkpoint).toHaveBeenCalledTimes(4);
|
||||
expect(onProgress.mock.calls.map(([value]) => value)).toEqual([
|
||||
{
|
||||
phase: 'saving-content',
|
||||
current: 5000,
|
||||
total: 5100,
|
||||
increment: 5000,
|
||||
},
|
||||
{
|
||||
phase: 'saving-content',
|
||||
current: 5100,
|
||||
total: 5100,
|
||||
increment: 100,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes the aggregate write phase on cancellation without changing the error', async () => {
|
||||
const timeline: string[] = [];
|
||||
const recording = createRecordingOperationPhaseCapture();
|
||||
@@ -195,9 +225,14 @@ describe('content import performance phases', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('captures cache deletion as one pair across content and category chunks', async () => {
|
||||
it('captures cache deletion as one pair across content groups and categories', async () => {
|
||||
const categoryRows = Array.from({ length: 150 }, (_, id) => ({ id }));
|
||||
const contentRows = Array.from({ length: 205 }, (_, id) => ({ id }));
|
||||
// 205 content rows counted over three of the categories.
|
||||
const contentRowCounts = [
|
||||
{ categoryId: 0, rowCount: 100 },
|
||||
{ categoryId: 1, rowCount: 100 },
|
||||
{ categoryId: 2, rowCount: 5 },
|
||||
];
|
||||
const select = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
@@ -207,10 +242,16 @@ describe('content import performance phases', () => {
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(contentRows),
|
||||
innerJoin: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
groupBy: jest
|
||||
.fn()
|
||||
.mockResolvedValue(contentRowCounts),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
const run = jest.fn();
|
||||
const run = jest.fn(() => ({ changes: 1 }));
|
||||
const deleteRows = jest.fn(() => ({
|
||||
where: jest.fn(() => ({ run })),
|
||||
}));
|
||||
@@ -227,7 +268,8 @@ describe('content import performance phases', () => {
|
||||
recording.capture
|
||||
);
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(5);
|
||||
// One row-budgeted content group, then the categories.
|
||||
expect(transaction).toHaveBeenCalledTimes(2);
|
||||
expect(recording.events).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
XtreamGlobalSearchResult,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
countContentRowsByCategory,
|
||||
sumCategoryRowCounts,
|
||||
} from './catalog-deletion';
|
||||
import {
|
||||
buildCompoundFtsMatchQuery,
|
||||
buildCompoundLikePatterns,
|
||||
@@ -997,18 +1001,25 @@ export async function clearXtreamImportCache(
|
||||
: { success: true };
|
||||
}
|
||||
|
||||
const contentRows = await db
|
||||
.select({ id: schema.content.id })
|
||||
.from(schema.content)
|
||||
.where(inArray(schema.content.categoryId, categoryIds));
|
||||
// Count and delete stay scoped to the captured ids, not to the
|
||||
// playlist/type predicate, so a category a concurrent import creates
|
||||
// between the read and the delete survives.
|
||||
const contentRowCounts = await countContentRowsByCategory(
|
||||
db,
|
||||
inArray(schema.categories.id, categoryIds)
|
||||
);
|
||||
|
||||
return capturePhase
|
||||
? capturePhase.captureAsync(
|
||||
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
|
||||
() => deleteXtreamCacheRows(db, contentRows, categoryIds),
|
||||
() => ({ itemCount: contentRows.length + categoryIds.length })
|
||||
() => deleteXtreamCacheRows(db, contentRowCounts, categoryIds),
|
||||
() => ({
|
||||
itemCount:
|
||||
sumCategoryRowCounts(contentRowCounts) +
|
||||
categoryIds.length,
|
||||
})
|
||||
)
|
||||
: deleteXtreamCacheRows(db, contentRows, categoryIds);
|
||||
: deleteXtreamCacheRows(db, contentRowCounts, categoryIds);
|
||||
}
|
||||
|
||||
export async function getContentByXtreamId(
|
||||
|
||||
+78
-20
@@ -1,3 +1,4 @@
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
|
||||
@@ -16,25 +17,72 @@ function createSelectStep(
|
||||
return { from };
|
||||
}
|
||||
|
||||
/** The grouped per-category content count query. */
|
||||
function createCountStep(
|
||||
rows: readonly unknown[],
|
||||
label: string,
|
||||
timeline: string[]
|
||||
) {
|
||||
const groupBy = jest.fn(() => {
|
||||
timeline.push(label);
|
||||
return Promise.resolve(rows);
|
||||
});
|
||||
const where = jest.fn(() => ({ groupBy }));
|
||||
const innerJoin = jest.fn(() => ({ where }));
|
||||
const from = jest.fn(() => ({ innerJoin }));
|
||||
return { from };
|
||||
}
|
||||
|
||||
const ROWS_PER_TABLE = new Map<unknown, number>([
|
||||
[schema.favorites, 2],
|
||||
[schema.recentlyViewed, 1],
|
||||
[schema.playbackPositions, 0],
|
||||
[schema.content, 205],
|
||||
[schema.categories, 2],
|
||||
]);
|
||||
|
||||
function createDeleteHarness() {
|
||||
const timeline: string[] = [];
|
||||
const selections = [
|
||||
['select:favorites', [{ id: 1 }, { id: 2 }]],
|
||||
['select:recently-viewed', [{ id: 3 }]],
|
||||
['select:playback-positions', []],
|
||||
['select:categories', [{ id: 10 }, { id: 11 }]],
|
||||
[
|
||||
'select:content',
|
||||
Array.from({ length: 205 }, (_, id) => ({ id: id + 100 })),
|
||||
],
|
||||
] as const;
|
||||
const select = jest.fn();
|
||||
for (const [label, rows] of selections) {
|
||||
select.mockReturnValueOnce(createSelectStep(rows, label, timeline));
|
||||
}
|
||||
const transactionRun = jest.fn();
|
||||
const transactionDelete = jest.fn(() => ({
|
||||
where: jest.fn(() => ({ run: transactionRun })),
|
||||
const select = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(
|
||||
createSelectStep([{ count: 2 }], 'select:favorites', timeline)
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
createSelectStep(
|
||||
[{ count: 1 }],
|
||||
'select:recently-viewed',
|
||||
timeline
|
||||
)
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
createSelectStep(
|
||||
[{ count: 0 }],
|
||||
'select:playback-positions',
|
||||
timeline
|
||||
)
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
createSelectStep(
|
||||
[{ id: 10 }, { id: 11 }],
|
||||
'select:categories',
|
||||
timeline
|
||||
)
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
createCountStep(
|
||||
[
|
||||
{ categoryId: 10, rowCount: 120 },
|
||||
{ categoryId: 11, rowCount: 85 },
|
||||
],
|
||||
'select:content',
|
||||
timeline
|
||||
)
|
||||
);
|
||||
const transactionDelete = jest.fn((table: unknown) => ({
|
||||
where: jest.fn(() => ({
|
||||
run: jest.fn(() => ({ changes: ROWS_PER_TABLE.get(table) ?? 0 })),
|
||||
})),
|
||||
}));
|
||||
const transaction = jest.fn((execute: (tx: unknown) => unknown) => {
|
||||
timeline.push('transaction');
|
||||
@@ -79,10 +127,20 @@ describe('playlist delete performance phases', () => {
|
||||
)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expect(harness.transaction).toHaveBeenCalledTimes(6);
|
||||
// Six committed chunks plus the final playlist row each keep both
|
||||
// Favorites, recently viewed, one row-budgeted content group and the
|
||||
// categories commit once each; the empty playback-position stage is
|
||||
// skipped.
|
||||
expect(harness.transaction).toHaveBeenCalledTimes(4);
|
||||
// Four committed stages plus the final playlist row each keep both
|
||||
// the pre-write and post-progress cooperative checkpoints.
|
||||
expect(checkpoint).toHaveBeenCalledTimes(14);
|
||||
expect(checkpoint).toHaveBeenCalledTimes(10);
|
||||
expect(onProgress.mock.calls.map(([value]) => value.phase)).toEqual([
|
||||
'deleting-favorites',
|
||||
'deleting-recently-viewed',
|
||||
'deleting-content',
|
||||
'deleting-categories',
|
||||
'deleting-playlist',
|
||||
]);
|
||||
expect(harness.timeline.slice(1, 6)).toEqual([
|
||||
'select:favorites',
|
||||
'select:recently-viewed',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { buildPlaylistRow } from './playlist.operations';
|
||||
|
||||
export const PLAYLIST_MIGRATION_KEY = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
export const LEGACY_PROFILE_MIGRATION_KEY =
|
||||
'playlists-electron-backend-profile-v1';
|
||||
|
||||
/** All rows and the receipt commit together; retries never replay a finished import. */
|
||||
export function migrateAppPlaylists(
|
||||
db: AppDatabase,
|
||||
playlists: Record<string, unknown>[],
|
||||
key = PLAYLIST_MIGRATION_KEY
|
||||
): { success: boolean; count: number } {
|
||||
try {
|
||||
return db.transaction((tx) => {
|
||||
if (
|
||||
tx
|
||||
.select()
|
||||
.from(schema.appState)
|
||||
.where(eq(schema.appState.key, key))
|
||||
.get()?.value === '1'
|
||||
) {
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
if (!Array.isArray(playlists))
|
||||
throw new Error('Invalid legacy playlists');
|
||||
const ids = new Set<string>();
|
||||
let count = 0;
|
||||
for (const playlist of playlists) {
|
||||
const row = buildPlaylistRow(playlist);
|
||||
if (!row || ids.has(row.id))
|
||||
throw new Error('Invalid legacy playlist');
|
||||
ids.add(row.id);
|
||||
const existing = tx
|
||||
.select()
|
||||
.from(schema.playlists)
|
||||
.where(eq(schema.playlists.id, row.id))
|
||||
.get();
|
||||
// A payload identifies an authoritative application source. A
|
||||
// v0.19 cache-only row can gain its legacy metadata without
|
||||
// replacing any catalog/favorite/history rows.
|
||||
if (existing?.payload) continue;
|
||||
const merged = {
|
||||
...row,
|
||||
// Cache-only credentials can be stale: v0.19 edited the
|
||||
// authoritative source in IndexedDB without updating SQLite.
|
||||
dateCreated: existing?.dateCreated ?? row.dateCreated,
|
||||
lastUpdated: row.lastUpdated ?? existing?.lastUpdated,
|
||||
};
|
||||
tx.insert(schema.playlists)
|
||||
.values(merged)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.playlists.id,
|
||||
set: merged,
|
||||
})
|
||||
.run();
|
||||
count++;
|
||||
}
|
||||
tx.insert(schema.appState)
|
||||
.values({ key, value: '1' })
|
||||
.onConflictDoUpdate({
|
||||
target: schema.appState.key,
|
||||
set: { value: '1' },
|
||||
})
|
||||
.run();
|
||||
return { success: true, count };
|
||||
});
|
||||
} catch {
|
||||
// Drizzle errors can include SQL parameter values (portal credentials).
|
||||
throw new Error(
|
||||
'Legacy playlist migration failed; original data was retained'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
@@ -10,9 +10,15 @@ import {
|
||||
type PerformancePhaseMetadata,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
type CategoryRowCount,
|
||||
countContentRowsByCategory,
|
||||
deleteCategoriesWhere,
|
||||
deleteContentByCategoryGroups,
|
||||
sumCategoryRowCounts,
|
||||
} from './catalog-deletion';
|
||||
import {
|
||||
checkpointOperation,
|
||||
chunkValues,
|
||||
type OperationControl,
|
||||
reportOperationProgress,
|
||||
} from './operation-control';
|
||||
@@ -26,8 +32,6 @@ const PLAYLIST_TYPES = {
|
||||
M3U_URL: 'm3u-url',
|
||||
} as const;
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 100;
|
||||
|
||||
export interface AppPlaylistUpsertPhaseCapture {
|
||||
captureAsync: <TResult>(
|
||||
phase: AppPlaylistUpsertPerformancePhase,
|
||||
@@ -162,7 +166,7 @@ function inferPlaylistType(playlist: Record<string, unknown>): PlaylistType {
|
||||
return PLAYLIST_TYPES.M3U_TEXT;
|
||||
}
|
||||
|
||||
function buildPlaylistRow(
|
||||
export function buildPlaylistRow(
|
||||
playlist: Record<string, unknown>
|
||||
): schema.NewPlaylist | null {
|
||||
const id = getStringValue(playlist._id) ?? getStringValue(playlist.id);
|
||||
@@ -590,30 +594,37 @@ export async function updatePlaylist(
|
||||
|
||||
interface PlaylistDeletionCollection {
|
||||
readonly categoryIds: number[];
|
||||
readonly contentRows: Array<{ id: number }>;
|
||||
readonly favoriteRows: Array<{ id: number }>;
|
||||
readonly playbackPositionRows: Array<{ id: number }>;
|
||||
readonly recentlyViewedRows: Array<{ id: number }>;
|
||||
/** Content rows per category, the unit the delete is batched by. */
|
||||
readonly contentRowCounts: CategoryRowCount[];
|
||||
readonly favoriteCount: number;
|
||||
readonly playbackPositionCount: number;
|
||||
readonly recentlyViewedCount: number;
|
||||
}
|
||||
|
||||
async function countPlaylistRows(
|
||||
db: AppDatabase,
|
||||
table:
|
||||
| typeof schema.favorites
|
||||
| typeof schema.playbackPositions
|
||||
| typeof schema.recentlyViewed,
|
||||
playlistId: string
|
||||
): Promise<number> {
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(table)
|
||||
.where(eq(table.playlistId, playlistId));
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
async function collectPlaylistDeletionRows(
|
||||
db: AppDatabase,
|
||||
playlistId: string
|
||||
): Promise<PlaylistDeletionCollection> {
|
||||
const [favoriteRows, recentlyViewedRows, playbackPositionRows] =
|
||||
const [favoriteCount, recentlyViewedCount, playbackPositionCount] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ id: schema.favorites.id })
|
||||
.from(schema.favorites)
|
||||
.where(eq(schema.favorites.playlistId, playlistId)),
|
||||
db
|
||||
.select({ id: schema.recentlyViewed.id })
|
||||
.from(schema.recentlyViewed)
|
||||
.where(eq(schema.recentlyViewed.playlistId, playlistId)),
|
||||
db
|
||||
.select({ id: schema.playbackPositions.id })
|
||||
.from(schema.playbackPositions)
|
||||
.where(eq(schema.playbackPositions.playlistId, playlistId)),
|
||||
countPlaylistRows(db, schema.favorites, playlistId),
|
||||
countPlaylistRows(db, schema.recentlyViewed, playlistId),
|
||||
countPlaylistRows(db, schema.playbackPositions, playlistId),
|
||||
]);
|
||||
|
||||
const categoryRows = await db
|
||||
@@ -621,20 +632,20 @@ async function collectPlaylistDeletionRows(
|
||||
.from(schema.categories)
|
||||
.where(eq(schema.categories.playlistId, playlistId));
|
||||
const categoryIds = categoryRows.map((category) => category.id);
|
||||
const contentRows =
|
||||
const contentRowCounts =
|
||||
categoryIds.length > 0
|
||||
? await db
|
||||
.select({ id: schema.content.id })
|
||||
.from(schema.content)
|
||||
.where(inArray(schema.content.categoryId, categoryIds))
|
||||
? await countContentRowsByCategory(
|
||||
db,
|
||||
eq(schema.categories.playlistId, playlistId)
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
categoryIds,
|
||||
contentRows,
|
||||
favoriteRows,
|
||||
playbackPositionRows,
|
||||
recentlyViewedRows,
|
||||
contentRowCounts,
|
||||
favoriteCount,
|
||||
playbackPositionCount,
|
||||
recentlyViewedCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -642,68 +653,84 @@ function countPlaylistDeletionRows(
|
||||
collection: PlaylistDeletionCollection
|
||||
): number {
|
||||
return (
|
||||
collection.favoriteRows.length +
|
||||
collection.recentlyViewedRows.length +
|
||||
collection.playbackPositionRows.length +
|
||||
collection.contentRows.length +
|
||||
collection.favoriteCount +
|
||||
collection.recentlyViewedCount +
|
||||
collection.playbackPositionCount +
|
||||
sumCategoryRowCounts(collection.contentRowCounts) +
|
||||
collection.categoryIds.length
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the playlist's rows table by table so progress and cancellation
|
||||
* keep their stage granularity, then the playlist row itself. User-data
|
||||
* tables go in one statement each (they are playlist-indexed and small next
|
||||
* to the catalog); content goes in row-budgeted category groups; categories
|
||||
* go in one statement. A stage with nothing counted is skipped — the final
|
||||
* playlist delete cascades anything that appeared in between.
|
||||
*/
|
||||
async function deleteCollectedPlaylistRows(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
collection: PlaylistDeletionCollection,
|
||||
control?: OperationControl
|
||||
): Promise<number> {
|
||||
for (const [phase, ids, column, table] of [
|
||||
[
|
||||
'deleting-favorites',
|
||||
collection.favoriteRows.map((row) => row.id),
|
||||
schema.favorites.id,
|
||||
schema.favorites,
|
||||
],
|
||||
[
|
||||
'deleting-recently-viewed',
|
||||
collection.recentlyViewedRows.map((row) => row.id),
|
||||
schema.recentlyViewed.id,
|
||||
schema.recentlyViewed,
|
||||
],
|
||||
[
|
||||
'deleting-playback-positions',
|
||||
collection.playbackPositionRows.map((row) => row.id),
|
||||
schema.playbackPositions.id,
|
||||
schema.playbackPositions,
|
||||
],
|
||||
[
|
||||
'deleting-content',
|
||||
collection.contentRows.map((row) => row.id),
|
||||
schema.content.id,
|
||||
schema.content,
|
||||
],
|
||||
[
|
||||
'deleting-categories',
|
||||
collection.categoryIds,
|
||||
schema.categories.id,
|
||||
schema.categories,
|
||||
],
|
||||
] as const) {
|
||||
let current = 0;
|
||||
const total = ids.length;
|
||||
const userDataStages = [
|
||||
{
|
||||
phase: 'deleting-favorites',
|
||||
expected: collection.favoriteCount,
|
||||
table: schema.favorites,
|
||||
},
|
||||
{
|
||||
phase: 'deleting-recently-viewed',
|
||||
expected: collection.recentlyViewedCount,
|
||||
table: schema.recentlyViewed,
|
||||
},
|
||||
{
|
||||
phase: 'deleting-playback-positions',
|
||||
expected: collection.playbackPositionCount,
|
||||
table: schema.playbackPositions,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const chunk of chunkValues(ids, DEFAULT_BATCH_SIZE)) {
|
||||
await checkpointOperation(control);
|
||||
await db.transaction((tx) => {
|
||||
tx.delete(table).where(inArray(column, chunk)).run();
|
||||
});
|
||||
current += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase,
|
||||
current,
|
||||
total,
|
||||
increment: chunk.length,
|
||||
});
|
||||
for (const stage of userDataStages) {
|
||||
if (stage.expected === 0) {
|
||||
continue;
|
||||
}
|
||||
await checkpointOperation(control);
|
||||
const changes = await db.transaction(
|
||||
(tx) =>
|
||||
tx
|
||||
.delete(stage.table)
|
||||
.where(eq(stage.table.playlistId, playlistId))
|
||||
.run().changes
|
||||
);
|
||||
await reportOperationProgress(control, {
|
||||
phase: stage.phase,
|
||||
current: changes,
|
||||
total: stage.expected,
|
||||
increment: changes,
|
||||
});
|
||||
}
|
||||
|
||||
await deleteContentByCategoryGroups(db, collection.contentRowCounts, {
|
||||
control,
|
||||
phase: 'deleting-content',
|
||||
});
|
||||
|
||||
const totalCategories = collection.categoryIds.length;
|
||||
if (totalCategories > 0) {
|
||||
await checkpointOperation(control);
|
||||
const changes = await deleteCategoriesWhere(
|
||||
db,
|
||||
eq(schema.categories.playlistId, playlistId)
|
||||
);
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'deleting-categories',
|
||||
current: changes,
|
||||
total: totalCategories,
|
||||
increment: changes,
|
||||
});
|
||||
}
|
||||
|
||||
await checkpointOperation(control);
|
||||
|
||||
+57
-34
@@ -1,6 +1,12 @@
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
type CategoryRowCount,
|
||||
CONTENT_ROWS_PER_TRANSACTION,
|
||||
deleteCategoriesWhere,
|
||||
deleteContentByCategoryGroups,
|
||||
} from './catalog-deletion';
|
||||
import { scoreSearchTextMatch } from './content-search.util';
|
||||
import {
|
||||
checkpointOperation,
|
||||
@@ -9,6 +15,18 @@ import {
|
||||
reportOperationProgress,
|
||||
} from './operation-control';
|
||||
|
||||
/**
|
||||
* Rows per multi-row INSERT. Drizzle binds one parameter per column an
|
||||
* `XtreamContentValue` supplies (eleven; the rest are emitted as `default`),
|
||||
* so a statement carries 1,100 parameters, well under SQLite's 32,766 limit.
|
||||
* A single statement for a whole 5,000-row commit would exceed it.
|
||||
*/
|
||||
const INSERT_ROWS_PER_STATEMENT = 100;
|
||||
const INSERT_STATEMENTS_PER_TRANSACTION = Math.max(
|
||||
1,
|
||||
Math.floor(CONTENT_ROWS_PER_TRANSACTION / INSERT_ROWS_PER_STATEMENT)
|
||||
);
|
||||
|
||||
export type XtreamContentValue = {
|
||||
categoryId: number;
|
||||
title: string;
|
||||
@@ -48,58 +66,63 @@ export async function writeXtreamContentValues(
|
||||
control?: OperationControl
|
||||
): Promise<{ success: boolean; count: number }> {
|
||||
const total = values.length;
|
||||
const chunkSize = 100;
|
||||
let totalInserted = 0;
|
||||
|
||||
for (let index = 0; index < values.length; index += chunkSize) {
|
||||
// One commit per CONTENT_ROWS_PER_TRANSACTION rows, made of
|
||||
// statement-sized chunks: see catalog-deletion.ts for why a commit every
|
||||
// 100 rows is the expensive part of a large import.
|
||||
for (const statements of chunkValues(
|
||||
chunkValues(values, INSERT_ROWS_PER_STATEMENT),
|
||||
INSERT_STATEMENTS_PER_TRANSACTION
|
||||
)) {
|
||||
await checkpointOperation(control);
|
||||
const chunk = values.slice(index, index + chunkSize);
|
||||
await db.transaction((tx) => {
|
||||
tx.insert(schema.content)
|
||||
.values(chunk)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.content.categoryId,
|
||||
schema.content.type,
|
||||
schema.content.xtreamId,
|
||||
],
|
||||
})
|
||||
.run();
|
||||
for (const chunk of statements) {
|
||||
tx.insert(schema.content)
|
||||
.values(chunk)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.content.categoryId,
|
||||
schema.content.type,
|
||||
schema.content.xtreamId,
|
||||
],
|
||||
})
|
||||
.run();
|
||||
}
|
||||
});
|
||||
totalInserted += chunk.length;
|
||||
const inserted = statements.reduce(
|
||||
(count, chunk) => count + chunk.length,
|
||||
0
|
||||
);
|
||||
totalInserted += inserted;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'saving-content',
|
||||
current: totalInserted,
|
||||
total,
|
||||
increment: chunk.length,
|
||||
increment: inserted,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, count: totalInserted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops one content type's cached catalog: content in row-budgeted category
|
||||
* groups, then exactly the captured categories in one statement.
|
||||
*/
|
||||
export async function deleteXtreamCacheRows(
|
||||
db: AppDatabase,
|
||||
contentRows: Array<{ id: number }>,
|
||||
categoryIds: number[]
|
||||
contentRowCounts: readonly CategoryRowCount[],
|
||||
categoryIds: readonly number[]
|
||||
): Promise<{ success: boolean }> {
|
||||
for (const chunk of chunkValues(
|
||||
contentRows.map((row) => row.id),
|
||||
100
|
||||
)) {
|
||||
await db.transaction((tx) => {
|
||||
tx.delete(schema.content)
|
||||
.where(inArray(schema.content.id, chunk))
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
for (const chunk of chunkValues(categoryIds, 100)) {
|
||||
await db.transaction((tx) => {
|
||||
tx.delete(schema.categories)
|
||||
.where(inArray(schema.categories.id, chunk))
|
||||
.run();
|
||||
});
|
||||
await deleteContentByCategoryGroups(db, contentRowCounts, {
|
||||
phase: 'deleting-content',
|
||||
});
|
||||
if (categoryIds.length > 0) {
|
||||
await deleteCategoriesWhere(
|
||||
db,
|
||||
inArray(schema.categories.id, [...categoryIds])
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
+105
-15
@@ -1,8 +1,17 @@
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
|
||||
import { deleteXtreamContent } from './xtream.operations';
|
||||
|
||||
/** Renders a drizzle filter the way the driver would, for asserting its scope. */
|
||||
function renderFilter(filter: SQL): { sql: string; params: unknown[] } {
|
||||
const query = new SQLiteSyncDialect().sqlToQuery(filter);
|
||||
return { sql: query.sql, params: query.params };
|
||||
}
|
||||
|
||||
function createSelectStep(
|
||||
rows: readonly unknown[],
|
||||
label: string,
|
||||
@@ -17,6 +26,26 @@ function createSelectStep(
|
||||
return { from };
|
||||
}
|
||||
|
||||
/** The grouped per-category content count query. */
|
||||
function createCountStep(
|
||||
rows: readonly unknown[],
|
||||
label: string,
|
||||
timeline: string[],
|
||||
filters: SQL[]
|
||||
) {
|
||||
const groupBy = jest.fn(() => {
|
||||
timeline.push(label);
|
||||
return Promise.resolve(rows);
|
||||
});
|
||||
const where = jest.fn((filter: SQL) => {
|
||||
filters.push(filter);
|
||||
return { groupBy };
|
||||
});
|
||||
const innerJoin = jest.fn(() => ({ where }));
|
||||
const from = jest.fn(() => ({ innerJoin }));
|
||||
return { from };
|
||||
}
|
||||
|
||||
function createDeleteHarness() {
|
||||
const timeline: string[] = [];
|
||||
const categories = [
|
||||
@@ -38,7 +67,13 @@ function createDeleteHarness() {
|
||||
xtreamId: 202,
|
||||
},
|
||||
];
|
||||
const content = Array.from({ length: 205 }, (_, id) => ({ id: id + 1 }));
|
||||
// 205 content rows spread over the two categories.
|
||||
const contentRowCounts = [
|
||||
{ categoryId: 11, rowCount: 120 },
|
||||
{ categoryId: 12, rowCount: 85 },
|
||||
];
|
||||
const countFilters: SQL[] = [];
|
||||
const deleteFilters: Array<{ table: unknown; filter: SQL }> = [];
|
||||
const select = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(
|
||||
@@ -51,11 +86,22 @@ function createDeleteHarness() {
|
||||
createSelectStep(recentlyViewed, 'select:recently-viewed', timeline)
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
createSelectStep(content, 'select:content', timeline)
|
||||
createCountStep(
|
||||
contentRowCounts,
|
||||
'select:content',
|
||||
timeline,
|
||||
countFilters
|
||||
)
|
||||
);
|
||||
const run = jest.fn();
|
||||
const deleteRows = jest.fn(() => ({
|
||||
where: jest.fn(() => ({ run })),
|
||||
const deleteRows = jest.fn((table: unknown) => ({
|
||||
where: jest.fn((filter: SQL) => {
|
||||
deleteFilters.push({ table, filter });
|
||||
return {
|
||||
run: jest.fn(() => ({
|
||||
changes: table === schema.content ? 205 : 2,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
const transaction = jest.fn((execute: (tx: unknown) => unknown) => {
|
||||
timeline.push('transaction');
|
||||
@@ -63,8 +109,9 @@ function createDeleteHarness() {
|
||||
});
|
||||
|
||||
return {
|
||||
content,
|
||||
countFilters,
|
||||
db: { select, transaction } as unknown as AppDatabase,
|
||||
deleteFilters,
|
||||
timeline,
|
||||
transaction,
|
||||
};
|
||||
@@ -79,9 +126,16 @@ describe('Xtream delete performance phases', () => {
|
||||
const checkpoint = jest.fn(() => {
|
||||
harness.timeline.push('checkpoint');
|
||||
});
|
||||
const onProgress = jest.fn((progress: { current?: number }) => {
|
||||
harness.timeline.push(`progress:${progress.current}`);
|
||||
});
|
||||
const onProgress = jest.fn(
|
||||
(progress: {
|
||||
phase: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
increment?: number;
|
||||
}) => {
|
||||
harness.timeline.push(`progress:${progress.current}`);
|
||||
}
|
||||
);
|
||||
|
||||
await expect(
|
||||
deleteXtreamContent(
|
||||
@@ -110,12 +164,25 @@ describe('Xtream delete performance phases', () => {
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(harness.transaction).toHaveBeenCalledTimes(4);
|
||||
// Existing progress reporting performs a second cooperative
|
||||
// checkpoint after each committed batch.
|
||||
expect(checkpoint).toHaveBeenCalledTimes(8);
|
||||
expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([
|
||||
100, 200, 205, 2,
|
||||
// 205 rows fit one row-budgeted category group, then the categories
|
||||
// go in a single statement.
|
||||
expect(harness.transaction).toHaveBeenCalledTimes(2);
|
||||
// Progress reporting performs a second cooperative checkpoint after
|
||||
// each committed batch.
|
||||
expect(checkpoint).toHaveBeenCalledTimes(4);
|
||||
expect(onProgress.mock.calls.map(([value]) => value)).toEqual([
|
||||
{
|
||||
phase: 'deleting-content',
|
||||
current: 205,
|
||||
total: 205,
|
||||
increment: 205,
|
||||
},
|
||||
{
|
||||
phase: 'deleting-categories',
|
||||
current: 2,
|
||||
total: 2,
|
||||
increment: 2,
|
||||
},
|
||||
]);
|
||||
expect(harness.timeline.slice(1, 5)).toEqual([
|
||||
'select:categories',
|
||||
@@ -123,6 +190,29 @@ describe('Xtream delete performance phases', () => {
|
||||
'select:recently-viewed',
|
||||
'select:content',
|
||||
]);
|
||||
// The count and both deletes stay scoped to the category ids the
|
||||
// collection read, never to the playlist: a newer import of the same
|
||||
// playlist may create categories between the read and the delete.
|
||||
expect(harness.countFilters.map(renderFilter)).toEqual([
|
||||
{ sql: '"categories"."id" in (?, ?)', params: [11, 12] },
|
||||
]);
|
||||
expect(
|
||||
harness.deleteFilters.map(({ table, filter }) => ({
|
||||
table: table === schema.content ? 'content' : 'categories',
|
||||
...renderFilter(filter),
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
table: 'content',
|
||||
sql: '"content"."category_id" in (?, ?)',
|
||||
params: [11, 12],
|
||||
},
|
||||
{
|
||||
table: 'categories',
|
||||
sql: '"categories"."id" in (?, ?)',
|
||||
params: [11, 12],
|
||||
},
|
||||
]);
|
||||
const writeStart = harness.timeline.indexOf(
|
||||
'sqlite.xtream-delete.write-transactions:start'
|
||||
);
|
||||
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
type XtreamBackupRecentlyViewedItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
type CategoryRowCount,
|
||||
countContentRowsByCategory,
|
||||
deleteCategoriesWhere,
|
||||
deleteContentByCategoryGroups,
|
||||
sumCategoryRowCounts,
|
||||
} from './catalog-deletion';
|
||||
import {
|
||||
checkpointOperation,
|
||||
chunkValues,
|
||||
@@ -15,6 +22,7 @@ import {
|
||||
} from './operation-control';
|
||||
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
|
||||
|
||||
/** Favorites and recently-viewed rows restored per transaction. */
|
||||
const DEFAULT_BATCH_SIZE = 100;
|
||||
|
||||
type ContentIdentity = {
|
||||
@@ -32,7 +40,8 @@ function toContentIdentityKey(
|
||||
|
||||
interface XtreamDeletionCollection {
|
||||
readonly categoryIds: number[];
|
||||
readonly contentRows: Array<{ id: number }>;
|
||||
/** Content rows per category, the unit the delete is batched by. */
|
||||
readonly contentRowCounts: CategoryRowCount[];
|
||||
readonly favorites: XtreamBackupFavoriteItem[];
|
||||
readonly hiddenCategories: XtreamBackupHiddenCategory[];
|
||||
readonly recentlyViewed: XtreamBackupRecentlyViewedItem[];
|
||||
@@ -62,7 +71,7 @@ async function collectXtreamDeletionRows(
|
||||
|
||||
let favorites: XtreamBackupFavoriteItem[] = [];
|
||||
let recentlyViewed: XtreamBackupRecentlyViewedItem[] = [];
|
||||
let contentRows: Array<{ id: number }> = [];
|
||||
let contentRowCounts: CategoryRowCount[] = [];
|
||||
|
||||
if (categoryIds.length > 0) {
|
||||
const favoritedContent = await db
|
||||
@@ -115,71 +124,55 @@ async function collectXtreamDeletionRows(
|
||||
viewedAt: item.viewedAt || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
contentRows = await db
|
||||
.select({ id: schema.content.id })
|
||||
.from(schema.content)
|
||||
.where(inArray(schema.content.categoryId, categoryIds));
|
||||
contentRowCounts = await countContentRowsByCategory(
|
||||
db,
|
||||
inArray(schema.categories.id, categoryIds)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
categoryIds,
|
||||
contentRows,
|
||||
contentRowCounts,
|
||||
favorites,
|
||||
hiddenCategories,
|
||||
recentlyViewed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the collected catalog: content in row-budgeted category groups, then
|
||||
* the captured categories in one statement. Both stay scoped to the ids the
|
||||
* collection step read, never to the whole playlist: the worker serves other
|
||||
* requests between commits, so a newer import of the same playlist may have
|
||||
* created categories this refresh must not erase. Returns the number of
|
||||
* deletion candidates (content rows plus categories) for phase metadata.
|
||||
*/
|
||||
async function deleteCollectedXtreamRows(
|
||||
db: AppDatabase,
|
||||
collection: XtreamDeletionCollection,
|
||||
control?: OperationControl
|
||||
): Promise<number> {
|
||||
let deletedContent = 0;
|
||||
const totalContent = collection.contentRows.length;
|
||||
await deleteContentByCategoryGroups(db, collection.contentRowCounts, {
|
||||
control,
|
||||
phase: 'deleting-content',
|
||||
});
|
||||
|
||||
for (const chunk of chunkValues(
|
||||
collection.contentRows.map((content) => content.id),
|
||||
DEFAULT_BATCH_SIZE
|
||||
)) {
|
||||
await checkpointOperation(control);
|
||||
await db.transaction((tx) => {
|
||||
tx.delete(schema.content)
|
||||
.where(inArray(schema.content.id, chunk))
|
||||
.run();
|
||||
});
|
||||
deletedContent += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'deleting-content',
|
||||
current: deletedContent,
|
||||
total: totalContent,
|
||||
increment: chunk.length,
|
||||
});
|
||||
}
|
||||
|
||||
let deletedCategories = 0;
|
||||
const totalCategories = collection.categoryIds.length;
|
||||
|
||||
for (const chunk of chunkValues(
|
||||
collection.categoryIds,
|
||||
DEFAULT_BATCH_SIZE
|
||||
)) {
|
||||
if (totalCategories > 0) {
|
||||
await checkpointOperation(control);
|
||||
await db.transaction((tx) => {
|
||||
tx.delete(schema.categories)
|
||||
.where(inArray(schema.categories.id, chunk))
|
||||
.run();
|
||||
});
|
||||
deletedCategories += chunk.length;
|
||||
const deletedCategories = await deleteCategoriesWhere(
|
||||
db,
|
||||
inArray(schema.categories.id, collection.categoryIds)
|
||||
);
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'deleting-categories',
|
||||
current: deletedCategories,
|
||||
total: totalCategories,
|
||||
increment: chunk.length,
|
||||
increment: deletedCategories,
|
||||
});
|
||||
}
|
||||
|
||||
return totalContent + totalCategories;
|
||||
return sumCategoryRowCounts(collection.contentRowCounts) + totalCategories;
|
||||
}
|
||||
|
||||
export async function deleteXtreamContent(
|
||||
@@ -199,7 +192,8 @@ export async function deleteXtreamContent(
|
||||
() => collectXtreamDeletionRows(db, playlistId),
|
||||
(result) => ({
|
||||
itemCount:
|
||||
result.contentRows.length + result.categoryIds.length,
|
||||
sumCategoryRowCounts(result.contentRowCounts) +
|
||||
result.categoryIds.length,
|
||||
})
|
||||
)
|
||||
: await collectXtreamDeletionRows(db, playlistId);
|
||||
|
||||
@@ -4,40 +4,41 @@
|
||||
*/
|
||||
|
||||
export {
|
||||
// Tables
|
||||
playlists,
|
||||
categories,
|
||||
content,
|
||||
recentlyViewed,
|
||||
favorites,
|
||||
epgChannels,
|
||||
epgChannelMappings,
|
||||
epgPrograms,
|
||||
playbackPositions,
|
||||
downloads,
|
||||
recordings,
|
||||
appState,
|
||||
// Types
|
||||
type Playlist,
|
||||
type NewPlaylist,
|
||||
type AppState,
|
||||
type NewAppState,
|
||||
type Category,
|
||||
type NewCategory,
|
||||
type Content,
|
||||
type NewContent,
|
||||
type RecentlyViewed,
|
||||
type NewRecentlyViewed,
|
||||
type Favorite,
|
||||
type NewFavorite,
|
||||
type EpgChannel,
|
||||
type NewEpgChannel,
|
||||
type EpgProgramDb,
|
||||
type NewEpgProgramDb,
|
||||
type PlaybackPosition,
|
||||
type NewPlaybackPosition,
|
||||
type Download,
|
||||
type NewDownload,
|
||||
type Recording,
|
||||
type NewRecording,
|
||||
// Tables
|
||||
playlists,
|
||||
categories,
|
||||
content,
|
||||
recentlyViewed,
|
||||
favorites,
|
||||
epgChannels,
|
||||
epgChannelSources,
|
||||
epgChannelMappings,
|
||||
epgPrograms,
|
||||
playbackPositions,
|
||||
downloads,
|
||||
recordings,
|
||||
appState,
|
||||
// Types
|
||||
type Playlist,
|
||||
type NewPlaylist,
|
||||
type AppState,
|
||||
type NewAppState,
|
||||
type Category,
|
||||
type NewCategory,
|
||||
type Content,
|
||||
type NewContent,
|
||||
type RecentlyViewed,
|
||||
type NewRecentlyViewed,
|
||||
type Favorite,
|
||||
type NewFavorite,
|
||||
type EpgChannel,
|
||||
type NewEpgChannel,
|
||||
type EpgProgramDb,
|
||||
type NewEpgProgramDb,
|
||||
type PlaybackPosition,
|
||||
type NewPlaybackPosition,
|
||||
type Download,
|
||||
type NewDownload,
|
||||
type Recording,
|
||||
type NewRecording,
|
||||
} from '@iptvnator/shared/database';
|
||||
@@ -4,12 +4,18 @@
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { recoverLegacyProfile } from '../../services/legacy-profile-recovery';
|
||||
import { databaseWorkerClient } from '../../services/database-worker-client';
|
||||
import {
|
||||
handleWorkerRequest,
|
||||
requestWorkerWithEvents,
|
||||
} from './worker-events.utils';
|
||||
|
||||
ipcMain.handle('DB_RECOVER_LEGACY_PLAYLISTS', () => recoverLegacyProfile());
|
||||
handleWorkerRequest(
|
||||
'DB_MIGRATE_APP_PLAYLISTS',
|
||||
(playlists: Record<string, unknown>[]) => ({ playlists })
|
||||
);
|
||||
handleWorkerRequest('DB_CREATE_PLAYLIST', (playlist: Record<string, unknown>) => playlist);
|
||||
handleWorkerRequest(
|
||||
'DB_UPSERT_APP_PLAYLIST',
|
||||
|
||||
@@ -73,6 +73,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
|
||||
args: [playlist],
|
||||
payload: playlist,
|
||||
},
|
||||
{
|
||||
operation: 'DB_MIGRATE_APP_PLAYLISTS',
|
||||
args: [playlists],
|
||||
payload: { playlists },
|
||||
},
|
||||
{
|
||||
operation: 'DB_UPSERT_APP_PLAYLISTS',
|
||||
args: [playlists],
|
||||
|
||||
@@ -92,11 +92,20 @@ describe('database worker IPC contract', () => {
|
||||
|
||||
it('registers an IPC handler for every worker operation', () => {
|
||||
const registeredDbChannels = [...mockRegisteredHandlers.keys()]
|
||||
.filter((channel) => channel !== 'DB_CANCEL_OPERATION')
|
||||
.filter(
|
||||
(channel) =>
|
||||
![
|
||||
'DB_CANCEL_OPERATION',
|
||||
'DB_RECOVER_LEGACY_PLAYLISTS',
|
||||
].includes(channel)
|
||||
)
|
||||
.sort();
|
||||
|
||||
expect(registeredDbChannels).toEqual([...DB_WORKER_OPERATIONS].sort());
|
||||
expect(mockRegisteredHandlers.has('DB_CANCEL_OPERATION')).toBe(true);
|
||||
expect(mockRegisteredHandlers.has('DB_RECOVER_LEGACY_PLAYLISTS')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it.each(workerIpcContractCases)(
|
||||
|
||||
@@ -5,17 +5,26 @@ jest.mock('electron', () => ({
|
||||
}));
|
||||
|
||||
const mockEmbeddedMpvService = {
|
||||
createSession: jest.fn(),
|
||||
getSupport: jest.fn(),
|
||||
setPaused: jest.fn(),
|
||||
};
|
||||
const mockSessionOptions = {
|
||||
extraOptions: ['network-timeout=10', 'hwdec=no'],
|
||||
autoReconnect: false,
|
||||
};
|
||||
|
||||
jest.mock('../services/embedded-mpv-native.service', () => ({
|
||||
EmbeddedMpvNativeService: class {},
|
||||
embeddedMpvNativeService: mockEmbeddedMpvService,
|
||||
}));
|
||||
jest.mock('../services/embedded-mpv-session-options', () => ({
|
||||
readEmbeddedMpvSessionOptions: () => mockSessionOptions,
|
||||
}));
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
EMBEDDED_MPV_CREATE_SESSION,
|
||||
EMBEDDED_MPV_SET_PAUSED,
|
||||
EMBEDDED_MPV_SUPPORT,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -41,10 +50,29 @@ function getIpcMainHandler(
|
||||
|
||||
describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
beforeEach(() => {
|
||||
mockEmbeddedMpvService.createSession.mockReset();
|
||||
mockEmbeddedMpvService.getSupport.mockReset();
|
||||
mockEmbeddedMpvService.setPaused.mockReset();
|
||||
});
|
||||
|
||||
it('creates a session with the options read from the settings mirror', async () => {
|
||||
const session = { id: 'session-1', status: 'idle' };
|
||||
mockEmbeddedMpvService.createSession.mockReturnValue(session);
|
||||
const bounds = { x: 0, y: 0, width: 640, height: 360 };
|
||||
|
||||
const handler = getIpcMainHandler(EMBEDDED_MPV_CREATE_SESSION);
|
||||
|
||||
await expect(handler({}, bounds, 'Title', 0.5)).resolves.toEqual(
|
||||
session
|
||||
);
|
||||
expect(mockEmbeddedMpvService.createSession).toHaveBeenCalledWith(
|
||||
bounds,
|
||||
'Title',
|
||||
0.5,
|
||||
mockSessionOptions
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards arguments to the native service and returns its result', async () => {
|
||||
const session = { id: 'session-1', status: 'paused' };
|
||||
mockEmbeddedMpvService.setPaused.mockReturnValue(session);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
EMBEDDED_MPV_LOAD_PLAYBACK,
|
||||
EMBEDDED_MPV_PREPARE,
|
||||
EMBEDDED_MPV_SEEK,
|
||||
EMBEDDED_MPV_SEEK_BY,
|
||||
EMBEDDED_MPV_SELECT_SUBTITLE_FILE,
|
||||
EMBEDDED_MPV_SET_ASPECT,
|
||||
EMBEDDED_MPV_SET_AUDIO_TRACK,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
EmbeddedMpvNativeService,
|
||||
embeddedMpvNativeService,
|
||||
} from '../services/embedded-mpv-native.service';
|
||||
import { readEmbeddedMpvSessionOptions } from '../services/embedded-mpv-session-options';
|
||||
|
||||
export default class EmbeddedMpvEvents {
|
||||
static bootstrapEmbeddedMpvEvents(): Electron.IpcMain {
|
||||
@@ -56,10 +58,7 @@ function handleEmbeddedMpv<Args extends unknown[]>(
|
||||
try {
|
||||
return await handler(...(args as Args));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Embedded MPV] ${channel} handler failed:`,
|
||||
error
|
||||
);
|
||||
console.error(`[Embedded MPV] ${channel} handler failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -72,7 +71,14 @@ handleEmbeddedMpv(EMBEDDED_MPV_PREPARE, () => getService().prepareAddon());
|
||||
handleEmbeddedMpv(
|
||||
EMBEDDED_MPV_CREATE_SESSION,
|
||||
(bounds: EmbeddedMpvBounds, title?: string, initialVolume?: number) =>
|
||||
getService().createSession(bounds, title, initialVolume)
|
||||
// Settings are read here, per session, so the service itself never
|
||||
// touches the config store (constructed at module load).
|
||||
getService().createSession(
|
||||
bounds,
|
||||
title,
|
||||
initialVolume,
|
||||
readEmbeddedMpvSessionOptions()
|
||||
)
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(
|
||||
@@ -97,6 +103,12 @@ handleEmbeddedMpv(EMBEDDED_MPV_SEEK, (sessionId: string, seconds: number) =>
|
||||
getService().seek(sessionId, seconds)
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(
|
||||
EMBEDDED_MPV_SEEK_BY,
|
||||
(sessionId: string, deltaSeconds: number) =>
|
||||
getService().seekBy(sessionId, deltaSeconds)
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(
|
||||
EMBEDDED_MPV_SET_VOLUME,
|
||||
(sessionId: string, volume: number) =>
|
||||
@@ -137,9 +149,8 @@ handleEmbeddedMpv(EMBEDDED_MPV_SELECT_SUBTITLE_FILE, () =>
|
||||
getService().selectSubtitleFile()
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(
|
||||
EMBEDDED_MPV_SET_SPEED,
|
||||
(sessionId: string, speed: number) => getService().setSpeed(sessionId, speed)
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_SET_SPEED, (sessionId: string, speed: number) =>
|
||||
getService().setSpeed(sessionId, speed)
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import type { Worker } from 'worker_threads';
|
||||
import type {
|
||||
ElectronBridgeSecurityErrorCode,
|
||||
ElectronBridgeTrustOptions,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { epgSourceGeneration } from './epg-source-generation';
|
||||
import type { EpgWorkerRuntime } from './epg-worker-runtime';
|
||||
import type { EpgProgressStats, EpgWorkerService } from './epg-worker.service';
|
||||
|
||||
interface EpgWorkerMessage {
|
||||
type: string;
|
||||
error?: string;
|
||||
errorCode?: ElectronBridgeSecurityErrorCode;
|
||||
errorHost?: string;
|
||||
url?: string;
|
||||
stats?: EpgProgressStats;
|
||||
}
|
||||
|
||||
interface FetchContext {
|
||||
runtime: EpgWorkerRuntime;
|
||||
workers: Map<string, Worker>;
|
||||
fetchedUrls: Set<string>;
|
||||
loggerLabel: string;
|
||||
fetchTimeoutMs: number;
|
||||
sendProgressToRenderer: EpgWorkerService['sendProgressToRenderer'];
|
||||
}
|
||||
|
||||
/** One import's message, cancellation, timeout and exit lifecycle. */
|
||||
export function runEpgFetch(
|
||||
url: string,
|
||||
options: ElectronBridgeTrustOptions,
|
||||
context: FetchContext
|
||||
): Promise<void> {
|
||||
const generation = epgSourceGeneration(url);
|
||||
return new Promise((resolve, reject) => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = context.runtime.createEpgWorker();
|
||||
} catch (error) {
|
||||
epgLogger.error(
|
||||
context.loggerLabel,
|
||||
'Failed to create worker:',
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
context.workers.set(url, worker);
|
||||
|
||||
// Guards against double-settling and keeps the outer loop moving
|
||||
// when the worker dies or hangs without sending EPG_COMPLETE/EPG_ERROR.
|
||||
let settled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
let lastProgressStats: EpgProgressStats = {
|
||||
totalChannels: 0,
|
||||
totalPrograms: 0,
|
||||
};
|
||||
|
||||
const clearFetchTimeout = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearFetchTimeout();
|
||||
fn();
|
||||
};
|
||||
|
||||
const cancelIfRetired = (exited = false): boolean => {
|
||||
if (generation === epgSourceGeneration(url)) return false;
|
||||
if (context.workers.get(url) === worker)
|
||||
context.workers.delete(url);
|
||||
settle(() => {
|
||||
context.sendProgressToRenderer(
|
||||
url,
|
||||
'cancelled',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
generation
|
||||
);
|
||||
if (exited) resolve();
|
||||
else
|
||||
void context.runtime
|
||||
.terminateWorker(worker, 'retired fetch')
|
||||
.then(() => resolve());
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const scheduleFetchTimeout = () => {
|
||||
clearFetchTimeout();
|
||||
timeoutId = setTimeout(() => {
|
||||
handleFetchTimeout();
|
||||
}, context.fetchTimeoutMs);
|
||||
};
|
||||
|
||||
const hasProgressMoved = (stats: EpgProgressStats): boolean =>
|
||||
stats.totalChannels > lastProgressStats.totalChannels ||
|
||||
stats.totalPrograms > lastProgressStats.totalPrograms;
|
||||
|
||||
const recordProgress = (stats: EpgProgressStats): void => {
|
||||
lastProgressStats = {
|
||||
totalChannels: Math.max(
|
||||
lastProgressStats.totalChannels,
|
||||
stats.totalChannels
|
||||
),
|
||||
totalPrograms: Math.max(
|
||||
lastProgressStats.totalPrograms,
|
||||
stats.totalPrograms
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const handleFetchTimeout = () => {
|
||||
if (settled || cancelIfRetired()) return;
|
||||
const errorMessage = `EPG fetch timed out after ${
|
||||
context.fetchTimeoutMs / 1000
|
||||
}s without progress`;
|
||||
epgLogger.error(context.loggerLabel, errorMessage);
|
||||
context.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
errorMessage
|
||||
);
|
||||
context.workers.delete(url);
|
||||
// Settle only after the worker thread is really gone: a
|
||||
// terminated-but-still-running worker can keep holding the
|
||||
// SQLite lock and block the next EPG fetch.
|
||||
settle(() => {
|
||||
void context.runtime
|
||||
.terminateWorker(worker, 'timed out fetch')
|
||||
.then(() => reject(new Error(errorMessage)));
|
||||
});
|
||||
};
|
||||
|
||||
scheduleFetchTimeout();
|
||||
|
||||
worker.on('message', async (message: EpgWorkerMessage) => {
|
||||
if (settled || generation !== epgSourceGeneration(url)) return;
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'READY':
|
||||
scheduleFetchTimeout();
|
||||
context.sendProgressToRenderer(url, 'loading', {
|
||||
totalChannels: 0,
|
||||
totalPrograms: 0,
|
||||
});
|
||||
worker.postMessage({
|
||||
type: 'FETCH_EPG',
|
||||
url,
|
||||
options,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'EPG_PROGRESS':
|
||||
if (message.stats) {
|
||||
if (hasProgressMoved(message.stats)) {
|
||||
recordProgress(message.stats);
|
||||
scheduleFetchTimeout();
|
||||
}
|
||||
context.sendProgressToRenderer(
|
||||
url,
|
||||
'loading',
|
||||
message.stats
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EPG_COMPLETE':
|
||||
epgLogger.log(
|
||||
context.loggerLabel,
|
||||
'EPG parsing complete:',
|
||||
message.stats
|
||||
);
|
||||
context.sendProgressToRenderer(
|
||||
url,
|
||||
'complete',
|
||||
message.stats
|
||||
);
|
||||
context.fetchedUrls.add(url);
|
||||
context.workers.delete(url);
|
||||
settle(() => {
|
||||
void context.runtime
|
||||
.terminateWorker(worker, 'completed fetch')
|
||||
.then(() => resolve());
|
||||
});
|
||||
break;
|
||||
|
||||
case 'EPG_ERROR':
|
||||
epgLogger.error(
|
||||
context.loggerLabel,
|
||||
'Worker error:',
|
||||
message.error
|
||||
);
|
||||
context.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
message.error,
|
||||
undefined,
|
||||
message.errorCode,
|
||||
message.errorHost
|
||||
);
|
||||
context.workers.delete(url);
|
||||
settle(() => {
|
||||
void context.runtime
|
||||
.terminateWorker(worker, 'failed fetch')
|
||||
.then(() =>
|
||||
reject(
|
||||
new Error(
|
||||
message.error || 'Unknown error'
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
if (settled || cancelIfRetired()) return;
|
||||
epgLogger.error(
|
||||
context.loggerLabel,
|
||||
'Error handling message:',
|
||||
err
|
||||
);
|
||||
context.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
context.workers.delete(url);
|
||||
settle(() => {
|
||||
void context.runtime
|
||||
.terminateWorker(worker, 'failed message handling')
|
||||
.then(() => reject(err));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
worker.on('error', (error) => {
|
||||
if (settled || cancelIfRetired()) return;
|
||||
epgLogger.error(context.loggerLabel, 'Worker error event:', error);
|
||||
context.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
error.message
|
||||
);
|
||||
context.workers.delete(url);
|
||||
settle(() => {
|
||||
void context.runtime
|
||||
.terminateWorker(worker, 'errored fetch')
|
||||
.then(() => reject(error));
|
||||
});
|
||||
});
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (settled || cancelIfRetired(true)) return;
|
||||
const errorMessage = `Worker exited unexpectedly (code ${code})`;
|
||||
epgLogger.error(context.loggerLabel, errorMessage);
|
||||
context.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
errorMessage
|
||||
);
|
||||
context.workers.delete(url);
|
||||
settle(() => reject(new Error(errorMessage)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import { epgSourceGeneration, requestEpgSource } from './epg-source-generation';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces';
|
||||
import { getDatabase } from '../database/connection';
|
||||
@@ -45,12 +47,13 @@ export async function checkEpgFreshness(
|
||||
const db = await getDatabase();
|
||||
|
||||
for (const url of urls) {
|
||||
const generation = epgSourceGeneration(url);
|
||||
if (!url?.trim()) continue;
|
||||
|
||||
const result = await db
|
||||
.select({ updatedAt: schema.epgChannels.updatedAt })
|
||||
.from(schema.epgChannels)
|
||||
.where(eq(schema.epgChannels.sourceUrl, url))
|
||||
.select({ updatedAt: schema.epgChannelSources.updatedAt })
|
||||
.from(schema.epgChannelSources)
|
||||
.where(eq(schema.epgChannelSources.sourceUrl, url))
|
||||
.limit(1);
|
||||
|
||||
const isFresh =
|
||||
@@ -58,6 +61,7 @@ export async function checkEpgFreshness(
|
||||
result[0].updatedAt &&
|
||||
result[0].updatedAt >= cutoffTime;
|
||||
|
||||
if (generation !== epgSourceGeneration(url)) continue;
|
||||
if (isFresh) {
|
||||
freshUrls.push(url);
|
||||
epgWorkerService.markFetchedUrl(url);
|
||||
@@ -66,18 +70,18 @@ export async function checkEpgFreshness(
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error checking EPG freshness:', error);
|
||||
epgLogger.error(loggerLabel, 'Error checking EPG freshness:', error);
|
||||
return { staleUrls: urls, freshUrls: [] };
|
||||
}
|
||||
|
||||
if (freshUrls.length > 0) {
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
loggerLabel,
|
||||
`EPG fresh (skipping): ${freshUrls.length} source(s)`
|
||||
);
|
||||
}
|
||||
if (staleUrls.length > 0) {
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
loggerLabel,
|
||||
`EPG stale (will fetch): ${staleUrls.length} source(s)`
|
||||
);
|
||||
@@ -95,7 +99,12 @@ export async function handleFetchEpg(
|
||||
urls: string[],
|
||||
options: ElectronBridgeTrustOptions = {}
|
||||
): Promise<EpgFetchResult> {
|
||||
const validUrls = urls.filter((url) => url?.trim());
|
||||
const validUrls = urls
|
||||
.filter((url) => url?.trim())
|
||||
.map((url) => url.trim());
|
||||
const generations = new Map(
|
||||
validUrls.map((url) => [url, requestEpgSource(url)])
|
||||
);
|
||||
|
||||
if (validUrls.length === 0) {
|
||||
return { success: false, message: 'No valid URLs provided' };
|
||||
@@ -118,11 +127,13 @@ export async function handleFetchEpg(
|
||||
// a 'queued' status, then fetchEpgFromUrl silently skips the URL and no
|
||||
// completion update ever arrives, leaving the UI stuck at "queued".
|
||||
const urlsToFetch = staleUrls.filter(
|
||||
(url) => !epgWorkerService.hasFetchedUrl(url)
|
||||
(url) =>
|
||||
generations.get(url) === epgSourceGeneration(url) &&
|
||||
!epgWorkerService.hasFetchedUrl(url)
|
||||
);
|
||||
|
||||
if (urlsToFetch.length === 0) {
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
loggerLabel,
|
||||
`All ${staleUrls.length} stale URL(s) already fetched this session; skipping`
|
||||
);
|
||||
@@ -142,16 +153,23 @@ export async function handleFetchEpg(
|
||||
const errors: string[] = [];
|
||||
for (const url of urlsToFetch) {
|
||||
try {
|
||||
if (generations.get(url) !== epgSourceGeneration(url)) {
|
||||
epgWorkerService.sendProgressToRenderer(
|
||||
url,
|
||||
'cancelled',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
generations.get(url)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await epgWorkerService.fetchEpgFromUrl(url, options);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
loggerLabel,
|
||||
`Error fetching EPG from ${url}:`,
|
||||
error
|
||||
);
|
||||
errors.push(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
epgLogger.error(loggerLabel, 'Error fetching EPG source:', error);
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ function flattenSql(value: unknown, seen = new Set<unknown>()): string {
|
||||
return sqlLike.value.join(' ');
|
||||
}
|
||||
|
||||
// Bound parameters (`sql\`${now}\``) carry a scalar value.
|
||||
if (typeof sqlLike.value === 'string') {
|
||||
return sqlLike.value;
|
||||
}
|
||||
|
||||
if (typeof sqlLike.name === 'string') {
|
||||
return sqlLike.name;
|
||||
}
|
||||
@@ -289,6 +294,80 @@ describe('EpgQueryService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('evaluates "currently airing" at the caller-supplied instant', async () => {
|
||||
const whereCalls: unknown[] = [];
|
||||
const select = jest
|
||||
.fn()
|
||||
.mockReturnValue(createLimitedSelectChain([], whereCalls));
|
||||
getDatabase.mockResolvedValue({ select });
|
||||
// The renderer hands over its wall clock shifted into the provider's
|
||||
// EPG clock (display offset); the SQL must compare against exactly that.
|
||||
const nowMs = Date.parse('2026-06-29T20:30:00.000Z');
|
||||
|
||||
await service.getCurrentProgramsBatch(['ch'], {
|
||||
sourceUrls: ['https://example.com/guide.xml'],
|
||||
nowMs,
|
||||
});
|
||||
|
||||
const flattened = whereCalls.map((condition) => flattenSql(condition));
|
||||
expect(
|
||||
flattened.some((condition) =>
|
||||
condition.includes('2026-06-29T20:30:00.000Z')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the main-process clock for a missing or corrupt instant', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-06-29T21:00:00.000Z'));
|
||||
try {
|
||||
for (const nowMs of [undefined, Number.NaN, 1e20, -1e20]) {
|
||||
const whereCalls: unknown[] = [];
|
||||
const select = jest.fn().mockReturnValue(
|
||||
createLimitedSelectChain(
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
channelId: 'ch',
|
||||
start: '2026-06-29T20:00:00Z',
|
||||
stop: '2026-06-29T22:00:00Z',
|
||||
title: 'Now',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
sourceUrl: 'https://example.com/guide.xml',
|
||||
},
|
||||
],
|
||||
whereCalls
|
||||
)
|
||||
);
|
||||
getDatabase.mockResolvedValue({ select });
|
||||
|
||||
await expect(
|
||||
service.getCurrentProgramsBatch(['ch'], {
|
||||
sourceUrls: ['https://example.com/guide.xml'],
|
||||
nowMs,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ch: expect.objectContaining({ title: 'Now' }),
|
||||
});
|
||||
|
||||
const flattened = whereCalls.map((condition) =>
|
||||
flattenSql(condition)
|
||||
);
|
||||
expect(
|
||||
flattened.some((condition) =>
|
||||
condition.includes('2026-06-29T21:00:00.000Z')
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to an unscoped current-programme lookup when programmes are tagged with an out-of-scope source', async () => {
|
||||
const whereCalls: unknown[] = [];
|
||||
const currentProgram = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import {
|
||||
EpgChannelMetadata,
|
||||
@@ -154,7 +155,7 @@ export class EpgQueryService {
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error getting channel programs:',
|
||||
error
|
||||
@@ -165,7 +166,7 @@ export class EpgQueryService {
|
||||
|
||||
async getCurrentProgramsBatch(
|
||||
channelIds: string[],
|
||||
options: { sourceUrls?: string[] } = {}
|
||||
options: { sourceUrls?: string[]; nowMs?: number } = {}
|
||||
): Promise<Record<string, EpgProgram | null>> {
|
||||
const result: Record<string, EpgProgram | null> = {};
|
||||
if (!Array.isArray(channelIds) || channelIds.length === 0) {
|
||||
@@ -185,7 +186,7 @@ export class EpgQueryService {
|
||||
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
const now = this.toAiringInstantIso(options.nowMs);
|
||||
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
|
||||
|
||||
this.assignCurrentProgramRows(
|
||||
@@ -310,7 +311,7 @@ export class EpgQueryService {
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error getting batch current programs:',
|
||||
error
|
||||
@@ -335,7 +336,7 @@ export class EpgQueryService {
|
||||
|
||||
return { channels, programs: [] };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error getting all channels:',
|
||||
error
|
||||
@@ -391,7 +392,7 @@ export class EpgQueryService {
|
||||
])
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error getting channel metadata:',
|
||||
error
|
||||
@@ -439,7 +440,7 @@ export class EpgQueryService {
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error getting channels by range:',
|
||||
error
|
||||
@@ -618,6 +619,22 @@ export class EpgQueryService {
|
||||
.limit(channelIds.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant "currently airing" is evaluated at, as the UTC ISO string
|
||||
* `isAiringAt` expects. The renderer passes its wall clock shifted into
|
||||
* the provider's EPG clock (the display offset setting) so the row it
|
||||
* receives is the one it will also render as "now"; without a usable
|
||||
* value the main process clock applies. Bounded to the range `Date` can
|
||||
* serialize so a corrupt payload cannot throw inside the query.
|
||||
*/
|
||||
private toAiringInstantIso(nowMs?: number): string {
|
||||
const usable =
|
||||
typeof nowMs === 'number' &&
|
||||
Number.isFinite(nowMs) &&
|
||||
Math.abs(nowMs) <= 8.64e15;
|
||||
return new Date(usable ? nowMs : Date.now()).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Timezone-aware "currently airing at `now`" predicate. EPG start/stop are
|
||||
* stored as ISO strings that often carry a UTC offset (e.g. `+03:00`) while
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Retires queued imports as well as workers already parsing a removed URL. */
|
||||
const generations = new Map<string, number>();
|
||||
const requests = new Map<string, symbol>();
|
||||
export function epgSourceGeneration(url: string): number {
|
||||
return generations.get(url.trim()) ?? 0;
|
||||
}
|
||||
export function requestEpgSource(url: string): number {
|
||||
requests.set(url.trim(), Symbol());
|
||||
return epgSourceGeneration(url);
|
||||
}
|
||||
export function retireEpgSource(url: string): void {
|
||||
generations.set(url.trim(), epgSourceGeneration(url) + 1);
|
||||
}
|
||||
export function requestedEpgSources(): Map<string, symbol> {
|
||||
return new Map(requests);
|
||||
}
|
||||
export function forgetEpgSourceRequest(
|
||||
url: string,
|
||||
request: symbol | undefined
|
||||
): void {
|
||||
// A request received during cleanup still needs reconciliation next time.
|
||||
if (requests.get(url.trim()) === request) requests.delete(url.trim());
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
appState,
|
||||
epgChannels,
|
||||
epgChannelSources,
|
||||
epgPrograms,
|
||||
playlists,
|
||||
} from '../database/schema';
|
||||
import { getDatabase } from '../database/connection';
|
||||
import { epgWorkerService } from './epg-worker.service';
|
||||
import { epgSourceGeneration, requestEpgSource } from './epg-source-generation';
|
||||
import { reconcileEpgSources } from './epg-source-settings.service';
|
||||
|
||||
jest.mock('../database/connection', () => ({ getDatabase: jest.fn() }));
|
||||
jest.mock('./epg-worker.service', () => ({
|
||||
epgWorkerService: {
|
||||
clearEpgDataForSource: jest.fn().mockResolvedValue(undefined),
|
||||
sendProgressToRenderer: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('committed EPG source reconciliation', () => {
|
||||
let rows: Map<unknown, unknown[]>;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
rows = new Map<unknown, unknown[]>([
|
||||
[appState, [{ value: '1' }]],
|
||||
[playlists, []],
|
||||
[epgChannels, [{ url: 'removed' }, { url: 'shared' }]],
|
||||
[epgPrograms, [{ url: 'removed' }, { url: 'second' }]],
|
||||
]);
|
||||
const select = () => ({
|
||||
from: (table: unknown) => {
|
||||
const result = rows.get(table) ?? [];
|
||||
return Object.assign(Promise.resolve(result), {
|
||||
where: () => Promise.resolve(result),
|
||||
});
|
||||
},
|
||||
});
|
||||
(getDatabase as jest.Mock).mockResolvedValue({
|
||||
select,
|
||||
selectDistinct: select,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves global and every enabled M3U source, including an overlapping channel owner', async () => {
|
||||
rows.set(playlists, [{ type: 'm3u-url', urls: '["shared"]' }]);
|
||||
const generation = epgSourceGeneration('removed');
|
||||
await reconcileEpgSources([' second ']);
|
||||
expect(epgWorkerService.clearEpgDataForSource).toHaveBeenCalledWith(
|
||||
'removed'
|
||||
);
|
||||
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalledWith(
|
||||
'shared'
|
||||
);
|
||||
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalledWith(
|
||||
'second'
|
||||
);
|
||||
expect(epgSourceGeneration('removed')).toBeGreaterThan(generation);
|
||||
});
|
||||
|
||||
it('cleans already orphaned sources when the saved global list is empty', async () => {
|
||||
await reconcileEpgSources([]);
|
||||
for (const url of ['removed', 'shared', 'second']) {
|
||||
expect(epgWorkerService.clearEpgDataForSource).toHaveBeenCalledWith(
|
||||
url
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('finds metadata-only source owners after restart', async () => {
|
||||
rows.set(epgChannels, [{ url: 'active' }]);
|
||||
rows.set(epgPrograms, []);
|
||||
rows.set(epgChannelSources, [
|
||||
{ url: 'metadata-only' },
|
||||
{ url: 'active' },
|
||||
]);
|
||||
await reconcileEpgSources(['active']);
|
||||
expect(epgWorkerService.clearEpgDataForSource).toHaveBeenCalledWith(
|
||||
'metadata-only'
|
||||
);
|
||||
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalledWith(
|
||||
'active'
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels completed error rows even without cached data or a running worker', async () => {
|
||||
rows.set(epgChannels, []);
|
||||
rows.set(epgPrograms, []);
|
||||
const generation = requestEpgSource('failed-import');
|
||||
await reconcileEpgSources([]);
|
||||
expect(epgWorkerService.sendProgressToRenderer).toHaveBeenCalledWith(
|
||||
'failed-import',
|
||||
'cancelled',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
generation
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels every retired row before the first source clear can fail', async () => {
|
||||
const generation = epgSourceGeneration('second');
|
||||
(
|
||||
epgWorkerService.clearEpgDataForSource as jest.Mock
|
||||
).mockImplementationOnce(async () => {
|
||||
expect(
|
||||
epgWorkerService.sendProgressToRenderer
|
||||
).toHaveBeenCalledWith(
|
||||
'second',
|
||||
'cancelled',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
generation
|
||||
);
|
||||
throw new Error('clear failed');
|
||||
});
|
||||
await expect(reconcileEpgSources(['shared'])).rejects.toThrow(
|
||||
'clear failed'
|
||||
);
|
||||
expect(
|
||||
(
|
||||
epgWorkerService.sendProgressToRenderer as jest.Mock
|
||||
).mock.calls.map(([url]) => url)
|
||||
).not.toContain('shared');
|
||||
});
|
||||
|
||||
it('does not clear historical request keys again after successful cleanup', async () => {
|
||||
requestEpgSource('removed');
|
||||
await reconcileEpgSources([]);
|
||||
rows.set(epgChannels, []);
|
||||
rows.set(epgPrograms, []);
|
||||
jest.clearAllMocks();
|
||||
await reconcileEpgSources([]);
|
||||
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a failed cleanup even when the queued source had no database rows', async () => {
|
||||
rows.set(epgChannels, []);
|
||||
rows.set(epgPrograms, []);
|
||||
requestEpgSource('queued-only');
|
||||
(
|
||||
epgWorkerService.clearEpgDataForSource as jest.Mock
|
||||
).mockRejectedValueOnce(new Error('worker failure'));
|
||||
await expect(reconcileEpgSources([])).rejects.toThrow('worker failure');
|
||||
await reconcileEpgSources([]);
|
||||
expect(epgWorkerService.clearEpgDataForSource).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('preserves a new request received while the previous request is being cleared', async () => {
|
||||
rows.set(epgChannels, []);
|
||||
rows.set(epgPrograms, []);
|
||||
requestEpgSource('requested-again');
|
||||
(
|
||||
epgWorkerService.clearEpgDataForSource as jest.Mock
|
||||
).mockImplementationOnce(async () => {
|
||||
requestEpgSource('requested-again');
|
||||
});
|
||||
await reconcileEpgSources([]);
|
||||
await reconcileEpgSources([]);
|
||||
expect(epgWorkerService.clearEpgDataForSource).toHaveBeenCalledTimes(2);
|
||||
jest.clearAllMocks();
|
||||
await reconcileEpgSources([]);
|
||||
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not prune sources before playlist migration succeeds', async () => {
|
||||
rows.set(appState, []);
|
||||
await expect(reconcileEpgSources([])).rejects.toThrow(
|
||||
'migrated playlists'
|
||||
);
|
||||
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not guess ownership when persisted playlist source metadata is invalid', async () => {
|
||||
rows.set(playlists, [{ type: 'm3u-url', urls: '{broken' }]);
|
||||
await expect(reconcileEpgSources([])).rejects.toThrow();
|
||||
expect(epgWorkerService.clearEpgDataForSource).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDatabase } from '../database/connection';
|
||||
import {
|
||||
appState,
|
||||
epgChannels,
|
||||
epgChannelSources,
|
||||
epgPrograms,
|
||||
playlists,
|
||||
} from '../database/schema';
|
||||
import { epgWorkerService } from './epg-worker.service';
|
||||
import {
|
||||
epgSourceGeneration,
|
||||
forgetEpgSourceRequest,
|
||||
requestedEpgSources,
|
||||
retireEpgSource,
|
||||
} from './epg-source-generation';
|
||||
|
||||
let reconciliation = Promise.resolve();
|
||||
|
||||
/** Called only with settings successfully read from or written to IndexedDB. */
|
||||
export function reconcileEpgSources(globalUrls: string[]): Promise<void> {
|
||||
const next = reconciliation
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const db = await getDatabase();
|
||||
const migration = await db
|
||||
.select()
|
||||
.from(appState)
|
||||
.where(
|
||||
eq(appState.key, 'm3u-playlists-indexeddb-to-sqlite-v1')
|
||||
);
|
||||
// A failed/incomplete migration cannot establish all playlist owners.
|
||||
if (migration[0]?.value !== '1') {
|
||||
throw new Error(
|
||||
'EPG source reconciliation requires migrated playlists'
|
||||
);
|
||||
}
|
||||
const active = new Set(
|
||||
globalUrls.map((url) => url.trim()).filter(Boolean)
|
||||
);
|
||||
const savedPlaylists = await db
|
||||
.select({ type: playlists.type, urls: playlists.epgUrls })
|
||||
.from(playlists);
|
||||
for (const playlist of savedPlaylists) {
|
||||
if (!playlist.type.startsWith('m3u-')) continue;
|
||||
const urls: unknown = JSON.parse(playlist.urls || '[]');
|
||||
if (
|
||||
!Array.isArray(urls) ||
|
||||
urls.some((url) => typeof url !== 'string')
|
||||
) {
|
||||
throw new Error('Invalid saved playlist EPG ownership');
|
||||
}
|
||||
for (const url of urls as string[])
|
||||
if (url.trim()) active.add(url.trim());
|
||||
}
|
||||
const channels = await db
|
||||
.selectDistinct({ url: epgChannels.sourceUrl })
|
||||
.from(epgChannels);
|
||||
const programs = await db
|
||||
.selectDistinct({ url: epgPrograms.sourceUrl })
|
||||
.from(epgPrograms);
|
||||
const metadata = await db
|
||||
.selectDistinct({ url: epgChannelSources.sourceUrl })
|
||||
.from(epgChannelSources);
|
||||
const requested = requestedEpgSources();
|
||||
const removed = [
|
||||
...new Set(
|
||||
[
|
||||
...channels.map((row) => row.url),
|
||||
...programs.map((row) => row.url),
|
||||
...metadata.map((row) => row.url),
|
||||
...requested.keys(),
|
||||
].filter(
|
||||
(url): url is string =>
|
||||
!!url?.trim() && !active.has(url.trim())
|
||||
)
|
||||
),
|
||||
];
|
||||
// Fence the whole obsolete set before awaiting the first worker exit.
|
||||
removed.forEach((url) => {
|
||||
const generation = epgSourceGeneration(url);
|
||||
retireEpgSource(url);
|
||||
// Finished imports can leave actionable error rows with Retry.
|
||||
epgWorkerService.sendProgressToRenderer(
|
||||
url,
|
||||
'cancelled',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
generation
|
||||
);
|
||||
});
|
||||
for (const url of removed) {
|
||||
await epgWorkerService.clearEpgDataForSource(url);
|
||||
forgetEpgSourceRequest(url, requested.get(url.trim()));
|
||||
}
|
||||
});
|
||||
reconciliation = next;
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import { app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { Worker } from 'worker_threads';
|
||||
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
|
||||
|
||||
interface ClearWorkerMessage {
|
||||
type: 'CLEAR_EPG' | 'CLEAR_EPG_SOURCE';
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
/** Worker bootstrap, shutdown and one-shot cache-clear protocol. */
|
||||
export class EpgWorkerRuntime {
|
||||
constructor(
|
||||
private readonly loggerLabel: string,
|
||||
private readonly fetchTimeoutMs: number
|
||||
) {}
|
||||
|
||||
runClearWorker(options: {
|
||||
timeoutLabel: string;
|
||||
exitLabel: string;
|
||||
readyMessage: ClearWorkerMessage;
|
||||
completeWorkerLabel: string;
|
||||
failedWorkerLabel: string;
|
||||
erroredWorkerLabel: string;
|
||||
onComplete: (worker: Worker) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = this.createEpgWorker();
|
||||
} catch (error) {
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Failed to create worker for clear:',
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
fn();
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
const errorMessage = `${options.timeoutLabel} timed out after ${
|
||||
this.fetchTimeoutMs / 1000
|
||||
}s`;
|
||||
epgLogger.error(this.loggerLabel, errorMessage);
|
||||
settle(() => {
|
||||
void this.terminateWorker(
|
||||
worker,
|
||||
`timed out ${options.timeoutLabel}`
|
||||
).then(() => reject(new Error(errorMessage)));
|
||||
});
|
||||
}, this.fetchTimeoutMs);
|
||||
|
||||
worker.on(
|
||||
'message',
|
||||
(message: { type: string; error?: string }) => {
|
||||
if (message.type === 'READY') {
|
||||
worker.postMessage(options.readyMessage);
|
||||
} else if (message.type === 'CLEAR_COMPLETE') {
|
||||
settle(() => {
|
||||
void options
|
||||
.onComplete(worker)
|
||||
.then(() => resolve(), reject);
|
||||
});
|
||||
} else if (message.type === 'EPG_ERROR') {
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Worker clear error:',
|
||||
message.error
|
||||
);
|
||||
settle(() => {
|
||||
void this.terminateWorker(
|
||||
worker,
|
||||
options.failedWorkerLabel
|
||||
).then(() =>
|
||||
reject(
|
||||
new Error(message.error || 'Clear failed')
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
worker.on('error', (error) => {
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Worker error during clear:',
|
||||
error
|
||||
);
|
||||
settle(() => {
|
||||
void this.terminateWorker(
|
||||
worker,
|
||||
options.erroredWorkerLabel
|
||||
).then(() => reject(error));
|
||||
});
|
||||
});
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return;
|
||||
const errorMessage = `${options.exitLabel} exited unexpectedly (code ${code})`;
|
||||
epgLogger.error(this.loggerLabel, errorMessage);
|
||||
settle(() => reject(new Error(errorMessage)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaits worker shutdown so callers can sequence work (e.g. the next DB
|
||||
* access) after the thread has really exited. Termination failures are
|
||||
* logged and swallowed — there is nothing actionable left to do.
|
||||
*/
|
||||
async terminateWorker(worker: Worker, context: string): Promise<void> {
|
||||
try {
|
||||
await worker.terminate();
|
||||
} catch (error) {
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
`Failed to terminate ${context} worker:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
createEpgWorker(): Worker {
|
||||
const bootstrap = resolveWorkerRuntimeBootstrap({
|
||||
isPackaged: app.isPackaged,
|
||||
workerFilename: 'epg-parser.worker.js',
|
||||
developmentWorkerDir: path.join(__dirname, 'workers'),
|
||||
resourcesPath: (
|
||||
process as NodeJS.Process & { resourcesPath?: string }
|
||||
).resourcesPath,
|
||||
appPath: app.getAppPath(),
|
||||
});
|
||||
|
||||
const workerURL = pathToFileURL(bootstrap.workerPath);
|
||||
return new Worker(workerURL, {
|
||||
resourceLimits: {
|
||||
maxOldGenerationSizeMb: 4096,
|
||||
maxYoungGenerationSizeMb: 512,
|
||||
},
|
||||
workerData: {
|
||||
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,62 @@ describe('EpgWorkerService worker lifecycle', () => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps source credentials out of completion, reuse, cache and cleanup logs', async () => {
|
||||
const secretUrl =
|
||||
'https://secret-user:secret-password@example.com/private-key/guide.xml?custom=secret-query#secret-fragment';
|
||||
const first = service.fetchEpgFromUrl(secretUrl);
|
||||
const reused = service.fetchEpgFromUrl(secretUrl);
|
||||
mockWorkerInstances[0].emit('message', {
|
||||
type: 'EPG_COMPLETE',
|
||||
stats: { totalChannels: 1, totalPrograms: 2 },
|
||||
});
|
||||
await Promise.all([first, reused]);
|
||||
await service.fetchEpgFromUrl(secretUrl);
|
||||
const clear = service.clearEpgDataForSource(secretUrl);
|
||||
mockWorkerInstances[1].emit('message', { type: 'CLEAR_COMPLETE' });
|
||||
await clear;
|
||||
const output = JSON.stringify(consoleLogSpy.mock.calls);
|
||||
for (const secret of [
|
||||
'secret-user',
|
||||
'secret-password',
|
||||
'private-key',
|
||||
'secret-query',
|
||||
'secret-fragment',
|
||||
]) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain('EPG parsing complete');
|
||||
});
|
||||
|
||||
it('redacts worker errors without changing the rejected error', async () => {
|
||||
const secretUrl =
|
||||
'https://user:password@example.com/path-token/guide.xml?custom=query-token';
|
||||
const error = Object.assign(new Error(`Request failed: ${secretUrl}`), {
|
||||
config: {
|
||||
url: secretUrl,
|
||||
headers: { Authorization: 'Bearer header-secret' },
|
||||
},
|
||||
cause: new Error(
|
||||
'Redirect failed: https://example.org/redirect-token?custom=redirect-query'
|
||||
),
|
||||
});
|
||||
const pending = service.fetchEpgFromUrl(secretUrl);
|
||||
mockWorkerInstances[0].emit('error', error);
|
||||
await expect(pending).rejects.toBe(error);
|
||||
const output = JSON.stringify(consoleErrorSpy.mock.calls);
|
||||
for (const secret of [
|
||||
'password',
|
||||
'path-token',
|
||||
'query-token',
|
||||
'header-secret',
|
||||
'redirect-token',
|
||||
'redirect-query',
|
||||
]) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain('Request failed');
|
||||
});
|
||||
|
||||
describe('worker lifecycle', () => {
|
||||
it('spawns the worker with bootstrap paths and drives the FETCH_EPG flow', async () => {
|
||||
const options = { manuallyTrustedHosts: ['example.com'] } as any;
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import {
|
||||
epgSourceGeneration,
|
||||
requestEpgSource,
|
||||
retireEpgSource,
|
||||
} from './epg-source-generation';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { EpgWorkerRuntime } from './epg-worker-runtime';
|
||||
import { runEpgFetch } from './epg-fetch-operation';
|
||||
import { Worker } from 'worker_threads';
|
||||
import {
|
||||
ElectronBridgeSecurityErrorCode,
|
||||
ElectronBridgeTrustOptions,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
|
||||
|
||||
export type EpgProgressStatus = 'queued' | 'loading' | 'complete' | 'error';
|
||||
export type EpgProgressStatus =
|
||||
'queued' | 'loading' | 'complete' | 'error' | 'cancelled';
|
||||
|
||||
export interface EpgProgressStats {
|
||||
totalChannels: number;
|
||||
totalPrograms: number;
|
||||
}
|
||||
|
||||
interface EpgWorkerMessage {
|
||||
type: string;
|
||||
error?: string;
|
||||
errorCode?: ElectronBridgeSecurityErrorCode;
|
||||
errorHost?: string;
|
||||
url?: string;
|
||||
stats?: EpgProgressStats;
|
||||
}
|
||||
|
||||
interface ClearWorkerMessage {
|
||||
type: 'CLEAR_EPG' | 'CLEAR_EPG_SOURCE';
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export class EpgWorkerService {
|
||||
private readonly fetchedUrls = new Set<string>();
|
||||
private readonly workers = new Map<string, Worker>();
|
||||
private readonly inFlightFetches = new Map<string, Promise<void>>();
|
||||
private readonly inFlightSourceClears = new Map<string, Promise<void>>();
|
||||
|
||||
private readonly runtime: EpgWorkerRuntime;
|
||||
|
||||
constructor(
|
||||
private readonly loggerLabel = '[EPG Events]',
|
||||
private readonly fetchTimeoutMs = 5 * 60 * 1000
|
||||
) {}
|
||||
) {
|
||||
this.runtime = new EpgWorkerRuntime(loggerLabel, fetchTimeoutMs);
|
||||
}
|
||||
|
||||
hasFetchedUrl(url: string): boolean {
|
||||
return this.fetchedUrls.has(url);
|
||||
@@ -58,12 +55,14 @@ export class EpgWorkerService {
|
||||
error?: string,
|
||||
queuePosition?: number,
|
||||
errorCode?: ElectronBridgeSecurityErrorCode,
|
||||
errorHost?: string
|
||||
errorHost?: string,
|
||||
generation = epgSourceGeneration(url)
|
||||
): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
windows.forEach((win) => {
|
||||
win.webContents.send('EPG_PROGRESS_UPDATE', {
|
||||
url,
|
||||
generation,
|
||||
status,
|
||||
stats,
|
||||
error,
|
||||
@@ -78,6 +77,26 @@ export class EpgWorkerService {
|
||||
url: string,
|
||||
options: ElectronBridgeTrustOptions = {}
|
||||
): Promise<void> {
|
||||
url = url.trim();
|
||||
const generation = requestEpgSource(url);
|
||||
const clear = this.inFlightSourceClears.get(url);
|
||||
if (clear) {
|
||||
await clear.catch(() => undefined);
|
||||
if (generation !== epgSourceGeneration(url)) {
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'cancelled',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
generation
|
||||
);
|
||||
return;
|
||||
}
|
||||
return this.fetchEpgFromUrl(url, options);
|
||||
}
|
||||
// A second request for an URL that is already being fetched must not
|
||||
// spawn a competing worker: both would parse and write the same EPG
|
||||
// data, and the late one would overwrite the early one's entry in
|
||||
@@ -87,17 +106,14 @@ export class EpgWorkerService {
|
||||
// must keep awaiting that termination window.
|
||||
const inFlight = this.inFlightFetches.get(url);
|
||||
if (inFlight) {
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
`Reusing in-flight EPG fetch: ${url}`
|
||||
);
|
||||
epgLogger.log(this.loggerLabel, 'Reusing in-flight EPG fetch');
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
if (this.fetchedUrls.has(url)) {
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
this.loggerLabel,
|
||||
`Skipping already fetched URL: ${url}`
|
||||
'Skipping already fetched EPG source'
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -113,230 +129,18 @@ export class EpgWorkerService {
|
||||
url: string,
|
||||
options: ElectronBridgeTrustOptions
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = this.createEpgWorker();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Failed to create worker:',
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.workers.set(url, worker);
|
||||
|
||||
// Guards against double-settling and keeps the outer loop moving
|
||||
// when the worker dies or hangs without sending EPG_COMPLETE/EPG_ERROR.
|
||||
let settled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
let lastProgressStats: EpgProgressStats = {
|
||||
totalChannels: 0,
|
||||
totalPrograms: 0,
|
||||
};
|
||||
|
||||
const clearFetchTimeout = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearFetchTimeout();
|
||||
fn();
|
||||
};
|
||||
|
||||
const scheduleFetchTimeout = () => {
|
||||
clearFetchTimeout();
|
||||
timeoutId = setTimeout(() => {
|
||||
handleFetchTimeout();
|
||||
}, this.fetchTimeoutMs);
|
||||
};
|
||||
|
||||
const hasProgressMoved = (stats: EpgProgressStats): boolean =>
|
||||
stats.totalChannels > lastProgressStats.totalChannels ||
|
||||
stats.totalPrograms > lastProgressStats.totalPrograms;
|
||||
|
||||
const recordProgress = (stats: EpgProgressStats): void => {
|
||||
lastProgressStats = {
|
||||
totalChannels: Math.max(
|
||||
lastProgressStats.totalChannels,
|
||||
stats.totalChannels
|
||||
),
|
||||
totalPrograms: Math.max(
|
||||
lastProgressStats.totalPrograms,
|
||||
stats.totalPrograms
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const handleFetchTimeout = () => {
|
||||
const errorMessage = `EPG fetch timed out after ${
|
||||
this.fetchTimeoutMs / 1000
|
||||
}s without progress`;
|
||||
console.error(this.loggerLabel, `${errorMessage}: ${url}`);
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
errorMessage
|
||||
);
|
||||
this.workers.delete(url);
|
||||
// Settle only after the worker thread is really gone: a
|
||||
// terminated-but-still-running worker can keep holding the
|
||||
// SQLite lock and block the next EPG fetch.
|
||||
settle(() => {
|
||||
void this.terminateWorker(worker, 'timed out fetch').then(
|
||||
() => reject(new Error(errorMessage))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
scheduleFetchTimeout();
|
||||
|
||||
worker.on('message', async (message: EpgWorkerMessage) => {
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'READY':
|
||||
scheduleFetchTimeout();
|
||||
this.sendProgressToRenderer(url, 'loading', {
|
||||
totalChannels: 0,
|
||||
totalPrograms: 0,
|
||||
});
|
||||
worker.postMessage({
|
||||
type: 'FETCH_EPG',
|
||||
url,
|
||||
options,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'EPG_PROGRESS':
|
||||
if (message.stats) {
|
||||
if (hasProgressMoved(message.stats)) {
|
||||
recordProgress(message.stats);
|
||||
scheduleFetchTimeout();
|
||||
}
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'loading',
|
||||
message.stats
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EPG_COMPLETE':
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
`EPG parsing complete for ${url}:`,
|
||||
message.stats
|
||||
);
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'complete',
|
||||
message.stats
|
||||
);
|
||||
this.fetchedUrls.add(url);
|
||||
this.workers.delete(url);
|
||||
settle(() => {
|
||||
void this.terminateWorker(
|
||||
worker,
|
||||
'completed fetch'
|
||||
).then(() => resolve());
|
||||
});
|
||||
break;
|
||||
|
||||
case 'EPG_ERROR':
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Worker error:',
|
||||
message.error
|
||||
);
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
message.error,
|
||||
undefined,
|
||||
message.errorCode,
|
||||
message.errorHost
|
||||
);
|
||||
this.workers.delete(url);
|
||||
settle(() => {
|
||||
void this.terminateWorker(
|
||||
worker,
|
||||
'failed fetch'
|
||||
).then(() =>
|
||||
reject(
|
||||
new Error(
|
||||
message.error || 'Unknown error'
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Error handling message:',
|
||||
err
|
||||
);
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
this.workers.delete(url);
|
||||
settle(() => {
|
||||
void this.terminateWorker(
|
||||
worker,
|
||||
'failed message handling'
|
||||
).then(() => reject(err));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
worker.on('error', (error) => {
|
||||
console.error(this.loggerLabel, 'Worker error event:', error);
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
error.message
|
||||
);
|
||||
this.workers.delete(url);
|
||||
settle(() => {
|
||||
void this.terminateWorker(worker, 'errored fetch').then(
|
||||
() => reject(error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return;
|
||||
const errorMessage = `Worker exited unexpectedly (code ${code})`;
|
||||
console.error(this.loggerLabel, `${errorMessage}: ${url}`);
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
errorMessage
|
||||
);
|
||||
this.workers.delete(url);
|
||||
settle(() => reject(new Error(errorMessage)));
|
||||
});
|
||||
return runEpgFetch(url, options, {
|
||||
runtime: this.runtime,
|
||||
workers: this.workers,
|
||||
fetchedUrls: this.fetchedUrls,
|
||||
loggerLabel: this.loggerLabel,
|
||||
fetchTimeoutMs: this.fetchTimeoutMs,
|
||||
sendProgressToRenderer: this.sendProgressToRenderer.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
async clearEpgData(): Promise<void> {
|
||||
return this.runClearWorker({
|
||||
return this.runtime.runClearWorker({
|
||||
timeoutLabel: 'EPG clear',
|
||||
exitLabel: 'Clear worker',
|
||||
readyMessage: { type: 'CLEAR_EPG' },
|
||||
@@ -344,21 +148,21 @@ export class EpgWorkerService {
|
||||
failedWorkerLabel: 'failed clear',
|
||||
erroredWorkerLabel: 'errored clear',
|
||||
onComplete: async (worker) => {
|
||||
console.log(this.loggerLabel, 'EPG data cleared via worker');
|
||||
epgLogger.log(this.loggerLabel, 'EPG data cleared via worker');
|
||||
this.fetchedUrls.clear();
|
||||
// Resolve only after every interrupted fetch worker has exited
|
||||
// too — they may still hold the SQLite lock the caller expects
|
||||
// to be free.
|
||||
const terminations = [...this.workers.values()].map(
|
||||
(runningWorker) =>
|
||||
this.terminateWorker(
|
||||
this.runtime.terminateWorker(
|
||||
runningWorker,
|
||||
'fetch during clear'
|
||||
)
|
||||
);
|
||||
this.workers.clear();
|
||||
terminations.push(
|
||||
this.terminateWorker(worker, 'completed clear')
|
||||
this.runtime.terminateWorker(worker, 'completed clear')
|
||||
);
|
||||
await Promise.all(terminations);
|
||||
},
|
||||
@@ -371,13 +175,34 @@ export class EpgWorkerService {
|
||||
return;
|
||||
}
|
||||
|
||||
retireEpgSource(normalizedSourceUrl);
|
||||
this.fetchedUrls.delete(normalizedSourceUrl);
|
||||
const previous = this.inFlightSourceClears.get(normalizedSourceUrl);
|
||||
const operation = previous
|
||||
? previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.startSourceClear(normalizedSourceUrl))
|
||||
: this.startSourceClear(normalizedSourceUrl);
|
||||
const clear = operation.finally(() => {
|
||||
if (this.inFlightSourceClears.get(normalizedSourceUrl) === clear)
|
||||
this.inFlightSourceClears.delete(normalizedSourceUrl);
|
||||
});
|
||||
this.inFlightSourceClears.set(normalizedSourceUrl, clear);
|
||||
return clear;
|
||||
}
|
||||
|
||||
private async startSourceClear(normalizedSourceUrl: string): Promise<void> {
|
||||
const inFlight = this.inFlightFetches.get(normalizedSourceUrl);
|
||||
const runningWorker = this.workers.get(normalizedSourceUrl);
|
||||
if (runningWorker) {
|
||||
this.workers.delete(normalizedSourceUrl);
|
||||
await this.terminateWorker(runningWorker, 'source clear');
|
||||
await this.runtime.terminateWorker(runningWorker, 'source clear');
|
||||
}
|
||||
// Error/timeout handlers may already have removed the worker from the
|
||||
// lookup, but its fetch promise still owns asynchronous termination.
|
||||
if (inFlight) await inFlight.catch(() => undefined);
|
||||
|
||||
return this.runClearWorker({
|
||||
return this.runtime.runClearWorker({
|
||||
timeoutLabel: 'EPG source clear',
|
||||
exitLabel: 'Source clear worker',
|
||||
readyMessage: {
|
||||
@@ -388,153 +213,15 @@ export class EpgWorkerService {
|
||||
failedWorkerLabel: 'failed source clear',
|
||||
erroredWorkerLabel: 'errored source clear',
|
||||
onComplete: async (worker) => {
|
||||
console.log(
|
||||
epgLogger.log(
|
||||
this.loggerLabel,
|
||||
`EPG data cleared for source via worker: ${normalizedSourceUrl}`
|
||||
'EPG data cleared for source via worker'
|
||||
);
|
||||
this.fetchedUrls.delete(normalizedSourceUrl);
|
||||
await this.terminateWorker(worker, 'completed source clear');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private runClearWorker(options: {
|
||||
timeoutLabel: string;
|
||||
exitLabel: string;
|
||||
readyMessage: ClearWorkerMessage;
|
||||
completeWorkerLabel: string;
|
||||
failedWorkerLabel: string;
|
||||
erroredWorkerLabel: string;
|
||||
onComplete: (worker: Worker) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = this.createEpgWorker();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Failed to create worker for clear:',
|
||||
error
|
||||
await this.runtime.terminateWorker(
|
||||
worker,
|
||||
'completed source clear'
|
||||
);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
fn();
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
const errorMessage = `${options.timeoutLabel} timed out after ${
|
||||
this.fetchTimeoutMs / 1000
|
||||
}s`;
|
||||
console.error(this.loggerLabel, errorMessage);
|
||||
settle(() => {
|
||||
void this.terminateWorker(
|
||||
worker,
|
||||
`timed out ${options.timeoutLabel}`
|
||||
).then(() => reject(new Error(errorMessage)));
|
||||
});
|
||||
}, this.fetchTimeoutMs);
|
||||
|
||||
worker.on(
|
||||
'message',
|
||||
(message: { type: string; error?: string }) => {
|
||||
if (message.type === 'READY') {
|
||||
worker.postMessage(options.readyMessage);
|
||||
} else if (message.type === 'CLEAR_COMPLETE') {
|
||||
settle(() => {
|
||||
void options
|
||||
.onComplete(worker)
|
||||
.then(() => resolve(), reject);
|
||||
});
|
||||
} else if (message.type === 'EPG_ERROR') {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Worker clear error:',
|
||||
message.error
|
||||
);
|
||||
settle(() => {
|
||||
void this.terminateWorker(
|
||||
worker,
|
||||
options.failedWorkerLabel
|
||||
).then(() =>
|
||||
reject(
|
||||
new Error(message.error || 'Clear failed')
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
worker.on('error', (error) => {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Worker error during clear:',
|
||||
error
|
||||
);
|
||||
settle(() => {
|
||||
void this.terminateWorker(
|
||||
worker,
|
||||
options.erroredWorkerLabel
|
||||
).then(() => reject(error));
|
||||
});
|
||||
});
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return;
|
||||
const errorMessage = `${options.exitLabel} exited unexpectedly (code ${code})`;
|
||||
console.error(this.loggerLabel, errorMessage);
|
||||
settle(() => reject(new Error(errorMessage)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaits worker shutdown so callers can sequence work (e.g. the next DB
|
||||
* access) after the thread has really exited. Termination failures are
|
||||
* logged and swallowed — there is nothing actionable left to do.
|
||||
*/
|
||||
private async terminateWorker(
|
||||
worker: Worker,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await worker.terminate();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
`Failed to terminate ${context} worker:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createEpgWorker(): Worker {
|
||||
const bootstrap = resolveWorkerRuntimeBootstrap({
|
||||
isPackaged: app.isPackaged,
|
||||
workerFilename: 'epg-parser.worker.js',
|
||||
developmentWorkerDir: path.join(__dirname, 'workers'),
|
||||
resourcesPath: (
|
||||
process as NodeJS.Process & { resourcesPath?: string }
|
||||
).resourcesPath,
|
||||
appPath: app.getAppPath(),
|
||||
});
|
||||
|
||||
const workerURL = pathToFileURL(bootstrap.workerPath);
|
||||
return new Worker(workerURL, {
|
||||
resourceLimits: {
|
||||
maxOldGenerationSizeMb: 4096,
|
||||
maxYoungGenerationSizeMb: 512,
|
||||
},
|
||||
workerData: {
|
||||
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +78,37 @@ describe('EpgEvents', () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
it.each(['missing', 'stale', 'fresh'])(
|
||||
'uses source-owned freshness for %s metadata',
|
||||
async (state) => {
|
||||
const { epgChannelSources } = await import('../database/schema');
|
||||
const { checkEpgFreshness } = await import('./epg-fetch.service');
|
||||
const fresh = [{ updatedAt: new Date().toISOString() }];
|
||||
const snapshots =
|
||||
state === 'missing'
|
||||
? []
|
||||
: state === 'fresh'
|
||||
? fresh
|
||||
: [{ updatedAt: '2000-01-01T00:00:00Z' }];
|
||||
getDatabase.mockResolvedValue({
|
||||
select: () => ({
|
||||
from: (table: unknown) => ({
|
||||
where: () => ({
|
||||
limit: async () =>
|
||||
table === epgChannelSources ? snapshots : fresh,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const result = await checkEpgFreshness(['shared-source'], 12);
|
||||
expect(result).toEqual(
|
||||
state === 'fresh'
|
||||
? { freshUrls: ['shared-source'], staleUrls: [] }
|
||||
: { freshUrls: [], staleUrls: ['shared-source'] }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('uses the shared worker bootstrap and passes native module search paths to the EPG worker', async () => {
|
||||
const fetchPromise = (EpgEvents as unknown as Record<string, any>)[
|
||||
'fetchEpgFromUrl'
|
||||
@@ -287,6 +318,120 @@ describe('EpgEvents', () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['exit', 1],
|
||||
['error', new Error('terminated worker')],
|
||||
] as const)(
|
||||
'cancels a retired source on %s without reviving it from late READY or COMPLETE messages',
|
||||
async (event, payload) => {
|
||||
const service = new EpgWorkerService('[Test EPG]', 1000);
|
||||
const url = 'https://removed.example/guide.xml';
|
||||
const progress = jest.spyOn(service, 'sendProgressToRenderer');
|
||||
const fetch = service.fetchEpgFromUrl(url).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
const worker = mockWorkerInstances[0];
|
||||
let finishTermination!: () => void;
|
||||
worker.terminate.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finishTermination = resolve;
|
||||
})
|
||||
);
|
||||
const clear = service.clearEpgDataForSource(url);
|
||||
worker.emit('message', { type: 'READY' });
|
||||
worker.emit('message', { type: 'EPG_COMPLETE' });
|
||||
expect(worker.postMessage).not.toHaveBeenCalled();
|
||||
expect(service.hasFetchedUrl(url)).toBe(false);
|
||||
expect(mockWorkerInstances).toHaveLength(1);
|
||||
worker.emit(event, payload);
|
||||
finishTermination();
|
||||
await flushPromises();
|
||||
const clearWorker = mockWorkerInstances[1];
|
||||
clearWorker.emit('message', { type: 'READY' });
|
||||
clearWorker.emit('message', { type: 'CLEAR_COMPLETE' });
|
||||
await clear;
|
||||
expect(await fetch).toBe(true);
|
||||
expect(progress.mock.calls.map((call) => call[1])).toEqual([
|
||||
'cancelled',
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not start a queued source removed while an earlier source imports', async () => {
|
||||
getDatabase.mockRejectedValue(new Error('force stale for test'));
|
||||
const { handleFetchEpg } = await import('./epg-fetch.service');
|
||||
const { retireEpgSource } = await import('./epg-source-generation');
|
||||
const { epgWorkerService } = await import('./epg-worker.service');
|
||||
const progress = jest.spyOn(epgWorkerService, 'sendProgressToRenderer');
|
||||
let finishFirst!: () => void;
|
||||
const fetch = jest
|
||||
.spyOn(epgWorkerService, 'fetchEpgFromUrl')
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
})
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
const request = handleFetchEpg([
|
||||
'https://first.example/guide.xml',
|
||||
'https://queued.example/guide.xml',
|
||||
]);
|
||||
await flushPromises();
|
||||
retireEpgSource('https://queued.example/guide.xml');
|
||||
finishFirst();
|
||||
await request;
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(progress).toHaveBeenCalledWith(
|
||||
'https://queued.example/guide.xml',
|
||||
'cancelled',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.any(Number)
|
||||
);
|
||||
progress.mockRestore();
|
||||
fetch.mockRestore();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'awaits a failing worker termination before source cleanup (already retired: %s)',
|
||||
async (alreadyRetired) => {
|
||||
const service = new EpgWorkerService('[Test EPG]', 1000);
|
||||
const url = `https://terminating-${alreadyRetired}.example/guide.xml`;
|
||||
const fetch = service.fetchEpgFromUrl(url).catch(() => undefined);
|
||||
const worker = mockWorkerInstances[0];
|
||||
let finishTermination!: () => void;
|
||||
worker.terminate.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finishTermination = resolve;
|
||||
})
|
||||
);
|
||||
if (alreadyRetired) {
|
||||
const { retireEpgSource } =
|
||||
await import('./epg-source-generation');
|
||||
retireEpgSource(url);
|
||||
}
|
||||
worker.emit(
|
||||
'error',
|
||||
new Error('worker failed while another source clears')
|
||||
);
|
||||
const clear = service.clearEpgDataForSource(url);
|
||||
const workersBeforeTermination = mockWorkerInstances.length;
|
||||
finishTermination();
|
||||
await fetch;
|
||||
await flushPromises();
|
||||
const clearWorker = mockWorkerInstances[1];
|
||||
clearWorker.emit('message', { type: 'READY' });
|
||||
clearWorker.emit('message', { type: 'CLEAR_COMPLETE' });
|
||||
await clear;
|
||||
expect(workersBeforeTermination).toBe(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('clears one EPG source through a worker and allows it to be fetched again', async () => {
|
||||
const workerService = new EpgWorkerService('[Test EPG]', 1000);
|
||||
const sourceUrl = 'https://playlist.example.com/guide.xml';
|
||||
@@ -336,6 +481,66 @@ describe('EpgEvents', () => {
|
||||
expect(terminated).toBe(true);
|
||||
});
|
||||
|
||||
it('waits for source cleanup before starting a replacement import of the same normalized URL', async () => {
|
||||
const service = new EpgWorkerService('[Test EPG]', 1000);
|
||||
const url = 'https://replacement.example/guide.xml';
|
||||
const clear = service.clearEpgDataForSource(url);
|
||||
const clearWorker = mockWorkerInstances[0];
|
||||
const replacement = service.fetchEpgFromUrl(` ${url} `);
|
||||
const workersBeforeCleanup = mockWorkerInstances.length;
|
||||
clearWorker.emit('message', { type: 'READY' });
|
||||
clearWorker.emit('message', { type: 'CLEAR_COMPLETE' });
|
||||
await clear;
|
||||
await flushPromises();
|
||||
const fetchWorker = mockWorkerInstances[1];
|
||||
fetchWorker.emit('message', { type: 'READY' });
|
||||
fetchWorker.emit('message', { type: 'EPG_COMPLETE' });
|
||||
await replacement;
|
||||
expect(workersBeforeCleanup).toBe(1);
|
||||
expect(service.hasFetchedUrl(url)).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes repeated clears and retires a replacement waiting for the earlier clear', async () => {
|
||||
const service = new EpgWorkerService('[Test EPG]', 1000);
|
||||
const url = 'https://twice-removed.example/guide.xml';
|
||||
const firstClear = service.clearEpgDataForSource(url);
|
||||
const waitingFetch = service.fetchEpgFromUrl(url);
|
||||
const secondClear = service.clearEpgDataForSource(url);
|
||||
expect(mockWorkerInstances).toHaveLength(1);
|
||||
mockWorkerInstances[0].emit('message', { type: 'CLEAR_COMPLETE' });
|
||||
await firstClear;
|
||||
await waitingFetch;
|
||||
await flushPromises();
|
||||
expect(mockWorkerInstances).toHaveLength(2);
|
||||
mockWorkerInstances[1].emit('message', { type: 'READY' });
|
||||
expect(mockWorkerInstances[1].postMessage).toHaveBeenCalledWith({
|
||||
type: 'CLEAR_EPG_SOURCE',
|
||||
sourceUrl: url,
|
||||
});
|
||||
mockWorkerInstances[1].emit('message', { type: 'CLEAR_COMPLETE' });
|
||||
await secondClear;
|
||||
expect(service.hasFetchedUrl(url)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a replacement import after a failed source cleanup has terminated', async () => {
|
||||
const service = new EpgWorkerService('[Test EPG]', 1000);
|
||||
const url = 'https://retry-clear.example/guide.xml';
|
||||
const clear = service.clearEpgDataForSource(url);
|
||||
const outcome = clear.catch((error: Error) => error.message);
|
||||
const replacement = service.fetchEpgFromUrl(url);
|
||||
mockWorkerInstances[0].emit('message', {
|
||||
type: 'EPG_ERROR',
|
||||
error: 'clear failed',
|
||||
});
|
||||
expect(await outcome).toBe('clear failed');
|
||||
await flushPromises();
|
||||
expect(mockWorkerInstances[0].terminate).toHaveBeenCalled();
|
||||
mockWorkerInstances[1].emit('message', { type: 'READY' });
|
||||
mockWorkerInstances[1].emit('message', { type: 'EPG_COMPLETE' });
|
||||
await replacement;
|
||||
expect(service.hasFetchedUrl(url)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an active EPG fetch alive when worker progress keeps moving', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -381,7 +586,10 @@ describe('EpgEvents', () => {
|
||||
* inserts extra queries that the original test didn't anticipate.
|
||||
*/
|
||||
function queryChain<T>(data: T) {
|
||||
const chain: Record<string, jest.Mock> = {} as Record<string, jest.Mock>;
|
||||
const chain: Record<string, jest.Mock> = {} as Record<
|
||||
string,
|
||||
jest.Mock
|
||||
>;
|
||||
chain.where = jest.fn().mockReturnValue(chain);
|
||||
chain.innerJoin = jest.fn().mockReturnValue(chain);
|
||||
chain.groupBy = jest.fn().mockReturnValue(chain);
|
||||
@@ -393,25 +601,30 @@ describe('EpgEvents', () => {
|
||||
// getMapping queries (must come first — they return empty)
|
||||
const from = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings
|
||||
.mockReturnValueOnce(queryChain([])) // 2. getMapping: content table
|
||||
.mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings
|
||||
.mockReturnValueOnce(queryChain([])) // 2. getMapping: content table
|
||||
// Test expectations below
|
||||
.mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty)
|
||||
.mockReturnValueOnce(queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }])) // 4. selectChannelById → channel found
|
||||
.mockReturnValueOnce(queryChain([ // 5. selectChannelPrograms (BBC.ONE.UK → programs)
|
||||
{
|
||||
id: 1,
|
||||
channelId: 'BBC.ONE.UK',
|
||||
start: '2026-04-14T10:00:00Z',
|
||||
stop: '2026-04-14T11:00:00Z',
|
||||
title: 'News',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
},
|
||||
]));
|
||||
.mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty)
|
||||
.mockReturnValueOnce(
|
||||
queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }])
|
||||
) // 4. selectChannelById → channel found
|
||||
.mockReturnValueOnce(
|
||||
queryChain([
|
||||
// 5. selectChannelPrograms (BBC.ONE.UK → programs)
|
||||
{
|
||||
id: 1,
|
||||
channelId: 'BBC.ONE.UK',
|
||||
start: '2026-04-14T10:00:00Z',
|
||||
stop: '2026-04-14T11:00:00Z',
|
||||
title: 'News',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
select.mockImplementation(() => ({ from }));
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { reconcileEpgSources } from './epg-source-settings.service';
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
ElectronBridgeCurrentProgramsOptions,
|
||||
ElectronBridgeTrustOptions,
|
||||
EpgChannelMetadata,
|
||||
EpgProgram,
|
||||
@@ -28,6 +30,14 @@ export default class EpgEvents {
|
||||
* Bootstrap EPG events
|
||||
*/
|
||||
static bootstrapEpgEvents(): Electron.IpcMain {
|
||||
ipcMain.handle(
|
||||
'EPG_RECONCILE_SOURCES',
|
||||
async (_event, args: { urls: string[] }) => {
|
||||
await reconcileEpgSources(args.urls);
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'FETCH_EPG',
|
||||
async (
|
||||
@@ -57,7 +67,7 @@ export default class EpgEvents {
|
||||
_event,
|
||||
args: {
|
||||
channelIds: string[];
|
||||
options?: { sourceUrls?: string[] };
|
||||
options?: ElectronBridgeCurrentProgramsOptions;
|
||||
}
|
||||
) => {
|
||||
return this.handleGetCurrentProgramsBatch(
|
||||
@@ -178,10 +188,7 @@ export default class EpgEvents {
|
||||
|
||||
ipcMain.handle(
|
||||
'EPG_CHANNEL_SEARCH',
|
||||
async (
|
||||
_event,
|
||||
args: { searchTerm: string; limit?: number }
|
||||
) => {
|
||||
async (_event, args: { searchTerm: string; limit?: number }) => {
|
||||
return handleSearchEpgChannels(args.searchTerm, args.limit);
|
||||
}
|
||||
);
|
||||
@@ -219,7 +226,7 @@ export default class EpgEvents {
|
||||
|
||||
private static async handleGetCurrentProgramsBatch(
|
||||
channelIds: string[],
|
||||
options?: { sourceUrls?: string[] }
|
||||
options?: ElectronBridgeCurrentProgramsOptions
|
||||
): Promise<Record<string, EpgProgram | null>> {
|
||||
return queryByResolvedChannelIds(channelIds, (resolvedIds) =>
|
||||
epgQueryService.getCurrentProgramsBatch(resolvedIds, options)
|
||||
|
||||
@@ -217,6 +217,20 @@ describe('autoUpdatePlaylists', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('downloads startup refreshes with each playlist’s own User-Agent', async () => {
|
||||
mockFetchPlaylistFromUrl.mockResolvedValue(createPlaylist());
|
||||
const playlist = createPlaylist({
|
||||
url: 'https://example.test/list.m3u',
|
||||
userAgent: 'IPTVnator-Test/1.0',
|
||||
});
|
||||
await autoUpdatePlaylists([playlist]);
|
||||
expect(mockFetchPlaylistFromUrl).toHaveBeenCalledWith(
|
||||
playlist.url,
|
||||
playlist.title,
|
||||
{ userAgent: playlist.userAgent }
|
||||
);
|
||||
});
|
||||
|
||||
it('skips playlists without a usable source and forwards trust options', async () => {
|
||||
mockFetchPlaylistFromUrl.mockResolvedValue(createPlaylist());
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ async function refreshPlaylist(
|
||||
playlistObject = await fetchPlaylistFromUrl(
|
||||
playlist.url,
|
||||
playlist.title,
|
||||
options
|
||||
{ ...options, userAgent: playlist.userAgent }
|
||||
);
|
||||
} else if (playlist.filePath) {
|
||||
console.log(
|
||||
@@ -91,7 +91,7 @@ export async function autoUpdatePlaylists(
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update playlist "${playlist.title}":`,
|
||||
error
|
||||
redactSensitiveData(error)
|
||||
);
|
||||
statuses[index] = 'failed';
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { M3uImportPerformanceCapture } from './playlist-import-performance'
|
||||
import { M3U_IMPORT_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface PlaylistFetchOptions {
|
||||
userAgent?: string;
|
||||
performanceCapture?: M3uImportPerformanceCapture | null;
|
||||
trustedInsecureTlsHosts?: readonly string[];
|
||||
}
|
||||
@@ -88,6 +89,7 @@ export async function fetchPlaylistFromUrl(
|
||||
options: PlaylistFetchOptions = {}
|
||||
): Promise<Playlist> {
|
||||
const performanceCapture = options.performanceCapture;
|
||||
const userAgent = options.userAgent?.trim() || undefined;
|
||||
const request = () =>
|
||||
requestWithValidatedRedirects<string>(
|
||||
url,
|
||||
@@ -96,6 +98,7 @@ export async function fetchPlaylistFromUrl(
|
||||
trustedInsecureTlsHosts: options.trustedInsecureTlsHosts,
|
||||
}),
|
||||
method: 'GET',
|
||||
...(userAgent ? { headers: { 'User-Agent': userAgent } } : {}),
|
||||
timeout: PLAYLIST_FETCH_TIMEOUT_MS,
|
||||
},
|
||||
{ allowPrivateNetworks: true }
|
||||
@@ -135,12 +138,16 @@ export async function fetchPlaylistFromUrl(
|
||||
? 'Imported from URL'
|
||||
: extractedName;
|
||||
|
||||
return createPlaylistObject(
|
||||
const playlist = createPlaylistObject(
|
||||
title ?? playlistName,
|
||||
parsedPlaylist,
|
||||
url,
|
||||
'URL'
|
||||
);
|
||||
if (userAgent) {
|
||||
playlist.userAgent = userAgent;
|
||||
}
|
||||
return playlist;
|
||||
};
|
||||
|
||||
return performanceCapture
|
||||
|
||||
@@ -220,6 +220,27 @@ describe('playlist IPC events', () => {
|
||||
expect(result).toEqual(playlist);
|
||||
});
|
||||
|
||||
it.each([' IPTVnator-Test/1.0 ', '', ' '])(
|
||||
'uses and persists the optional initial download User-Agent: %s',
|
||||
async (userAgent) => {
|
||||
mockAxiosGet.mockResolvedValue({ data: '#EXTM3U' });
|
||||
mockParse.mockReturnValue({ items: [] });
|
||||
mockCreatePlaylistObject.mockReturnValue(createPlaylist());
|
||||
|
||||
const result = await getHandler('fetch-playlist-by-url')(
|
||||
createIpcEvent(),
|
||||
'https://example.test/remote.m3u',
|
||||
undefined,
|
||||
{ userAgent }
|
||||
);
|
||||
|
||||
const expected = userAgent.trim() || undefined;
|
||||
const request = mockAxiosGet.mock.calls[0][0];
|
||||
expect(request.headers?.['User-Agent']).toBe(expected);
|
||||
expect((result as Playlist).userAgent).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it('publishes request-scoped URL import phases only through the opt-in internal channel', async () => {
|
||||
process.env[PERF_CAPTURE_ENV] = '1';
|
||||
const body = '#EXTM3U\n#EXTINF:-1,News\nhttps://stream.test/news';
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { app, dialog, ipcMain, WebContents } from 'electron';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { Worker } from 'worker_threads';
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE,
|
||||
PLAYLIST_REFRESH_EVENT,
|
||||
ElectronBridgeTrustOptions,
|
||||
ElectronBridgePlaylistFetchOptions,
|
||||
Playlist,
|
||||
PlaylistRefreshCancelledResult,
|
||||
PlaylistRefreshEvent,
|
||||
@@ -94,7 +96,7 @@ ipcMain.handle(
|
||||
event,
|
||||
url,
|
||||
title?: string,
|
||||
options?: ElectronBridgeTrustOptions
|
||||
options?: ElectronBridgePlaylistFetchOptions
|
||||
) => {
|
||||
try {
|
||||
const performanceCapture = createM3uImportPerformanceCapture({
|
||||
@@ -102,10 +104,14 @@ ipcMain.handle(
|
||||
});
|
||||
return await fetchPlaylistFromUrl(url, title, {
|
||||
performanceCapture,
|
||||
userAgent: options?.userAgent,
|
||||
trustedInsecureTlsHosts: options?.trustedInsecureTlsHosts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching playlist:', error);
|
||||
console.error(
|
||||
'Error fetching playlist:',
|
||||
redactSensitiveData(error)
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ type SettingsUpdatePayload = Omit<
|
||||
| 'embeddedMpvFrameCopy'
|
||||
| 'language'
|
||||
| 'mpvPlayerArguments'
|
||||
| 'startupWindowMode'
|
||||
| 'vlcPlayerArguments'
|
||||
> & {
|
||||
embeddedMpvFrameCopy?: boolean | number;
|
||||
language?: string;
|
||||
mpvPlayerArguments?: ExternalPlayerArgumentsSetting;
|
||||
startupWindowMode?: string;
|
||||
vlcPlayerArguments?: ExternalPlayerArgumentsSetting;
|
||||
};
|
||||
type SettingsUpdateHandler = (
|
||||
@@ -20,9 +22,13 @@ type SettingsUpdateHandler = (
|
||||
|
||||
const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
|
||||
const STORE_KEYS = {
|
||||
EMBEDDED_MPV_AUTO_RECONNECT: 'EMBEDDED_MPV_AUTO_RECONNECT',
|
||||
EMBEDDED_MPV_EXTRA_OPTIONS: 'EMBEDDED_MPV_EXTRA_OPTIONS',
|
||||
EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY',
|
||||
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
|
||||
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
|
||||
STARTUP_WINDOW_MODE: 'STARTUP_WINDOW_MODE',
|
||||
PORTAL_CONNECTIVITY_GUARD: 'PORTAL_CONNECTIVITY_GUARD',
|
||||
VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS',
|
||||
VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE',
|
||||
} as const;
|
||||
@@ -44,9 +50,13 @@ jest.mock('electron', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('../services/store.service', () => ({
|
||||
EMBEDDED_MPV_AUTO_RECONNECT: STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT,
|
||||
EMBEDDED_MPV_EXTRA_OPTIONS: STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS,
|
||||
EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY,
|
||||
MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS,
|
||||
MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE,
|
||||
STARTUP_WINDOW_MODE: STORE_KEYS.STARTUP_WINDOW_MODE,
|
||||
PORTAL_CONNECTIVITY_GUARD: STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
|
||||
VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS,
|
||||
VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE,
|
||||
store: {
|
||||
@@ -93,6 +103,37 @@ describe('SETTINGS_UPDATE', () => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('restores the persisted preference before the renderer starts', async () => {
|
||||
mockStoreGet.mockReturnValue(false);
|
||||
const settingsEvents = (await import('./settings.events')).default;
|
||||
settingsEvents.bootstrapSettingsEvents();
|
||||
const guard = await import('../util/host-connectivity-guard');
|
||||
expect(
|
||||
guard.beginGuardedHostRequest('https://portal.example')
|
||||
).toBeNull();
|
||||
settingsUpdateHandler({}, { portalConnectivityGuard: true });
|
||||
expect(
|
||||
guard.beginGuardedHostRequest('https://portal.example')
|
||||
).not.toBeNull();
|
||||
expect(mockStoreSet).toHaveBeenCalledWith(
|
||||
STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('applies an opt-out immediately and preserves it through unrelated saves', async () => {
|
||||
const guard = await import('../util/host-connectivity-guard');
|
||||
settingsUpdateHandler({}, { portalConnectivityGuard: false });
|
||||
settingsUpdateHandler({}, { showCaptions: true });
|
||||
expect(
|
||||
guard.beginGuardedHostRequest('https://portal.example')
|
||||
).toBeNull();
|
||||
expect(mockStoreSet).toHaveBeenCalledWith(
|
||||
STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes external-player arguments and preserves explicit false reuse settings', () => {
|
||||
settingsUpdateHandler(
|
||||
{},
|
||||
@@ -136,6 +177,48 @@ describe('SETTINGS_UPDATE', () => {
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mirrors the embedded MPV extra options in canonical form', () => {
|
||||
settingsUpdateHandler(
|
||||
{},
|
||||
{ embeddedMpvExtraOptions: ' --hwdec = auto \r\n\nvo=null\n' }
|
||||
);
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS, 'hwdec=auto\nvo=null'],
|
||||
]);
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mirrors the embedded MPV auto-reconnect toggle as a boolean', () => {
|
||||
settingsUpdateHandler({}, { embeddedMpvAutoReconnect: false });
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT, false],
|
||||
]);
|
||||
|
||||
mockStoreSet.mockClear();
|
||||
settingsUpdateHandler({}, { embeddedMpvAutoReconnect: true });
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT, true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('mirrors the startup window mode into the main-process store', () => {
|
||||
settingsUpdateHandler({}, { startupWindowMode: 'fullscreen' });
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.STARTUP_WINDOW_MODE, 'fullscreen'],
|
||||
]);
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes an unknown startup window mode to normal instead of storing junk', () => {
|
||||
settingsUpdateHandler({}, { startupWindowMode: 'kiosk' });
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.STARTUP_WINDOW_MODE, 'normal'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reconciles an enabled remote-control update with the stored port', () => {
|
||||
mockStoreGet.mockImplementation(
|
||||
(key: string, fallbackValue: unknown): unknown =>
|
||||
@@ -197,6 +280,26 @@ describe('SETTINGS_UPDATE', () => {
|
||||
expect(output).toContain('enabled');
|
||||
});
|
||||
|
||||
it('does not print credentials embedded in embedded MPV extra options', () => {
|
||||
const headerSecret = 'embedded-mpv-header-secret';
|
||||
|
||||
settingsUpdateHandler(
|
||||
{},
|
||||
{
|
||||
language: 'de',
|
||||
embeddedMpvExtraOptions: `cache-secs=5\nhttp-header-fields=X-Playback-Key: ${headerSecret}`,
|
||||
}
|
||||
);
|
||||
|
||||
const output = JSON.stringify(consoleLogSpy.mock.calls);
|
||||
expect(output).not.toContain(headerSecret);
|
||||
expect(output).toContain('language');
|
||||
expect(mockStoreSet).toHaveBeenCalledWith(
|
||||
STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS,
|
||||
`cache-secs=5\nhttp-header-fields=X-Playback-Key: ${headerSecret}`
|
||||
);
|
||||
});
|
||||
|
||||
it('does not print credentials embedded in external player arguments', () => {
|
||||
const authorizationSecret = 'player-authorization-secret';
|
||||
const cookieSecret = 'player-cookie-secret';
|
||||
|
||||
Loaded 100 of 626 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user