diff --git a/.changes/README.md b/.changes/README.md
new file mode 100644
index 000000000..e41282f03
--- /dev/null
+++ b/.changes/README.md
@@ -0,0 +1,120 @@
+# Release notes (`.changes/`)
+
+Every PR with a user-visible change drops one file here describing that change
+in plain language. At release time
+`tools/release/build-release-notes.mjs` turns the accumulated files into the
+GitHub release body, the `CHANGELOG.md` section, and a blog-post scaffold for
+the website — then deletes them.
+
+The point is to write the note **while the context is still fresh**, instead of
+reconstructing three months of work from commit titles at release time.
+
+## File
+
+Name it `-.md`, e.g. `.changes/playback-up-next-rail.md`.
+
+```markdown
+---
+type: feature
+area: playback
+issues: [1187]
+screenshot: up-next-rail
+---
+
+Series now show an "Up Next" rail beside the player on wide windows: the rest
+of the current season, watch progress, and click-to-play inline.
+```
+
+| Field | Required | Value |
+| ------------ | -------- | ----------------------------------------------------------- |
+| `type` | yes | `breaking`, `feature`, `fix`, `perf`, or `internal` |
+| `area` | yes | lowercase slug, same as the conventional-commit scope |
+| `issues` | no | issue numbers this closes — `[1187]` or `1187` |
+| `screenshot` | no | slug from `tools/release/screenshots.manifest.json` |
+
+There is **no version field**. The release version is chosen deliberately at
+release time, not derived from these files.
+
+You never write a PR number: the generator resolves it from the commit that
+added the file.
+
+## Writing the body
+
+One to three sentences, present tense, **written for a user, not a reviewer**.
+The body is capped at 400 characters — depth belongs in the blog post.
+
+- ❌ "Refactor `WebVideoControlsAdapter` to hoist volume state into the session"
+- ✅ "The player now remembers volume between episodes"
+
+- ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`"
+- ✅ "Series whose title carries a season marker no longer show the wrong season"
+
+`type: internal` is for changes with no user-visible effect that are still worth
+recording (dependency bumps with behaviour risk, packaging moves). They stay out
+of the release body and blog post, and land collapsed in `CHANGELOG.md`.
+
+## When a note is not needed
+
+Skip the note — and apply the `no-release-note` label — for test-only changes,
+docs, CI/workflow plumbing, and pure refactors with no behaviour change.
+
+## Commands
+
+```bash
+pnpm run release:notes:validate
+pnpm run release:notes:github
+pnpm run release:notes:changelog
+pnpm run release:notes:blog
+node tools/release/build-release-notes.mjs --consume
+```
+
+The release version comes from the root `package.json` — bump it first, then
+generate. `--version 0.24.0` overrides it to preview a release before the bump:
+
+```bash
+pnpm run release:notes:github --version 0.24.0
+```
+
+A bare `--` separator is accepted and ignored, so the npm habit of
+`pnpm run release:notes:github -- --version 0.24.0` works too: pnpm forwards
+that separator to the script rather than consuming it the way npm does.
+
+`--validate` and `--format github` only read and print. `--format changelog`
+and `--format blog` write their target file (rerunning `changelog` for the same
+version replaces that section rather than duplicating it). Only `--consume`
+deletes anything.
+
+The release sequence is: bump the version → `release:notes:changelog` →
+`release:notes:blog` → `--consume` → commit → tag → push. The tag build then
+extracts the new `CHANGELOG.md` section into the GitHub release body
+(`tools/release/extract-changelog-section.mjs`) and **fails the release** if
+the section is missing — a tag cut without the changelog step cannot silently
+ship PR-title-only notes.
+
+The website publishes **one post per minor version** (`v0-18` … `v0-22`), and
+release screenshots live under the matching `blog/v0-24/` directory. A patch
+release therefore edits the existing post rather than generating a new one, so
+`--format blog` refuses to overwrite unless you pass `--force`.
+
+## Screenshots
+
+`pnpm run release:screenshots` captures every manifest shot in dark and light
+against the built app plus the Xtream mock server — never a real account.
+The run is fail-closed: it proves the real `~/.iptvnator/databases` directory
+(including the SQLite WAL sidecars, checked after Electron exits) was not
+touched, launches the app with an allowlisted environment, records and blocks
+all non-localhost traffic, scans every frame for external resources and
+credential-shaped text, and asserts TMDB enrichment stays disabled. Frames are
+staged outside the repository and published only once every shot and every
+guard has passed.
+
+Adding a shot for a new feature = one entry in
+`tools/release/screenshots.manifest.json` (plus, if navigation is new, one
+named action in `tools/release/capture-navigation.ts`).
+
+```bash
+pnpm nx run electron-backend:build-e2e # once, before capturing
+pnpm run release:screenshots # all shots, both themes
+pnpm run release:screenshots -- --only dashboard --theme dark
+pnpm run release:screenshots -- --release v0-24
+```
diff --git a/.changes/backup-hidden-categories.md b/.changes/backup-hidden-categories.md
new file mode 100644
index 000000000..064f99137
--- /dev/null
+++ b/.changes/backup-hidden-categories.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: backup
+issues: [1017]
+---
+
+Backups carry hidden Xtream categories correctly. An export used to lose which
+categories you had hidden, and restoring such a backup then hid every category
+of that kind.
diff --git a/.changes/deps-npm-minor-patch-group.md b/.changes/deps-npm-minor-patch-group.md
new file mode 100644
index 000000000..1e2eb2a84
--- /dev/null
+++ b/.changes/deps-npm-minor-patch-group.md
@@ -0,0 +1,9 @@
+---
+type: internal
+area: deps
+---
+
+Updated 43 dependencies, including the HTTP client behind every playlist and
+portal request — that one closes seven advisories that affect the shipped app,
+among them a proxy-credential leak on redirects. Also refreshes the ArtPlayer,
+hls.js, Video.js and Shaka player engines. No behaviour change intended.
diff --git a/.changes/deps-transitive-cve-overrides.md b/.changes/deps-transitive-cve-overrides.md
new file mode 100644
index 000000000..8be3e46d5
--- /dev/null
+++ b/.changes/deps-transitive-cve-overrides.md
@@ -0,0 +1,8 @@
+---
+type: internal
+area: deps
+---
+
+Patched five vulnerable transitive dependencies that ship with the app —
+including the YAML parser `electron-updater` uses to read update manifests, and
+the HTTP form encoder behind portal requests. No behaviour change.
diff --git a/.changes/downloads-pause-resume.md b/.changes/downloads-pause-resume.md
new file mode 100644
index 000000000..2bde5dffb
--- /dev/null
+++ b/.changes/downloads-pause-resume.md
@@ -0,0 +1,9 @@
+---
+type: feature
+area: downloads
+---
+
+Downloads can be paused and picked up later. A paused transfer keeps what it
+already fetched and continues from that point instead of starting over, and
+downloads cut short by a crash or a closed app come back as paused rather than
+lost.
diff --git a/.changes/electron-remote-static-paths.md b/.changes/electron-remote-static-paths.md
new file mode 100644
index 000000000..70313de33
--- /dev/null
+++ b/.changes/electron-remote-static-paths.md
@@ -0,0 +1,7 @@
+---
+type: fix
+area: electron
+---
+
+The desktop remote-control server now blocks crafted static paths from escaping
+bundled web files on Windows.
diff --git a/.changes/embedded-mpv-frame-copy.md b/.changes/embedded-mpv-frame-copy.md
new file mode 100644
index 000000000..6a866ae2f
--- /dev/null
+++ b/.changes/embedded-mpv-frame-copy.md
@@ -0,0 +1,9 @@
+---
+type: feature
+area: embedded-mpv
+---
+
+Experimental Embedded MPV can draw video inside the app window instead of into a
+separate layer pinned on top of it, so menus, dialogs and the player controls
+stop being swallowed by the picture. Available on macOS (Apple Silicon), Windows
+and Linux x64; switching it on needs a restart.
diff --git a/.changes/epg-manual-channel-mapping.md b/.changes/epg-manual-channel-mapping.md
new file mode 100644
index 000000000..7cb429b15
--- /dev/null
+++ b/.changes/epg-manual-channel-mapping.md
@@ -0,0 +1,9 @@
+---
+type: feature
+area: epg
+---
+
+Channels whose guide never matched can be mapped by hand: right-click a channel
+in any list and pick "Map EPG channel" to attach it to a channel from your
+uploaded XMLTV guide. The mapping is remembered and used everywhere the guide is
+read — M3U playlists, Xtream and Stalker portals alike.
diff --git a/.changes/epg-mapping-lookup-fail-soft.md b/.changes/epg-mapping-lookup-fail-soft.md
new file mode 100644
index 000000000..dc35aebbe
--- /dev/null
+++ b/.changes/epg-mapping-lookup-fail-soft.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: epg
+---
+
+A database error while reading or saving a manual EPG channel mapping no longer
+surfaces as a failed request. Looking up, saving, deleting, and searching
+mappings now fall back quietly, so a transient storage hiccup can no longer take
+down the EPG panel or the "Map EPG channel" dialog.
diff --git a/.changes/favorites-lost-updates.md b/.changes/favorites-lost-updates.md
new file mode 100644
index 000000000..a54c32de3
--- /dev/null
+++ b/.changes/favorites-lost-updates.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: favorites
+issues: [1137]
+---
+
+Favorites stop losing changes. The custom drag-and-drop order of an Xtream
+playlist's own favorites is saved again, and two favorites or history entries
+added at almost the same moment no longer quietly overwrite each other.
diff --git a/.changes/i18n-hungarian.md b/.changes/i18n-hungarian.md
new file mode 100644
index 000000000..691f77900
--- /dev/null
+++ b/.changes/i18n-hungarian.md
@@ -0,0 +1,7 @@
+---
+type: feature
+area: i18n
+issues: [1192]
+---
+
+IPTVnator speaks Hungarian, its 19th language — contributed by @htibcsike.
diff --git a/.changes/m3u-dash-clearkey.md b/.changes/m3u-dash-clearkey.md
new file mode 100644
index 000000000..c2853da6b
--- /dev/null
+++ b/.changes/m3u-dash-clearkey.md
@@ -0,0 +1,10 @@
+---
+type: feature
+area: m3u
+issues: [86, 614, 656, 733, 752]
+---
+
+MPEG-DASH channels play in the built-in player, ClearKey-encrypted ones
+included — the keys are read from the playlist's #KODIPROP lines, whether they
+sit above or below the channel entry. Streams locked with Widevine or PlayReady
+still cannot be played, but they now say so instead of failing silently.
diff --git a/.changes/m3u-favorites-hydration.md b/.changes/m3u-favorites-hydration.md
new file mode 100644
index 000000000..97d9deb64
--- /dev/null
+++ b/.changes/m3u-favorites-hydration.md
@@ -0,0 +1,7 @@
+---
+type: perf
+area: m3u
+---
+
+Importing large M3U playlists is faster because IPTVnator no longer rewrites
+the entire playlist when loading saved favorites.
diff --git a/.changes/m3u-long-stream-urls.md b/.changes/m3u-long-stream-urls.md
new file mode 100644
index 000000000..e084e55ee
--- /dev/null
+++ b/.changes/m3u-long-stream-urls.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: m3u
+issues: [1189]
+---
+
+Playlists with very long stream URLs — Pluto TV style lists that carry a session
+token in every link — import in full again instead of collapsing into a single
+channel.
diff --git a/.changes/m3u-refresh-cancellation.md b/.changes/m3u-refresh-cancellation.md
new file mode 100644
index 000000000..6029dad22
--- /dev/null
+++ b/.changes/m3u-refresh-cancellation.md
@@ -0,0 +1,8 @@
+---
+type: perf
+area: m3u
+---
+
+Cancelling a large M3U refresh now stops its background worker before parsed
+channels can be copied or saved, keeping the interface responsive and leaving
+the existing playlist unchanged.
diff --git a/.changes/playback-shared-player-controls.md b/.changes/playback-shared-player-controls.md
new file mode 100644
index 000000000..d2f27b104
--- /dev/null
+++ b/.changes/playback-shared-player-controls.md
@@ -0,0 +1,9 @@
+---
+type: feature
+area: playback
+---
+
+An optional new set of player controls that looks and behaves the same in the
+HTML5, Video.js and ArtPlayer players, with picture-in-picture and, in
+fullscreen, the name of what you are watching. Enable it in Settings → Playback;
+left off, each of the three keeps its own controls exactly as before.
diff --git a/.changes/playback-subtitle-preference.md b/.changes/playback-subtitle-preference.md
new file mode 100644
index 000000000..8c320a9d1
--- /dev/null
+++ b/.changes/playback-subtitle-preference.md
@@ -0,0 +1,11 @@
+---
+type: fix
+area: playback
+issues: [1155]
+---
+
+The "Show subtitles" setting now works in the built-in players: turning it off
+hides subtitles a stream switched on by itself, and the preference finally
+applies on Xtream and Stalker pages too. Previously it only reached the M3U
+player, and even there Video.js and ArtPlayer ignored it. The player's own
+subtitle menu still overrides the setting for the current stream.
diff --git a/.changes/playback-theater-stage.md b/.changes/playback-theater-stage.md
new file mode 100644
index 000000000..b8f67a5c4
--- /dev/null
+++ b/.changes/playback-theater-stage.md
@@ -0,0 +1,9 @@
+---
+type: feature
+area: playback
+---
+
+The inline player on movie and series pages fills the whole content area like a
+theater: the video sits centered in black instead of leaving a strip of app
+background beside it. In the built-in web players, an optional ambient mode
+fills that space with a blurred, dimmed copy of the poster.
diff --git a/.changes/playback-up-next-rail.md b/.changes/playback-up-next-rail.md
new file mode 100644
index 000000000..13738a792
--- /dev/null
+++ b/.changes/playback-up-next-rail.md
@@ -0,0 +1,9 @@
+---
+type: feature
+area: playback
+---
+
+A series playing inline on a wide window shows an "Up Next" rail beside the
+video: the rest of the season and the start of the next one, the current episode
+highlighted, watch progress on every card. Click one to jump straight to it.
+Built-in web players only; switch the rail off in Settings → Playback.
diff --git a/.changes/playlists-auto-refresh.md b/.changes/playlists-auto-refresh.md
new file mode 100644
index 000000000..2104099f2
--- /dev/null
+++ b/.changes/playlists-auto-refresh.md
@@ -0,0 +1,10 @@
+---
+type: fix
+area: playlists
+issues: [931]
+---
+
+One unreachable playlist no longer holds up the rest. Refreshes give up after 30
+seconds and run a few at a time, so your other playlists still update, and the
+message on startup names how many actually failed instead of always claiming
+success.
diff --git a/.changes/search-punctuation-words.md b/.changes/search-punctuation-words.md
new file mode 100644
index 000000000..b33302a09
--- /dev/null
+++ b/.changes/search-punctuation-words.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: search
+issues: [1161]
+---
+
+Searching for names that carry punctuation inside them — "A&E", "X-Men",
+"L'Equipe" — finds them anywhere in a title, including channels the provider
+prefixes, like "US: A&E".
diff --git a/.changes/settings-strip-country-prefix.md b/.changes/settings-strip-country-prefix.md
new file mode 100644
index 000000000..bc8980306
--- /dev/null
+++ b/.changes/settings-strip-country-prefix.md
@@ -0,0 +1,9 @@
+---
+type: feature
+area: settings
+---
+
+A new setting drops the country prefix from live channel names, turning
+"UK - BBC One" into "BBC One" in lists, the guide and the player. Movie and
+series titles are left alone, and names that only look like a prefix — "Sky -
+Sports F1" — stay intact.
diff --git a/.changes/stalker-detail-requests.md b/.changes/stalker-detail-requests.md
new file mode 100644
index 000000000..f8651f05d
--- /dev/null
+++ b/.changes/stalker-detail-requests.md
@@ -0,0 +1,9 @@
+---
+type: perf
+area: stalker
+---
+
+Detail pages for anything that is not a series — a movie, a live channel, a VOD
+item that only looks like a series — no longer fire an episode-list request
+before they can show anything, so they open faster on every route in, from
+browsing and search to Favorites, Recent and the dashboard.
diff --git a/.changes/stalker-embedded-series-episodes.md b/.changes/stalker-embedded-series-episodes.md
new file mode 100644
index 000000000..f808d4cbf
--- /dev/null
+++ b/.changes/stalker-embedded-series-episodes.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: stalker
+---
+
+Series opened from Favorites, Recent or the dashboard show their current
+episodes. One saved back when a single episode existed used to keep showing that
+one episode forever; the list is refreshed from the portal in the background
+instead.
diff --git a/.changes/stalker-live-tv-channel-list.md b/.changes/stalker-live-tv-channel-list.md
new file mode 100644
index 000000000..d93bfe8c4
--- /dev/null
+++ b/.changes/stalker-live-tv-channel-list.md
@@ -0,0 +1,9 @@
+---
+type: feature
+area: stalker
+---
+
+The Live TV section loads its full channel list up front: search covers every
+channel instead of only the page you are on, genres show how many channels they
+hold, and Live TV opens on a grid of all channels. Guide data for the visible
+rows loads in bulk, so it shows up without playing a channel first.
diff --git a/.changes/tmdb-broken-provider-id.md b/.changes/tmdb-broken-provider-id.md
new file mode 100644
index 000000000..cbc5b6cf4
--- /dev/null
+++ b/.changes/tmdb-broken-provider-id.md
@@ -0,0 +1,10 @@
+---
+type: fix
+area: tmdb
+---
+
+Movies whose provider ships a dead or wrong TMDB id are enriched again. The
+id is weighed against the title and release year: a dead one falls back to
+the title search, a stale one that clearly points at another film loses to
+it, and a working id is no longer thrown away just because the provider
+spells the title differently.
diff --git a/.changes/tmdb-cache-panel.md b/.changes/tmdb-cache-panel.md
new file mode 100644
index 000000000..0156ea055
--- /dev/null
+++ b/.changes/tmdb-cache-panel.md
@@ -0,0 +1,8 @@
+---
+type: feature
+area: tmdb
+---
+
+Settings → Metadata (TMDB) now shows how many entries the metadata cache
+holds and how much space they take, with a button to empty it. Clearing
+costs nothing but the next few lookups — enrichment refetches on demand.
diff --git a/.changes/tmdb-detail-page-extras.md b/.changes/tmdb-detail-page-extras.md
new file mode 100644
index 000000000..8b57a6181
--- /dev/null
+++ b/.changes/tmdb-detail-page-extras.md
@@ -0,0 +1,9 @@
+---
+type: feature
+area: tmdb
+---
+
+Series pages show whether a show has ended or is still returning, so you know
+before committing to it. Directors and creators became clickable avatar chips
+like the cast — they open the person's page, where directing credits now sit
+alongside acting ones.
diff --git a/.changes/tmdb-series-cast.md b/.changes/tmdb-series-cast.md
new file mode 100644
index 000000000..037ad2f8a
--- /dev/null
+++ b/.changes/tmdb-series-cast.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: tmdb
+---
+
+Series detail pages now list the cast of the whole show instead of only its
+newest season, so actors who left partway through stop disappearing from
+long-running shows — while people who joined for the current season still
+show up.
diff --git a/.changes/tmdb-title-matching.md b/.changes/tmdb-title-matching.md
new file mode 100644
index 000000000..025cb790a
--- /dev/null
+++ b/.changes/tmdb-title-matching.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: tmdb
+---
+
+Titles that providers dress up with language or quality tags — "|ALB| Fallout",
+"4K-DE - The Pitt (2025)", "Breaking Bad-eng" — now match against TMDB, so they
+get artwork, plot and cast like the rest of the catalog. Shows split into one
+entry per season also pull the season that entry really contains.
diff --git a/.changes/window-controls-fullscreen-exit.md b/.changes/window-controls-fullscreen-exit.md
new file mode 100644
index 000000000..65607da9b
--- /dev/null
+++ b/.changes/window-controls-fullscreen-exit.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: window-controls
+---
+
+On Windows, the minimize, maximize and close buttons disappeared for good after
+leaving fullscreen video playback — the only way to get them back was to restart
+the app. They now reappear as soon as you exit fullscreen, and the maximize
+button no longer gets stuck on the wrong icon afterwards.
diff --git a/.changes/xtream-catchup-collections.md b/.changes/xtream-catchup-collections.md
new file mode 100644
index 000000000..b8aa5dda6
--- /dev/null
+++ b/.changes/xtream-catchup-collections.md
@@ -0,0 +1,9 @@
+---
+type: feature
+area: xtream
+issues: [1138]
+---
+
+Catch-up is available from Favorites and Recent, not just Live TV, so an
+archived programme is reachable wherever the channel is. The programme currently
+on air can also be restarted from the beginning.
diff --git a/.changes/xtream-portal-connection.md b/.changes/xtream-portal-connection.md
new file mode 100644
index 000000000..6d680359d
--- /dev/null
+++ b/.changes/xtream-portal-connection.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: xtream
+---
+
+Portals sitting behind Cloudflare or a similar firewall connect again. Those
+setups answered the app with a challenge page instead of data, so "Test
+connection" failed on portals that worked fine in every other player; requests
+now identify themselves the way an ordinary IPTV player does.
diff --git a/.changes/xtream-series-resume.md b/.changes/xtream-series-resume.md
new file mode 100644
index 000000000..8f4b89b8a
--- /dev/null
+++ b/.changes/xtream-series-resume.md
@@ -0,0 +1,9 @@
+---
+type: feature
+area: xtream
+---
+
+Continue Watching resumes a series where you left it: opening one from the
+dashboard starts the exact episode at its saved position, and the series page
+offers "Play episode N" instead of always starting at the first one. Episodes
+launched in MPV or VLC count towards this too.
diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md
new file mode 100644
index 000000000..62e07edd5
--- /dev/null
+++ b/.claude/skills/release-cut/SKILL.md
@@ -0,0 +1,88 @@
+---
+name: release-cut
+description: Cut an IPTVnator release — bump the version, generate release notes from .changes/, scaffold the website post, tag, and verify the draft. Use when asked to release, cut a version, prepare release notes, or publish a new version.
+---
+
+# Release Cut
+
+The pipeline turns accumulated `.changes/*.md` notes into all three release
+surfaces. Order matters: **the tag build extracts the CHANGELOG section into
+the GitHub release body and fails if it is missing**, so the changelog step
+is not optional.
+
+## Sequence
+
+1. **Pick the version** — deliberate choice, edit `version` in the root
+ `package.json`. Bare semver only: any suffix flips electron-updater into
+ prerelease mode and leaks into installer version fields.
+
+2. **Review the notes** — read every file in `.changes/`. Fix wording (user
+ language, not reviewer language), then:
+
+ ```bash
+ pnpm run release:notes:validate
+ ```
+
+3. **Generate the changelog section** (idempotent per version — rerunning
+ replaces the section, so regenerate freely until it reads well):
+
+ ```bash
+ pnpm run release:notes:changelog
+ ```
+
+4. **Scaffold the website post**:
+
+ ```bash
+ pnpm run release:notes:blog
+ ```
+
+ Output is `apps/website/src/content/blog/v0-XX-release-notes.mdx` with
+ `draft: true`. The narrative intro, headlines, and `description` are
+ editorial — fill every `TODO` by hand. One post per **minor** version:
+ for a patch release, edit the existing post (the scaffold refuses to
+ overwrite without `--force`).
+
+5. **Screenshots** — only from the fail-closed capture script against the
+ mock servers, never from a real playlist or account: real streams, logos,
+ and TMDB artwork are copyrighted, and credentials must never reach a
+ published image.
+
+ ```bash
+ pnpm nx run electron-backend:build-e2e # once
+ pnpm run release:screenshots # all manifest shots, dark+light
+ ```
+
+ Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature
+ to showcase = new entry in `tools/release/screenshots.manifest.json`
+ (slug must match the note's `screenshot:` field). The run aborts and
+ deletes its frames on any guard violation (real-DB touch, external
+ request, credential-shaped text in frame, TMDB active).
+
+6. **Consume the notes** (the only destructive step):
+
+ ```bash
+ node tools/release/build-release-notes.mjs --consume
+ ```
+
+7. **Commit, tag, push**:
+
+ ```bash
+ git add CHANGELOG.md .changes apps/website package.json
+ git commit -m "chore(release): v0.XX.0"
+ git tag v0.XX.0 && git push && git push --tags
+ ```
+
+8. **Verify the draft release** once `build-and-make.yaml` finishes: authored
+ notes on top, GitHub's generated commit list below, all platform assets
+ present (`.dmg`/`.zip` + `latest-mac.yml`, `.exe`/`.msi` + `latest.yml`,
+ `.deb`/`.rpm`/`.AppImage`/`.snap`/`.flatpak` + `latest-linux*.yml`,
+ blockmaps). Publish manually; flip the blog post to `draft: false`.
+
+## Failure modes
+
+- **create-release fails with "CHANGELOG.md has no section for X"** — step 3
+ was skipped. Run it, commit, delete and re-push the tag.
+- **Snap store publication** is a separate manual flow after the public
+ release exists (`publish-snap.yaml`).
+- Post-release checklist candidates: i18n drift (`pnpm run i18n:check`),
+ update the website `v0-XX` blog assets, announce in Telegram.
diff --git a/.claude/skills/release-notes/SKILL.md b/.claude/skills/release-notes/SKILL.md
new file mode 100644
index 000000000..7ccb1338d
--- /dev/null
+++ b/.claude/skills/release-notes/SKILL.md
@@ -0,0 +1,70 @@
+---
+name: release-notes
+description: Write the .changes/ release note that every PR with a user-visible change must include. Use when creating or finishing a PR that changes behavior in apps/ or libs/, when the "Release note gate" CI check fails, or when deciding whether the no-release-note label applies.
+---
+
+# Release Notes (`.changes/`)
+
+Every PR with a user-visible change adds **one** note file under `.changes/`.
+At release time the notes become the GitHub release body, the `CHANGELOG.md`
+section, and the website blog scaffold. CI enforces this: the **Release note
+gate** check fails any PR that touches runtime code under `apps/` or `libs/`
+without an added `.changes/*.md` file or the `no-release-note` label.
+
+## File format
+
+Name: `.changes/-.md` — `area` matches the
+conventional-commit scope of the PR.
+
+```markdown
+---
+type: feature
+area: playback
+issues: [1187]
+screenshot: up-next-rail
+---
+
+Series now show an "Up Next" rail beside the player on wide windows: the rest
+of the current season, watch progress, and click-to-play inline.
+```
+
+| Field | Required | Value |
+| ------------ | -------- | ---------------------------------------------------- |
+| `type` | yes | `breaking` / `feature` / `fix` / `perf` / `internal` |
+| `area` | yes | lowercase slug = conventional-commit scope |
+| `issues` | no | `[1187]` or bare `1187` — issues this PR closes |
+| `screenshot` | no | slug from the release screenshot manifest |
+
+- **No version field.** The release version is chosen at release time.
+- **Never write a PR number.** The generator resolves it from git.
+- Unknown keys fail validation — this is what catches typos like `scopr:`.
+
+## Writing the body
+
+One to three sentences, present tense, max 400 characters, **written for a
+user, not a reviewer**:
+
+- ❌ "Refactor `WebVideoControlsAdapter` to hoist volume state"
+- ✅ "The player now remembers volume between episodes"
+- ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`"
+- ✅ "Series with a season marker in the title no longer show the wrong season"
+
+`type: internal` is for changes worth recording but invisible to users
+(dependency bumps with behavior risk, packaging moves). They are excluded
+from the release body and blog, and collapsed in `CHANGELOG.md`.
+
+## When to skip (`no-release-note` label)
+
+Test-only changes, docs, CI/workflow plumbing, pure refactors with no
+behavior change. The gate auto-exempts `*.spec.ts`, `*.e2e.ts`,
+`__snapshots__/`, `apps/website/`, `apps/*-e2e/`, `apps/*-mock-server/`,
+`libs/shared/testing/` and `*.md` — if only those changed, no label needed.
+
+## Verify before finishing
+
+```bash
+pnpm run release:notes:validate
+```
+
+Full format reference: `.changes/README.md`. Gate policy:
+`tools/release/check-release-note-gate.mjs`.
diff --git a/.codex/skills/release-cut/SKILL.md b/.codex/skills/release-cut/SKILL.md
new file mode 100644
index 000000000..62e07edd5
--- /dev/null
+++ b/.codex/skills/release-cut/SKILL.md
@@ -0,0 +1,88 @@
+---
+name: release-cut
+description: Cut an IPTVnator release — bump the version, generate release notes from .changes/, scaffold the website post, tag, and verify the draft. Use when asked to release, cut a version, prepare release notes, or publish a new version.
+---
+
+# Release Cut
+
+The pipeline turns accumulated `.changes/*.md` notes into all three release
+surfaces. Order matters: **the tag build extracts the CHANGELOG section into
+the GitHub release body and fails if it is missing**, so the changelog step
+is not optional.
+
+## Sequence
+
+1. **Pick the version** — deliberate choice, edit `version` in the root
+ `package.json`. Bare semver only: any suffix flips electron-updater into
+ prerelease mode and leaks into installer version fields.
+
+2. **Review the notes** — read every file in `.changes/`. Fix wording (user
+ language, not reviewer language), then:
+
+ ```bash
+ pnpm run release:notes:validate
+ ```
+
+3. **Generate the changelog section** (idempotent per version — rerunning
+ replaces the section, so regenerate freely until it reads well):
+
+ ```bash
+ pnpm run release:notes:changelog
+ ```
+
+4. **Scaffold the website post**:
+
+ ```bash
+ pnpm run release:notes:blog
+ ```
+
+ Output is `apps/website/src/content/blog/v0-XX-release-notes.mdx` with
+ `draft: true`. The narrative intro, headlines, and `description` are
+ editorial — fill every `TODO` by hand. One post per **minor** version:
+ for a patch release, edit the existing post (the scaffold refuses to
+ overwrite without `--force`).
+
+5. **Screenshots** — only from the fail-closed capture script against the
+ mock servers, never from a real playlist or account: real streams, logos,
+ and TMDB artwork are copyrighted, and credentials must never reach a
+ published image.
+
+ ```bash
+ pnpm nx run electron-backend:build-e2e # once
+ pnpm run release:screenshots # all manifest shots, dark+light
+ ```
+
+ Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature
+ to showcase = new entry in `tools/release/screenshots.manifest.json`
+ (slug must match the note's `screenshot:` field). The run aborts and
+ deletes its frames on any guard violation (real-DB touch, external
+ request, credential-shaped text in frame, TMDB active).
+
+6. **Consume the notes** (the only destructive step):
+
+ ```bash
+ node tools/release/build-release-notes.mjs --consume
+ ```
+
+7. **Commit, tag, push**:
+
+ ```bash
+ git add CHANGELOG.md .changes apps/website package.json
+ git commit -m "chore(release): v0.XX.0"
+ git tag v0.XX.0 && git push && git push --tags
+ ```
+
+8. **Verify the draft release** once `build-and-make.yaml` finishes: authored
+ notes on top, GitHub's generated commit list below, all platform assets
+ present (`.dmg`/`.zip` + `latest-mac.yml`, `.exe`/`.msi` + `latest.yml`,
+ `.deb`/`.rpm`/`.AppImage`/`.snap`/`.flatpak` + `latest-linux*.yml`,
+ blockmaps). Publish manually; flip the blog post to `draft: false`.
+
+## Failure modes
+
+- **create-release fails with "CHANGELOG.md has no section for X"** — step 3
+ was skipped. Run it, commit, delete and re-push the tag.
+- **Snap store publication** is a separate manual flow after the public
+ release exists (`publish-snap.yaml`).
+- Post-release checklist candidates: i18n drift (`pnpm run i18n:check`),
+ update the website `v0-XX` blog assets, announce in Telegram.
diff --git a/.codex/skills/release-notes/SKILL.md b/.codex/skills/release-notes/SKILL.md
new file mode 100644
index 000000000..7ccb1338d
--- /dev/null
+++ b/.codex/skills/release-notes/SKILL.md
@@ -0,0 +1,70 @@
+---
+name: release-notes
+description: Write the .changes/ release note that every PR with a user-visible change must include. Use when creating or finishing a PR that changes behavior in apps/ or libs/, when the "Release note gate" CI check fails, or when deciding whether the no-release-note label applies.
+---
+
+# Release Notes (`.changes/`)
+
+Every PR with a user-visible change adds **one** note file under `.changes/`.
+At release time the notes become the GitHub release body, the `CHANGELOG.md`
+section, and the website blog scaffold. CI enforces this: the **Release note
+gate** check fails any PR that touches runtime code under `apps/` or `libs/`
+without an added `.changes/*.md` file or the `no-release-note` label.
+
+## File format
+
+Name: `.changes/-.md` — `area` matches the
+conventional-commit scope of the PR.
+
+```markdown
+---
+type: feature
+area: playback
+issues: [1187]
+screenshot: up-next-rail
+---
+
+Series now show an "Up Next" rail beside the player on wide windows: the rest
+of the current season, watch progress, and click-to-play inline.
+```
+
+| Field | Required | Value |
+| ------------ | -------- | ---------------------------------------------------- |
+| `type` | yes | `breaking` / `feature` / `fix` / `perf` / `internal` |
+| `area` | yes | lowercase slug = conventional-commit scope |
+| `issues` | no | `[1187]` or bare `1187` — issues this PR closes |
+| `screenshot` | no | slug from the release screenshot manifest |
+
+- **No version field.** The release version is chosen at release time.
+- **Never write a PR number.** The generator resolves it from git.
+- Unknown keys fail validation — this is what catches typos like `scopr:`.
+
+## Writing the body
+
+One to three sentences, present tense, max 400 characters, **written for a
+user, not a reviewer**:
+
+- ❌ "Refactor `WebVideoControlsAdapter` to hoist volume state"
+- ✅ "The player now remembers volume between episodes"
+- ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`"
+- ✅ "Series with a season marker in the title no longer show the wrong season"
+
+`type: internal` is for changes worth recording but invisible to users
+(dependency bumps with behavior risk, packaging moves). They are excluded
+from the release body and blog, and collapsed in `CHANGELOG.md`.
+
+## When to skip (`no-release-note` label)
+
+Test-only changes, docs, CI/workflow plumbing, pure refactors with no
+behavior change. The gate auto-exempts `*.spec.ts`, `*.e2e.ts`,
+`__snapshots__/`, `apps/website/`, `apps/*-e2e/`, `apps/*-mock-server/`,
+`libs/shared/testing/` and `*.md` — if only those changed, no label needed.
+
+## Verify before finishing
+
+```bash
+pnpm run release:notes:validate
+```
+
+Full format reference: `.changes/README.md`. Gate policy:
+`tools/release/check-release-note-gate.mjs`.
diff --git a/.codex/skills/stalker-portal/SKILL.md b/.codex/skills/stalker-portal/SKILL.md
new file mode 100644
index 000000000..d59fa7a20
--- /dev/null
+++ b/.codex/skills/stalker-portal/SKILL.md
@@ -0,0 +1,66 @@
+---
+name: stalker-portal
+description: Repository guidance for Stalker/Ministra portal catalogs, VOD/series shapes, playback metadata, collections, EPG, and remote control.
+---
+
+# Stalker Portal
+
+Use this skill when changing Stalker/Ministra routes, stores, catalog/detail
+views, playback, favorites/recent activity, EPG, or remote control.
+
+## Read First
+
+- `docs/architecture/stalker-portal.md`
+- `docs/architecture/stalker-epg.md` for ITV EPG work
+- `docs/architecture/remote-control.md` for live remote-control work
+
+## Ownership
+
+- Feature UI: `libs/portal/stalker/feature/src/lib/`
+- Store/API data access: `libs/portal/stalker/data-access/src/lib/`
+- Electron requests: `apps/electron-backend/src/app/events/stalker.events.ts`
+- Shared Stalker item normalization:
+ `libs/shared/interfaces/src/lib/stalker-item.normalizer.ts`
+- Dashboard aggregation: `libs/workspace/dashboard/data-access/src/lib/`
+
+Keep provider-specific API and normalization behavior in Stalker data access.
+Keep shared portal layouts/utilities provider-neutral. Preserve full-portal
+session auth and simple IPC request paths.
+
+## `is_series` Cross-Surface Checklist
+
+Treat VOD items with `is_series` as series across every downstream surface.
+Do not stop after making the detail view render.
+
+1. Accept portal flags `true`, `1`, and `'1'` through the existing normalizers.
+ Preserve all three modes: regular `/series`, embedded VOD `series[]`, and
+ lazy Ministra VOD `is_series`.
+2. Build quick-start state through the shared series utility. Preserve
+ `labelKey`, `labelParams`, and `episodeLabel` when adapting it for Stalker;
+ translation parameters must reach the template.
+3. Preserve `is_series` and the VOD origin in favorites/recent activity.
+ `extractStalkerItemType()` must normalize that activity to dashboard type
+ `series`.
+4. Before either inline or external episode playback, persist the parent
+ `seriesXtreamId` plus resolved `seasonNumber` and `episodeNumber`. Keep
+ generated episode tracking IDs stable for lazy `is_series` episodes. When
+ `season_number` is absent, derive the coordinate from the same naturally
+ ordered season list used by quick start; do not default every season to 1.
+5. The dashboard reads saved playback positions; it must not infer episode
+ numbers from provider payloads. Legacy rows without season/episode metadata
+ remain badge-less until that episode is played again.
+
+## Regression Coverage
+
+- Series view/UI and playback handoff:
+ `pnpm nx test portal-stalker-feature`
+- Stalker shape/store behavior:
+ `pnpm nx test portal-stalker-data-access`
+- Dashboard classification and position lookup:
+ `pnpm nx test workspace-dashboard-data-access`
+- Dashboard badge rendering when changed:
+ `pnpm nx test workspace-dashboard-feature`
+
+For user-visible workflow changes, run the closest available E2E target. If no
+fixture covers the affected portal shape, record that gap and perform the
+strongest targeted unit/build validation available.
diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
new file mode 100644
index 000000000..7cd37535a
--- /dev/null
+++ b/.github/actionlint.yaml
@@ -0,0 +1,10 @@
+paths:
+ # build-cross-platform and build-linux share their steps via a YAML anchor
+ # (steps: *electron-build-steps). actionlint type-checks the anchored steps
+ # against each job's own matrix, so matrix.linux_profile — defined only in
+ # build-linux — is reported as unknown when the same steps are checked
+ # against the build-cross-platform matrix. The steps guard every use with
+ # `matrix.os == 'linux'` or a `|| ''` fallback, so this is a false positive.
+ .github/workflows/build-and-make.yaml:
+ ignore:
+ - 'property "linux_profile" is not defined in object type'
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..fdccfe138
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,39 @@
+# Every Dependabot PR triggers the full pipeline (~15 jobs), so version
+# updates are batched: weekly cadence, minor+patch bumps grouped into one PR
+# per ecosystem, majors as individual PRs so CI gates them one by one.
+# Security updates are separate and are not limited by this schedule.
+version: 2
+updates:
+ - package-ecosystem: npm
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ time: '06:00'
+ open-pull-requests-limit: 5
+ groups:
+ npm-minor-patch:
+ update-types:
+ - minor
+ - patch
+
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ time: '06:00'
+ groups:
+ actions-minor-patch:
+ patterns:
+ - '*'
+ update-types:
+ - minor
+ - patch
+
+ - package-ecosystem: docker
+ directory: /docker
+ schedule:
+ interval: weekly
+ day: monday
+ time: '06:00'
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..887886187
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,23 @@
+
+
+## What changed
+
+## Why
+
+## Release note
+
+Changes a user could notice need one file in `.changes/` describing the change
+in plain language — see
+[`.changes/README.md`](https://github.com/4gray/iptvnator/blob/master/.changes/README.md).
+It becomes the release notes and the website post, so it is worth a minute.
+
+- [ ] Added a note under `.changes/`
+- [ ] Not needed (test-only, docs, CI, or a refactor with no behavior change)
+
+## Checks
+
+- [ ] Tests added or updated for the changed behavior
+- [ ] `pnpm run lint` and the affected `pnpm nx test ` pass
diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml
index 41dd2a48e..efc62626e 100644
--- a/.github/workflows/build-and-make.yaml
+++ b/.github/workflows/build-and-make.yaml
@@ -1,22 +1,351 @@
name: Build and Make Electron App
+# Docs-only changes never affect the packaged app, so they skip the build
+# matrix. apps/website/** is intentionally NOT ignored: the Linux build job
+# builds the website to verify AppStream screenshot assets. Tag pushes are
+# unaffected — GitHub does not evaluate paths filters for tags, so v* release
+# builds always run.
+#
+# Known accepted edge case: if a PR built a test-pr- draft and later
+# reverts its code changes so the remaining diff is docs-only, new pushes
+# skip this workflow and the draft keeps assets from the older commit. The
+# draft's title/body name the exact commit they were built from, and
+# cleanup-pr-draft.yml deletes the draft when the PR closes, so the stale
+# window is visible and bounded; refreshing drafts on skipped runs is not
+# worth a separate workflow.
on:
push:
branches:
- master
tags:
- 'v*.*.*'
+ paths-ignore:
+ - '**/*.md'
+ - 'docs/**'
+ - '.plans/**'
+ - '.codex/**'
+ - '.claude/**'
pull_request:
branches:
- master
+ paths-ignore:
+ - '**/*.md'
+ - 'docs/**'
+ - '.plans/**'
+ - '.codex/**'
+ - '.claude/**'
workflow_dispatch:
+# Build jobs only read the repo; the create-release job raises itself to
+# contents: write at the job level to manage the rolling draft release.
+permissions:
+ contents: read
+
jobs:
- build:
+ linux-embedded-mpv-runtime:
+ name: Build pinned Linux Embedded MPV runtime
+ runs-on: ubuntu-22.04
+ timeout-minutes: 120
+ # Concurrency lives on the build jobs, not the workflow: cancelling a
+ # whole run could interrupt action-gh-release mid-update and leave the
+ # rolling draft with missing assets. Build slots cancel superseded PR
+ # work; the release job only serializes and is never cancelled.
+ concurrency:
+ group: ${{ github.workflow }}-build-linux-runtime-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Resolve Linux runtime toolchain cache key
+ id: linux-runtime-cache-key
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ sudo apt-get update
+ {
+ apt-cache policy \
+ binutils build-essential cmake curl git gperf \
+ libasound2-dev libdrm-dev libegl-dev libgbm-dev \
+ libgl-dev libpulse-dev libva-dev make nasm \
+ ninja-build patchelf perl pkg-config python3-pip \
+ tar xz-utils
+ echo 'meson=1.7.2'
+ } > "${RUNNER_TEMP}/linux-runtime-toolchain.txt"
+ TOOLCHAIN_SHA256="$(sha256sum "${RUNNER_TEMP}/linux-runtime-toolchain.txt" | cut -d ' ' -f 1)"
+ SOURCE_SHA256="${{ hashFiles('tools/embedded-mpv/build-linux-runtime.cjs', 'tools/embedded-mpv/build-linux-runtime.mjs', 'tools/embedded-mpv/generate-linux-runtime-notices.cjs', 'tools/embedded-mpv/linux-runtime-manifest.cjs', 'tools/embedded-mpv/linux-source-archive-contract.cjs', 'tools/embedded-mpv/stage-runtime.mjs', 'tools/packaging/prepare-linux-runtime-source-snapshot.cjs') }}"
+ echo "toolchain-sha256=${TOOLCHAIN_SHA256}" >> "${GITHUB_OUTPUT}"
+ echo "key=linux-frame-copy-runtime-v5-ubuntu-22.04-${TOOLCHAIN_SHA256}-${SOURCE_SHA256}" >> "${GITHUB_OUTPUT}"
+
+ - name: Restore pinned Linux runtime and immutable source inputs
+ id: linux-runtime-cache
+ uses: actions/cache@v4
+ with:
+ path: |
+ vendor/embedded-mpv/linux-x64/include
+ vendor/embedded-mpv/linux-x64/lib
+ vendor/embedded-mpv/linux-x64/runtime-manifest.json
+ dist/linux-frame-copy-runtime-source-inputs
+ key: ${{ steps.linux-runtime-cache-key.outputs.key }}
+
+ - name: Install pinned Linux runtime build dependencies
+ if: steps.linux-runtime-cache.outputs.cache-hit != 'true'
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ sudo apt-get install --no-install-recommends -y \
+ binutils \
+ build-essential \
+ cmake \
+ curl \
+ git \
+ gperf \
+ libasound2-dev \
+ libdrm-dev \
+ libegl-dev \
+ libgbm-dev \
+ libgl-dev \
+ libpulse-dev \
+ libva-dev \
+ make \
+ nasm \
+ ninja-build \
+ patchelf \
+ perl \
+ pkg-config \
+ python3-pip \
+ tar \
+ xz-utils
+ python3 -m pip install --user 'meson==1.7.2'
+
+ - name: Build and stage pinned LGPL Linux runtime
+ if: steps.linux-runtime-cache.outputs.cache-hit != 'true'
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ export PATH="${HOME}/.local/bin:${PATH}"
+ export IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-build"
+ export RUNTIME_PREFIX="${RUNNER_TEMP}/linux-frame-copy-runtime-prefix"
+
+ node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}"
+ node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}"
+
+ export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs"
+ rm -rf "${SOURCE_INPUT_ROOT}"
+ mkdir -p \
+ "${SOURCE_INPUT_ROOT}/archives" \
+ "${SOURCE_INPUT_ROOT}/git"
+
+ git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \
+ submodule foreach --recursive git clean -ffdqx
+ git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \
+ clean -ffdqx
+ cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/archives/." "${SOURCE_INPUT_ROOT}/archives/"
+ cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" "${SOURCE_INPUT_ROOT}/git/libplacebo"
+ node tools/embedded-mpv/generate-linux-runtime-notices.cjs collect \
+ --runtime-manifest "${RUNTIME_PREFIX}/runtime-manifest.json" \
+ --source-root "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources" \
+ --output-root "${SOURCE_INPUT_ROOT}/license-inputs"
+
+ - name: Generate Linux runtime notices and assemble source compliance
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ export RUNTIME_ROOT="${GITHUB_WORKSPACE}/vendor/embedded-mpv/linux-x64"
+ export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs"
+ export SOURCE_BUNDLE_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-sources"
+ export LIBPLACEBO_SOURCE_RECORD="${RUNNER_TEMP}/libplacebo-source-record.json"
+
+ test -f "${RUNTIME_ROOT}/runtime-manifest.json"
+ test -d "${SOURCE_INPUT_ROOT}/archives"
+ test -d "${SOURCE_INPUT_ROOT}/git/libplacebo"
+ test -f "${SOURCE_INPUT_ROOT}/license-inputs/linux-runtime-license-inputs.json"
+
+ rm -rf "${RUNTIME_ROOT}/notices" "${SOURCE_BUNDLE_ROOT}"
+ node tools/embedded-mpv/generate-linux-runtime-notices.cjs generate \
+ --runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \
+ --license-input-root "${SOURCE_INPUT_ROOT}/license-inputs" \
+ --output-root "${RUNTIME_ROOT}/notices"
+
+ mkdir -p \
+ "${SOURCE_BUNDLE_ROOT}/archives" \
+ "${SOURCE_BUNDLE_ROOT}/git" \
+ "${SOURCE_BUNDLE_ROOT}/license-inputs" \
+ "${SOURCE_BUNDLE_ROOT}/metadata" \
+ "${SOURCE_BUNDLE_ROOT}/notices" \
+ "${SOURCE_BUNDLE_ROOT}/tooling"
+
+ cp -a "${SOURCE_INPUT_ROOT}/archives/." "${SOURCE_BUNDLE_ROOT}/archives/"
+ cp -a "${SOURCE_INPUT_ROOT}/license-inputs/." "${SOURCE_BUNDLE_ROOT}/license-inputs/"
+ cp -a "${RUNTIME_ROOT}/notices/." "${SOURCE_BUNDLE_ROOT}/notices/"
+ cp "${RUNTIME_ROOT}/runtime-manifest.json" "${SOURCE_BUNDLE_ROOT}/metadata/runtime-manifest.json"
+ node tools/packaging/prepare-linux-runtime-source-snapshot.cjs prepare \
+ --runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \
+ --checkout "${SOURCE_INPUT_ROOT}/git/libplacebo" \
+ --output "${SOURCE_BUNDLE_ROOT}/git/libplacebo" \
+ --record-output "${LIBPLACEBO_SOURCE_RECORD}"
+ cp \
+ tools/embedded-mpv/build-linux-runtime.cjs \
+ tools/embedded-mpv/build-linux-runtime.mjs \
+ tools/embedded-mpv/generate-linux-runtime-notices.cjs \
+ tools/embedded-mpv/linux-runtime-manifest.cjs \
+ tools/embedded-mpv/linux-source-archive-contract.cjs \
+ tools/embedded-mpv/stage-runtime.mjs \
+ tools/packaging/prepare-linux-runtime-source-snapshot.cjs \
+ "${SOURCE_BUNDLE_ROOT}/tooling/"
+ test -f "${SOURCE_BUNDLE_ROOT}/notices/THIRD_PARTY_NOTICES.txt"
+ test -f "${SOURCE_BUNDLE_ROOT}/notices/embedded-mpv-notices.json"
+ git rev-parse HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/iptvnator-git-revision.txt"
+ git diff --binary HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/local-changes.patch"
+ node <<'NODE'
+ const crypto = require('node:crypto');
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const {
+ EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256,
+ validateLinuxRuntimeSourceSnapshot,
+ } = require('./tools/packaging/prepare-linux-runtime-source-snapshot.cjs');
+
+ const manifest = JSON.parse(
+ fs.readFileSync(path.join(process.env.RUNTIME_ROOT, 'runtime-manifest.json'), 'utf8')
+ );
+ const archivesDirectory = path.join(process.env.SOURCE_BUNDLE_ROOT, 'archives');
+ const archives = fs.readdirSync(archivesDirectory).sort().map((name) => {
+ const contents = fs.readFileSync(path.join(archivesDirectory, name));
+ return {
+ name,
+ sha256: crypto.createHash('sha256').update(contents).digest('hex'),
+ };
+ });
+ const expectedArchiveHashes = Object.values(manifest.packages)
+ .map(({ sourceSha256 }) => sourceSha256)
+ .filter(Boolean)
+ .sort();
+ const actualArchiveHashes = archives.map(({ sha256 }) => sha256).sort();
+ if (
+ new Set(expectedArchiveHashes).size !== expectedArchiveHashes.length ||
+ new Set(actualArchiveHashes).size !== actualArchiveHashes.length ||
+ archives.length !== expectedArchiveHashes.length ||
+ JSON.stringify(actualArchiveHashes) !== JSON.stringify(expectedArchiveHashes)
+ ) {
+ throw new Error(
+ 'Source bundle archives must match the exact unique pinned archive hash set.'
+ );
+ }
+
+ const libplacebo = JSON.parse(
+ fs.readFileSync(process.env.LIBPLACEBO_SOURCE_RECORD, 'utf8')
+ );
+ if (
+ libplacebo.sourceGitCommit !== manifest.packages.libplacebo.sourceGitCommit ||
+ JSON.stringify(libplacebo.sourceSubmodules) !==
+ JSON.stringify(manifest.packages.libplacebo.sourceSubmodules)
+ ) {
+ throw new Error('Prepared libplacebo source identity does not match the runtime manifest.');
+ }
+ validateLinuxRuntimeSourceSnapshot(libplacebo.sourceSnapshot, {
+ expectedSha256:
+ EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256,
+ });
+
+ const notices = JSON.parse(
+ fs.readFileSync(
+ path.join(process.env.SOURCE_BUNDLE_ROOT, 'notices', 'embedded-mpv-notices.json'),
+ 'utf8'
+ )
+ );
+ const repositoryRevision = fs
+ .readFileSync(
+ path.join(
+ process.env.SOURCE_BUNDLE_ROOT,
+ 'metadata',
+ 'iptvnator-git-revision.txt'
+ ),
+ 'utf8'
+ )
+ .trim();
+ fs.writeFileSync(
+ path.join(process.env.SOURCE_BUNDLE_ROOT, 'metadata', 'source-index.json'),
+ `${JSON.stringify(
+ {
+ schemaVersion: 3,
+ repositoryRevision,
+ sourcePackages: manifest.packages,
+ archives,
+ libplacebo,
+ legal: {
+ manifest: 'notices/embedded-mpv-notices.json',
+ noticeFile: notices.noticeFile,
+ packages: notices.packages,
+ },
+ },
+ null,
+ 2
+ )}\n`
+ );
+ NODE
+ (
+ cd "${SOURCE_BUNDLE_ROOT}/archives"
+ sha256sum * > "../metadata/archive-sha256.txt"
+ )
+ node tools/packaging/prepare-linux-runtime-source-snapshot.cjs assert-vcs-free \
+ --directory "${SOURCE_BUNDLE_ROOT}"
+
+ mkdir -p dist/compliance
+ rm -f dist/compliance/linux-frame-copy-runtime-sources.tar.xz
+ tar \
+ --create \
+ --xz \
+ --sort=name \
+ --mtime='UTC 1970-01-01' \
+ --owner=0 \
+ --group=0 \
+ --numeric-owner \
+ --file dist/compliance/linux-frame-copy-runtime-sources.tar.xz \
+ --directory "${SOURCE_BUNDLE_ROOT}" \
+ .
+ rm -f "${RUNTIME_ROOT}/source-archive-binding.json"
+ node tools/embedded-mpv/linux-source-archive-contract.cjs create \
+ --archive dist/compliance/linux-frame-copy-runtime-sources.tar.xz \
+ --repository-revision "$(git rev-parse HEAD)" \
+ --output "${RUNTIME_ROOT}/source-archive-binding.json"
+ test -s "${RUNTIME_ROOT}/source-archive-binding.json"
+
+ - name: Upload staged Linux runtime
+ uses: actions/upload-artifact@v7
+ with:
+ name: linux-embedded-mpv-runtime
+ path: vendor/embedded-mpv/linux-x64
+ if-no-files-found: error
+ retention-days: 7
+
+ - name: Upload Linux runtime source compliance
+ uses: actions/upload-artifact@v7
+ with:
+ name: linux-frame-copy-runtime-sources
+ path: dist/compliance/linux-frame-copy-runtime-sources.tar.xz
+ if-no-files-found: error
+ retention-days: 7
+
+ build-cross-platform:
name: Build on ${{ matrix.os }} ${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
+ concurrency:
+ group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.arch }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
strategy:
+ fail-fast: false
matrix:
include:
# macOS builds - separate runners to avoid native module conflicts
@@ -32,28 +361,16 @@ jobs:
embedded_mpv_platform: darwin
embedded_mpv_arch: arm64
embedded_mpv_build_runtime: true
- # Linux and Windows
- - os: linux
- runner: ubuntu-22.04
- linux_profile: standard
- embedded_mpv_platform: linux
- embedded_mpv_arch: x64
- embedded_mpv_build_runtime: false
- - os: linux
- runner: ubuntu-24.04
- linux_profile: flatpak
- embedded_mpv_platform: linux
- embedded_mpv_arch: x64
- embedded_mpv_build_runtime: false
- os: windows
runner: windows-2022
+ arch: x64
embedded_mpv_platform: win32
embedded_mpv_arch: x64
embedded_mpv_build_runtime: false
- steps:
+ steps: &electron-build-steps
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -68,38 +385,50 @@ jobs:
if: matrix.os == 'linux'
run: |
sudo apt-get update
- sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev libmpv-dev mpv pkg-config
+ sudo apt-get install --no-install-recommends -y \
+ appstream \
+ binutils \
+ dbus-daemon \
+ flatpak \
+ flatpak-builder \
+ libarchive-tools \
+ libegl-dev \
+ libgbm-dev \
+ libgl-dev \
+ libx11-dev \
+ libxext-dev \
+ mpv \
+ pkg-config \
+ rpm \
+ snapd \
+ squashfs-tools \
+ xauth \
+ xvfb
+
+ - name: Configure Flatpak build runtime
+ if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak'
+ run: |
+ set -euo pipefail
- # Configure Flatpak
- # 1. Add the Flathub repository (source of runtimes)
flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo
-
- # 2. Install the standard Freedesktop Platform and SDK (required by electron-builder)
- # We install version 24.08 as a safe default, electron-builder might pick what it needs
flatpak install --user -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
- name: Select Linux packaging targets for CI profile
if: matrix.os == 'linux'
run: |
- node -e "
- const fs = require('fs');
- const path = 'electron-builder.json';
- const config = JSON.parse(fs.readFileSync(path, 'utf8'));
- const targets = Array.isArray(config.linux?.target) ? config.linux.target : [];
- const profile = '${{ matrix.linux_profile }}';
-
- if (profile === 'standard') {
- config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() !== 'flatpak');
- } else if (profile === 'flatpak') {
- config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() === 'flatpak');
- }
-
- fs.writeFileSync(path, JSON.stringify(config, null, 4) + '\n');
- "
+ cp electron-builder.json "${RUNNER_TEMP}/electron-builder.base.json"
+ node tools/packaging/configure-linux-frame-copy-build.mjs --profile "${{ matrix.linux_profile }}"
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ - name: Download pinned Linux Embedded MPV runtime
+ if: matrix.os == 'linux'
+ uses: actions/download-artifact@v8
+ with:
+ name: linux-embedded-mpv-runtime
+ path: vendor/embedded-mpv/linux-x64
+
- name: Inject TMDB API key
# No-op when the secret is unavailable (e.g. fork PRs) — the
# app then requires a user-provided key for TMDB enrichment.
@@ -107,18 +436,26 @@ jobs:
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
run: node tools/tmdb/inject-tmdb-key.mjs
+ - name: Inject build commit
+ # Shows " ()" in Settings > About so bug reports
+ # from test builds identify the exact commit. PR builds use the
+ # head SHA — github.sha would be the ephemeral merge commit.
+ env:
+ BUILD_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
+ run: node tools/build/inject-build-commit.mjs
+
- name: Build frontend
run: pnpm nx build web --skip-nx-cache
- name: Resolve embedded MPV runtime cache key
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
- if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
+ if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
id: embedded-mpv-runtime-cache-key
shell: bash
env:
IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || '' }}
- IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: a26e28fa55c15ac5b6209d8a9f9c3c8cb39649631a53d3c4774beeae559a078c
+ IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: 6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0
run: |
set -euo pipefail
@@ -186,7 +523,7 @@ jobs:
- name: Restore embedded MPV runtime cache
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
- if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
+ if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
id: embedded-mpv-runtime-cache
uses: actions/cache/restore@v4
with:
@@ -199,7 +536,7 @@ jobs:
- name: Clear stale embedded MPV runtime files
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
- if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true'
+ if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true'
shell: bash
run: |
set -euo pipefail
@@ -229,8 +566,8 @@ jobs:
env:
IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL || '' }}
IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || '' }}
- IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: https://github.com/zhongfly/mpv-winbuild/releases/download/2026-06-14-7d245fd100/mpv-dev-lgpl-x86_64-20260614-git-7d245fd100.7z
- IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: a26e28fa55c15ac5b6209d8a9f9c3c8cb39649631a53d3c4774beeae559a078c
+ IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: https://github.com/zhongfly/mpv-winbuild/releases/download/2026-07-17-94335ab87a/mpv-dev-lgpl-x86_64-20260717-git-94335ab87a.7z
+ IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: 6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0
run: |
set -euo pipefail
@@ -254,47 +591,11 @@ jobs:
pnpm embedded-mpv:stage-runtime:windows-archive -- "${WINDOWS_RUNTIME_URL}" "${WINDOWS_RUNTIME_SHA256}"
- - name: Stage Linux embedded MPV build inputs
- if: matrix.os == 'linux'
- shell: bash
- run: |
- set -euo pipefail
-
- RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/linux-x64/prefix"
- rm -rf "${RUNTIME_PREFIX}"
- mkdir -p "${RUNTIME_PREFIX}/include"
-
- cp -a /usr/include/mpv "${RUNTIME_PREFIX}/include/"
-
- LIBMPV_DEV_VERSION="$(dpkg-query -W -f='${Version}' libmpv-dev)"
- MPV_VERSION="$(dpkg-query -W -f='${Version}' mpv)"
- export RUNTIME_PREFIX LIBMPV_DEV_VERSION MPV_VERSION
- node <<'NODE'
- const fs = require('fs');
- const path = require('path');
-
- const manifest = {
- linuxBackend: 'process-isolated mpv --wid',
- buildInputs: {
- libmpvDevPackage: process.env.LIBMPV_DEV_VERSION,
- mpvPackage: process.env.MPV_VERSION,
- },
- sourceDistribution:
- 'Linux CI build inputs come from Ubuntu runner packages. Runtime playback uses the system mpv executable; IPTVnator does not bundle or load libmpv in the Electron process on Linux.',
- };
-
- fs.writeFileSync(
- path.join(process.env.RUNTIME_PREFIX, 'runtime-manifest.json'),
- `${JSON.stringify(manifest, null, 2)}\n`
- );
- NODE
-
- pnpm embedded-mpv:stage-runtime -- linux x64 "${RUNTIME_PREFIX}"
-
- name: Build backend
env:
IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }}
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }}
+ IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }}
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master'))) && '1' || '0' }}
run: pnpm run build:backend
@@ -323,17 +624,37 @@ jobs:
;;
win32)
test -f dist/apps/electron-backend/native/lib/mpv-2.dll || test -f dist/apps/electron-backend/native/lib/libmpv-2.dll || test -f dist/apps/electron-backend/native/lib/mpv.dll || test -f dist/apps/electron-backend/native/lib/libmpv.dll
+ # Frame-copy engine artifacts ship on Windows. The helper
+ # resolves the mpv DLL from its own directory, so the DLL
+ # must sit beside it at the native/ top level too.
+ test -f dist/apps/electron-backend/native/iptvnator_mpv_helper.exe
+ test -f dist/apps/electron-backend/native/embedded_mpv_frame_reader.node
+ find dist/apps/electron-backend/native -maxdepth 1 \( -name 'mpv-2.dll' -o -name 'libmpv-2.dll' -o -name 'mpv.dll' -o -name 'libmpv.dll' \) -print -quit | grep -q .
;;
linux)
- node -e "const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); if (manifest.origin !== 'external-mpv-process') { throw new Error('Linux embedded MPV manifest must use external-mpv-process origin.'); }"
- if find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print -quit 2>/dev/null | grep -q .; then
- echo "::error::Linux embedded MPV packages must not bundle libmpv"
- find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print
+ node -e "const { execFileSync } = require('node:child_process'); const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); if (manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true || manifest.sourceArchive?.schemaVersion !== 1 || manifest.sourceArchive?.name !== 'linux-frame-copy-runtime-sources.tar.xz' || !/^[a-f0-9]{64}$/.test(manifest.sourceArchive?.sha256 ?? '') || manifest.sourceArchive?.repositoryRevision !== revision) { throw new Error('Linux embedded MPV build manifest must describe the validated source runtime and exact source archive.'); }"
+ test -f dist/apps/electron-backend/native/lib/libmpv.so.2
+ test -f dist/apps/electron-backend/native/iptvnator_mpv_helper
+ test -f dist/apps/electron-backend/native/embedded_mpv_frame_reader.node
+ if readelf -d dist/apps/electron-backend/native/embedded_mpv.node | grep -Eq 'Shared library:.*libmpv\.so'; then
+ echo "::error::Linux embedded MPV addon must not link directly to libmpv"
+ readelf -d dist/apps/electron-backend/native/embedded_mpv.node
exit 1
fi
- if ldd dist/apps/electron-backend/native/embedded_mpv.node | grep -q 'libmpv'; then
- echo "::error::Linux embedded MPV addon must not link directly to libmpv"
- ldd dist/apps/electron-backend/native/embedded_mpv.node
+ if readelf -d dist/apps/electron-backend/native/embedded_mpv_frame_reader.node | grep -Eq 'Shared library:.*libmpv\.so'; then
+ echo "::error::Linux frame reader must not link directly to libmpv"
+ readelf -d dist/apps/electron-backend/native/embedded_mpv_frame_reader.node
+ exit 1
+ fi
+ HELPER_DYNAMIC="$(readelf -d dist/apps/electron-backend/native/iptvnator_mpv_helper)"
+ if ! printf '%s\n' "${HELPER_DYNAMIC}" | grep -Eq 'Shared library: \[libmpv\.so\.2\]'; then
+ echo "::error::Linux frame-copy helper must need libmpv.so.2"
+ printf '%s\n' "${HELPER_DYNAMIC}"
+ exit 1
+ fi
+ if ! printf '%s\n' "${HELPER_DYNAMIC}" | grep -Fq 'Library runpath: [$ORIGIN/lib]'; then
+ echo "::error::Linux frame-copy helper must keep only the relative runtime path"
+ printf '%s\n' "${HELPER_DYNAMIC}"
exit 1
fi
;;
@@ -539,6 +860,7 @@ jobs:
env:
IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }}
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }}
+ IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }}
# TEMPORARY PR TEST: change this back to '0' after manually
# testing the macOS PR artifact with Embedded MPV included.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && github.event_name == 'pull_request')) && '1' || '0' }}
@@ -549,11 +871,250 @@ jobs:
env:
PACKAGE_OS: ${{ matrix.os }}
PACKAGE_ARCH: ${{ matrix.arch || matrix.embedded_mpv_arch || '' }}
+ IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }}
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master'))) && '1' || '0' }}
run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH"
+ - name: Make marker-only foreign-architecture DEB packages
+ if: matrix.os == 'linux' && matrix.linux_profile == 'system'
+ shell: bash
+ env:
+ IPTVNATOR_EMBEDDED_MPV_PLATFORM: linux
+ IPTVNATOR_EMBEDDED_MPV_ARCH: x64
+ IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ''
+ IPTVNATOR_REQUIRE_EMBEDDED_MPV: '1'
+ run: |
+ set -euo pipefail
+
+ for foreign_arch in armv7l arm64; do
+ rm -rf dist/executables-linux-foreign
+ cp "${RUNNER_TEMP}/electron-builder.base.json" electron-builder.json
+ node tools/packaging/configure-linux-frame-copy-build.mjs \
+ --foreign-deb \
+ --foreign-arch "${foreign_arch}"
+ pnpm nx run electron-backend:make \
+ --arch="${foreign_arch}" \
+ --outputPath=dist/executables-linux-foreign \
+ --publishPolicy=never
+ mapfile -t foreign_debs < <(
+ find dist/executables-linux-foreign -maxdepth 1 -type f -name '*.deb' -print
+ )
+ test "${#foreign_debs[@]}" -eq 1
+ case "${foreign_arch}" in
+ armv7l) expected_deb_arch=armhf ;;
+ arm64) expected_deb_arch=arm64 ;;
+ *)
+ echo "::error::Unexpected foreign DEB build architecture ${foreign_arch}"
+ exit 1
+ ;;
+ esac
+ actual_deb_arch="$(dpkg-deb --field "${foreign_debs[0]}" Architecture)"
+ test "${actual_deb_arch}" = "${expected_deb_arch}"
+ mv "${foreign_debs[0]}" dist/executables/
+ done
+
+ - name: Verify DEB payloads and x64 system runtime
+ if: matrix.os == 'linux' && matrix.linux_profile == 'system'
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ found=false
+ for artifact in dist/executables/*.deb; do
+ test -f "${artifact}" || continue
+ found=true
+ case "$(dpkg-deb --field "${artifact}" Architecture)" in
+ amd64)
+ docker run --rm \
+ --volume "${GITHUB_WORKSPACE}:/workspace:ro" \
+ --volume "$(realpath "${artifact}"):/artifact.deb:ro" \
+ --workdir /workspace \
+ ubuntu:24.04 \
+ bash -euo pipefail -c '
+ apt-get update
+ DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
+ binutils libegl1 libgbm1 libgl1 libgl1-mesa-dri libmpv2 \
+ nodejs squashfs-tools xauth xvfb
+ xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
+ node tools/packaging/verify-linux-frame-copy-runtime.mjs \
+ --artifact /artifact.deb --profile system
+ '
+ ;;
+ arm64|armhf)
+ node tools/packaging/verify-linux-frame-copy-runtime.mjs \
+ --artifact "${artifact}" --profile system
+ ;;
+ *)
+ echo "::error::Unexpected DEB architecture in ${artifact}"
+ exit 1
+ ;;
+ esac
+ done
+ test "${found}" = true
+
+ - name: Verify RPM payload and x64 system runtime
+ if: matrix.os == 'linux' && matrix.linux_profile == 'system'
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ artifact="$(find dist/executables -maxdepth 1 -type f -name '*.rpm' -print -quit)"
+ test -n "${artifact}"
+ docker run --rm \
+ --volume "${GITHUB_WORKSPACE}:/workspace:ro" \
+ --volume "$(realpath "${artifact}"):/artifact.rpm:ro" \
+ --workdir /workspace \
+ fedora:latest \
+ bash -euo pipefail -c '
+ dnf install -y \
+ binutils bsdtar libglvnd-egl libglvnd-glx mesa-dri-drivers \
+ mesa-libgbm mpv-libs nodejs rpm xorg-x11-server-Xvfb \
+ xorg-x11-xauth
+ xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
+ node tools/packaging/verify-linux-frame-copy-runtime.mjs \
+ --artifact /artifact.rpm --profile system
+ '
+
+ - name: Verify Pacman payload and x64 system runtime
+ if: matrix.os == 'linux' && matrix.linux_profile == 'system'
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ artifact="$(find dist/executables -maxdepth 1 -type f \( -name '*.pacman' -o -name '*.pkg.tar.*' \) -print -quit)"
+ test -n "${artifact}"
+ docker run --rm \
+ --volume "${GITHUB_WORKSPACE}:/workspace:ro" \
+ --volume "$(realpath "${artifact}"):/artifact.pacman:ro" \
+ --workdir /workspace \
+ archlinux:latest \
+ bash -euo pipefail -c '
+ pacman -Syu --noconfirm \
+ binutils libarchive libglvnd mesa mpv nodejs xorg-server-xvfb \
+ xorg-xauth
+ xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
+ node tools/packaging/verify-linux-frame-copy-runtime.mjs \
+ --artifact /artifact.pacman --profile system
+ '
+
+ - name: Verify AppImage payloads and bundled runtime
+ if: matrix.os == 'linux' && matrix.linux_profile == 'portable'
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ found=false
+ for artifact in dist/executables/*.AppImage; do
+ test -f "${artifact}" || continue
+ found=true
+ xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
+ node tools/packaging/verify-linux-frame-copy-runtime.mjs \
+ --artifact "${artifact}" --profile portable
+ done
+ test "${found}" = true
+
+ - name: Verify Snap payloads and strict-confinement runtime
+ if: matrix.os == 'linux' && matrix.linux_profile == 'portable'
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ found=false
+ installed_x64=false
+ for artifact in dist/executables/*.snap; do
+ test -f "${artifact}" || continue
+ found=true
+ verification="$(
+ xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
+ node tools/packaging/verify-linux-frame-copy-runtime.mjs \
+ --artifact "${artifact}" --profile portable \
+ 2>&1 | tee /dev/stderr
+ )"
+ if printf '%s\n' "${verification}" | grep -Fq 'Verified snap x64 Linux'; then
+ snap list mesa-core22 >/dev/null 2>&1 || sudo snap install mesa-core22
+ snap list gnome-3-28-1804 >/dev/null 2>&1 || sudo snap install gnome-3-28-1804
+ sudo snap install --dangerous "${artifact}"
+ installed_x64=true
+ fi
+ done
+ test "${found}" = true
+ test "${installed_x64}" = true
+ sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22
+ sudo snap connect iptvnator:gnome-3-28-1804 gnome-3-28-1804:gnome-3-28-1804
+ sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22
+ snap connections iptvnator | awk \
+ '$2 == "iptvnator:graphics-core22" && $3 == "-" { found=1 } END { exit !found }'
+ set +e
+ disconnected_probe="$(
+ xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
+ snap run iptvnator --embedded-mpv-runtime-probe 2>&1
+ )"
+ disconnected_status=$?
+ set -e
+ printf '%s\n' "${disconnected_probe}"
+ test "${disconnected_status}" -eq 1
+ printf '%s\n' "${disconnected_probe}" | \
+ grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}'
+ sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22
+ snap connections iptvnator | awk \
+ '$2 == "iptvnator:graphics-core22" && $3 == "mesa-core22:graphics-core22" { found=1 } END { exit !found }'
+ snap connections iptvnator | awk \
+ '$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804" { found=1 } END { exit !found }'
+ snap connections iptvnator | awk \
+ '$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory" { found=1 } END { exit !found }'
+ xvfb-run -a env \
+ LIBGL_ALWAYS_SOFTWARE=1 \
+ IPTVNATOR_TRACE_PLAYER=1 \
+ EGL_LOG_LEVEL=debug \
+ LIBGL_DEBUG=verbose \
+ __EGL_VENDOR_LIBRARY_FILENAMES=/tmp/hostile-egl-vendor.json \
+ GBM_BACKEND=/tmp/hostile-gbm \
+ MESA_LOADER_DRIVER_OVERRIDE=/tmp/hostile-dri \
+ LIBVA_DRIVER_NAME=/tmp/hostile-va \
+ VDPAU_DRIVER_PATH=/tmp/hostile-vdpau \
+ VK_DRIVER_FILES=/tmp/hostile-vulkan-driver.json \
+ VK_ICD_FILENAMES=/tmp/hostile-vulkan-icd.json \
+ VK_ADD_DRIVER_FILES=/tmp/hostile-vulkan-add-driver.json \
+ VK_ADD_LAYER_PATH=/tmp/hostile-vulkan-layers \
+ VK_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-implicit-layers \
+ VK_ADD_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-add-implicit-layers \
+ XDG_CONFIG_HOME=/tmp/hostile-xdg-config-home \
+ XDG_CONFIG_DIRS=/tmp/hostile-xdg-config-dirs \
+ XDG_DATA_HOME=/tmp/hostile-xdg-data-home \
+ XDG_DATA_DIRS=/tmp/hostile-xdg-data-dirs \
+ snap run iptvnator --embedded-mpv-runtime-probe
+
+ - name: Run packaged x64 frame-copy and fallback smoke
+ if: matrix.os == 'linux' && matrix.linux_profile == 'portable'
+ env:
+ IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1'
+ IPTVNATOR_E2E_PACKAGED_EXECUTABLE: ${{ github.workspace }}/dist/executables/linux-unpacked/iptvnator
+ LIBGL_ALWAYS_SOFTWARE: '1'
+ run: |
+ xvfb-run -a pnpm nx run \
+ electron-backend-e2e:packaged-frame-copy-smoke \
+ --skip-nx-cache
+
+ - name: Diagnose packaged x64 frame-copy hardware path
+ if: matrix.os == 'linux' && matrix.linux_profile == 'portable'
+ continue-on-error: true
+ env:
+ IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1'
+ IPTVNATOR_E2E_PACKAGED_EXECUTABLE: ${{ github.workspace }}/dist/executables/linux-unpacked/iptvnator
+ run: |
+ set -euo pipefail
+
+ if [ ! -e /dev/dri/renderD128 ]; then
+ echo "::notice::No /dev/dri/renderD128 is available; skipping the non-blocking hardware-path diagnostic."
+ exit 0
+ fi
+ ls -la /dev/dri
+ xvfb-run -a pnpm nx run \
+ electron-backend-e2e:packaged-frame-copy-smoke \
+ --skip-nx-cache
+
- name: Save embedded MPV runtime cache
# TEMPORARY ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
@@ -566,7 +1127,7 @@ jobs:
vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/runtime-manifest.json
key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }}
- - name: Smoke test packaged Flatpak launcher
+ - name: Verify Flatpak payload, launcher, and sandboxed runtime
if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak'
shell: bash
run: |
@@ -578,8 +1139,12 @@ jobs:
exit 1
fi
+ xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
+ node tools/packaging/verify-linux-frame-copy-runtime.mjs \
+ --artifact "${FLATPAK_BUNDLE}" --profile flatpak
flatpak install --user --noninteractive -y "${FLATPAK_BUNDLE}"
- flatpak run --command=sh com.fourgray.iptvnator -c '
+ xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
+ flatpak run --command=sh com.fourgray.iptvnator -c '
set -euo pipefail
test -f /app/share/metainfo/com.fourgray.iptvnator.metainfo.xml
@@ -587,14 +1152,42 @@ jobs:
LAUNCHER_PATH="$(readlink -f /app/bin/iptvnator)"
test -f "${LAUNCHER_PATH}"
- test -f "${LAUNCHER_PATH}.bin"
- grep -q '\''readlink -f "$SCRIPT_PATH"'\'' "${LAUNCHER_PATH}"
- grep -q '\''exec "$SCRIPT_DIR/iptvnator.bin"'\'' "${LAUNCHER_PATH}"
+ test -x "${LAUNCHER_PATH}"
+ if [ -e "${LAUNCHER_PATH}.bin" ] || [ -L "${LAUNCHER_PATH}.bin" ]; then
+ echo "::error::Flatpak must not contain ${LAUNCHER_PATH}.bin"
+ exit 1
+ fi
+ ELF_MAGIC="$(od -An -tx1 -N4 "${LAUNCHER_PATH}" | tr -d "[:space:]")"
+ test "${ELF_MAGIC}" = "7f454c46"
'
+ set +e
+ PROBE_OUTPUT="$(
+ xvfb-run -a dbus-run-session -- flatpak run \
+ --env=LIBGL_ALWAYS_SOFTWARE=1 \
+ com.fourgray.iptvnator \
+ --embedded-mpv-runtime-probe 2>&1
+ )"
+ PROBE_STATUS=$?
+ set -e
+
+ PROBE_OUTPUT_LIMIT=16384
+ printf '%s\n' "${PROBE_OUTPUT:0:PROBE_OUTPUT_LIMIT}"
+ if [ "${#PROBE_OUTPUT}" -gt "${PROBE_OUTPUT_LIMIT}" ]; then
+ echo "::warning::Flatpak runtime probe output was truncated to ${PROBE_OUTPUT_LIMIT} characters."
+ fi
+ if [[ "${PROBE_OUTPUT}" == *"not an ELF file"* ]] ||
+ [[ "${PROBE_OUTPUT}" == *"Zypak needs to be called directly"* ]]; then
+ echo "::error::Flatpak launched a wrapper instead of the Electron ELF."
+ exit 1
+ fi
+ if [ "${PROBE_STATUS}" -ne 0 ]; then
+ echo "::error::Flatpak application runtime probe failed with status ${PROBE_STATUS}."
+ exit "${PROBE_STATUS}"
+ fi
- name: Upload artifacts (macOS)
if: matrix.os == 'macos'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: macos-${{ matrix.arch }}-artifacts
path: |
@@ -604,25 +1197,33 @@ jobs:
dist/executables/**/*.blockmap
retention-days: 7
- - name: Upload artifacts (Linux)
- if: matrix.os == 'linux' && matrix.linux_profile == 'standard'
- uses: actions/upload-artifact@v4
+ - name: Upload system-runtime Linux artifacts
+ if: matrix.os == 'linux' && matrix.linux_profile == 'system'
+ uses: actions/upload-artifact@v7
with:
- name: linux-artifacts
+ name: linux-system-artifacts
path: |
- dist/executables/**/*.deb
- dist/executables/**/*.rpm
- dist/executables/**/*.snap
- dist/executables/**/*.AppImage
- dist/executables/**/*.tar.gz
- dist/executables/**/*.pacman
+ dist/executables/*.deb
+ dist/executables/*.rpm
+ dist/executables/*.pacman
+ dist/executables/*.pkg.tar.*
+ retention-days: 7
+
+ - name: Upload portable-runtime Linux artifacts
+ if: matrix.os == 'linux' && matrix.linux_profile == 'portable'
+ uses: actions/upload-artifact@v7
+ with:
+ name: linux-portable-artifacts
+ path: |
+ dist/executables/*.AppImage
+ dist/executables/*.snap
dist/executables/**/latest-linux*.yml
dist/executables/**/*.blockmap
retention-days: 7
- - name: Upload artifacts (Flatpak)
+ - name: Upload Flatpak-runtime Linux artifacts
if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: linux-flatpak-artifacts
path: |
@@ -631,7 +1232,7 @@ jobs:
- name: Upload artifacts (Windows)
if: matrix.os == 'windows'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: windows-artifacts
path: |
@@ -642,20 +1243,61 @@ jobs:
dist/executables/**/*.blockmap
retention-days: 7
+ build-linux:
+ name: Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }})
+ needs: linux-embedded-mpv-runtime
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 120
+ concurrency:
+ group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.linux_profile }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: linux
+ runner: ubuntu-22.04
+ arch: x64
+ linux_profile: system
+ embedded_mpv_platform: linux
+ embedded_mpv_arch: x64
+ embedded_mpv_build_runtime: false
+ - os: linux
+ runner: ubuntu-22.04
+ arch: x64
+ linux_profile: portable
+ embedded_mpv_platform: linux
+ embedded_mpv_arch: x64
+ embedded_mpv_build_runtime: false
+ - os: linux
+ runner: ubuntu-24.04
+ arch: x64
+ linux_profile: flatpak
+ embedded_mpv_platform: linux
+ embedded_mpv_arch: x64
+ embedded_mpv_build_runtime: false
+
+ steps: *electron-build-steps
+
create-release:
name: Create Draft Release
- needs: build
+ needs:
+ - build-cross-platform
+ - build-linux
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-latest
+ concurrency:
+ group: ${{ github.workflow }}-release-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: false
permissions:
contents: write
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Download all artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
path: artifacts
@@ -777,15 +1419,127 @@ jobs:
- name: Get version from package.json
id: package-version
- run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
+ run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
+
+ # Test drafts (PR/master) get a self-describing title plus a context
+ # header linking the PR, real head commit, and workflow run. A stable
+ # tag per PR (test-pr-) / branch (test-master) makes the action
+ # update one rolling draft in place instead of piling up a new draft
+ # for every push. PR builds must not use github.sha here: that is the
+ # ephemeral merge-commit SHA, which resolves to nothing in the repo.
+ - name: Compose release metadata
+ id: release-meta
+ shell: bash
+ env:
+ VERSION: ${{ steps.package-version.outputs.version }}
+ EVENT_NAME: ${{ github.event_name }}
+ IS_TAG_BUILD: ${{ startsWith(github.ref, 'refs/tags/') }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ PR_URL: ${{ github.event.pull_request.html_url }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
+ REPO_URL: ${{ github.server_url }}/${{ github.repository }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ run: |
+ set -euo pipefail
+
+ SHORT_SHA="${HEAD_SHA:0:7}"
+ SAFE_BRANCH="${SOURCE_BRANCH//\//-}"
+
+ if [ "${IS_TAG_BUILD}" = "true" ]; then
+ NAME="Release v${VERSION}"
+ TAG="${GITHUB_REF_NAME}"
+ # Authored release notes: the release flow writes this
+ # CHANGELOG section from .changes/*.md before tagging
+ # (see .changes/README.md), so at tag time the changelog
+ # is the authored source of truth. The extractor exits
+ # non-zero when the section is missing, failing the
+ # release rather than silently shipping PR-title-only
+ # notes. generate_release_notes stays on below, so the
+ # GitHub commit list still renders under this body.
+ BODY="$(node tools/release/extract-changelog-section.mjs "${VERSION}")"
+ elif [ "${EVENT_NAME}" = "pull_request" ]; then
+ NAME="v${VERSION} — PR #${PR_NUMBER} @ ${SHORT_SHA} [test]"
+ TAG="test-pr-${PR_NUMBER}"
+ BODY="$(printf '🧪 Test build for PR [#%s](%s) — %s\n\nCommit [`%s`](%s/commit/%s) · branch `%s` · [workflow run](%s)' \
+ "${PR_NUMBER}" "${PR_URL}" "${PR_TITLE}" \
+ "${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${SOURCE_BRANCH}" "${RUN_URL}")"
+ else
+ NAME="v${VERSION} — ${SAFE_BRANCH} @ ${SHORT_SHA} [test]"
+ TAG="test-${SAFE_BRANCH}"
+ BODY="$(printf '🧪 Test build from `%s` — commit [`%s`](%s/commit/%s) · [workflow run](%s)' \
+ "${SOURCE_BRANCH}" "${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${RUN_URL}")"
+ fi
+
+ {
+ echo "name=${NAME}"
+ echo "tag=${TAG}"
+ echo "commitish=${HEAD_SHA}"
+ } >> "${GITHUB_OUTPUT}"
+
+ if [ -n "${BODY}" ]; then
+ {
+ echo "body<> "${GITHUB_OUTPUT}"
+ else
+ echo "body=" >> "${GITHUB_OUTPUT}"
+ fi
+
+ # A PR can be closed while this workflow is still running; the
+ # cleanup workflow deletes the PR draft on close. Re-check the live
+ # PR state right before touching the draft so a late-finishing run
+ # cannot recreate a draft for a closed PR.
+ - name: Check PR is still open
+ if: github.event_name == 'pull_request'
+ id: pr-state
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ echo "state=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}" --jq '.state')" >> "${GITHUB_OUTPUT}"
+
+ # The rolling draft keeps assets across runs and the release action
+ # only replaces same-name files. If the app version changes between
+ # pushes, old-version installers would linger beside the new set,
+ # so drop every existing asset first — the action re-uploads the
+ # full current set right after. Only drafts are pruned; published
+ # releases are never touched.
+ - name: Prune stale draft assets
+ if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: ${{ steps.release-meta.outputs.tag }}
+ run: |
+ set -euo pipefail
+
+ release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate |
+ jq -s --arg tag "${RELEASE_TAG}" \
+ 'add | [.[] | select(.draft and .tag_name == $tag)][0].id // empty')"
+
+ if [ -z "${release_id}" ]; then
+ echo "No existing draft for ${RELEASE_TAG}; nothing to prune."
+ exit 0
+ fi
+
+ gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" --paginate --jq '.[].id' |
+ xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/{}"
+
+ echo "Pruned assets from draft ${release_id} (${RELEASE_TAG})."
- name: Create Draft Release
+ id: draft-release
+ if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
uses: softprops/action-gh-release@v2
with:
draft: true
- prerelease: ${{ github.event_name == 'pull_request' }}
- name: Release v${{ steps.package-version.outputs.version }}
- tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('test-{0}', github.sha) }}
+ prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
+ name: ${{ steps.release-meta.outputs.name }}
+ tag_name: ${{ steps.release-meta.outputs.tag }}
+ target_commitish: ${{ steps.release-meta.outputs.commitish }}
+ body: ${{ steps.release-meta.outputs.body }}
generate_release_notes: true
files: |
artifacts/macos-x64-artifacts/*-x64.dmg
@@ -795,15 +1549,16 @@ jobs:
artifacts/macos-arm64-artifacts/*-arm64.zip
artifacts/macos-arm64-artifacts/*.blockmap
artifacts/latest-mac.yml
- artifacts/linux-artifacts/*.AppImage
- artifacts/linux-artifacts/*.deb
- artifacts/linux-artifacts/*.rpm
- artifacts/linux-artifacts/*.snap
- artifacts/linux-artifacts/*.tar.gz
- artifacts/linux-artifacts/*.pacman
- artifacts/linux-artifacts/latest-linux*.yml
- artifacts/linux-artifacts/*.blockmap
+ artifacts/linux-system-artifacts/*.deb
+ artifacts/linux-system-artifacts/*.rpm
+ artifacts/linux-system-artifacts/*.pacman
+ artifacts/linux-system-artifacts/*.pkg.tar.*
+ artifacts/linux-portable-artifacts/*.AppImage
+ artifacts/linux-portable-artifacts/*.snap
+ artifacts/linux-portable-artifacts/latest-linux*.yml
+ artifacts/linux-portable-artifacts/*.blockmap
artifacts/linux-flatpak-artifacts/*.flatpak
+ artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz
artifacts/windows-artifacts/*-setup.exe
artifacts/windows-artifacts/*.msi
artifacts/windows-artifacts/*.zip
@@ -812,30 +1567,57 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- publish-snap:
- name: Publish to Snapcraft Store
- needs: build
- runs-on: ubuntu-latest
- if: startsWith(github.ref, 'refs/tags/v')
- env:
- SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
-
- steps:
- - name: Download snap artifact
- uses: actions/download-artifact@v4
- with:
- name: linux-artifacts
- path: artifacts
-
- - name: Setup Snapcraft
- uses: samuelmeuli/action-snapcraft@v3
-
- - name: Publish all snaps to edge channel
+ # Rare action-gh-release path: when the release listing transiently
+ # misses the rolling draft, the action creates a duplicate, deletes
+ # it in favor of the canonical (oldest) draft, and uploads assets
+ # there WITHOUT refreshing that draft's metadata. Rebuild the full
+ # metadata (title, commitish, and body = context header + notes
+ # from the same generate-notes API the action uses) on the release
+ # id the action actually used, so the draft ends up correct no
+ # matter which internal path ran. If notes generation fails, the
+ # body is left as the action set it and only title/commitish are
+ # re-asserted.
+ - name: Ensure draft metadata is current
+ if: steps.draft-release.outputs.id != ''
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_ID: ${{ steps.draft-release.outputs.id }}
+ RELEASE_TAG: ${{ steps.release-meta.outputs.tag }}
+ RELEASE_NAME: ${{ steps.release-meta.outputs.name }}
+ RELEASE_COMMITISH: ${{ steps.release-meta.outputs.commitish }}
+ RELEASE_BODY: ${{ steps.release-meta.outputs.body }}
run: |
- # Find and publish all snap files
- for SNAP_FILE in artifacts/*.snap; do
- if [ -f "$SNAP_FILE" ]; then
- echo "Publishing: $SNAP_FILE"
- snapcraft upload --release=edge "$SNAP_FILE"
- fi
- done
+ set -euo pipefail
+
+ GENERATED_NOTES="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
+ -f tag_name="${RELEASE_TAG}" \
+ -f target_commitish="${RELEASE_COMMITISH}" \
+ --jq '.body' || true)"
+
+ # tag_name MUST be included in every PATCH: updating a draft
+ # without it makes GitHub drop the pending tag (the draft
+ # becomes "untagged-"), which breaks the rolling-draft
+ # lookup on the next run.
+ if [ -z "${GENERATED_NOTES}" ]; then
+ jq -n \
+ --arg tag "${RELEASE_TAG}" \
+ --arg name "${RELEASE_NAME}" \
+ --arg commitish "${RELEASE_COMMITISH}" \
+ '{tag_name: $tag, name: $name, target_commitish: $commitish}' |
+ gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null
+ exit 0
+ fi
+
+ if [ -n "${RELEASE_BODY}" ]; then
+ FULL_BODY="$(printf '%s\n\n%s' "${RELEASE_BODY}" "${GENERATED_NOTES}")"
+ else
+ FULL_BODY="${GENERATED_NOTES}"
+ fi
+
+ jq -n \
+ --arg tag "${RELEASE_TAG}" \
+ --arg name "${RELEASE_NAME}" \
+ --arg commitish "${RELEASE_COMMITISH}" \
+ --arg body "${FULL_BODY}" \
+ '{tag_name: $tag, name: $name, target_commitish: $commitish, body: ($body | .[0:120000])}' |
+ gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bf4da6b24..14f32433b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,7 +9,82 @@ on:
- master
workflow_dispatch:
+# Superseded PR pushes cancel their still-running checks. Non-PR runs get a
+# unique group (run_id) because GitHub keeps at most one pending run per
+# group even with cancel-in-progress: false — a shared ref group would let a
+# rapid master push silently replace a queued sibling and leave a merged
+# commit without a lint/test record.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+ contents: read
+
jobs:
+ actionlint:
+ name: Workflow lint
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+
+ # Image pinned by digest (tag 1.7.12). False positives are
+ # suppressed in .github/actionlint.yaml; shellcheck runs at
+ # warning+ severity so style/info notes in long release scripts
+ # don't fail CI while real quoting/logic bugs still do.
+ - name: Run actionlint
+ uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667
+ with:
+ args: -color
+ env:
+ SHELLCHECK_OPTS: --severity=warning
+
+ release-note-gate:
+ name: Release note gate
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ pull-requests: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+
+ # The gate scripts are dependency-free Node, so this job skips
+ # pnpm install entirely and stays cheap.
+ - name: Validate release note format
+ run: node tools/release/build-release-notes.mjs --validate
+
+ # Labels are fetched live rather than read from the (stale) event
+ # payload, so applying `no-release-note` and re-running the check
+ # works without a new push. Policy lives in a unit-tested script,
+ # not in workflow bash.
+ - name: Require a release note for user-visible changes
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ set -euo pipefail
+
+ gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \
+ --paginate --jq '[.[] | {filename, status}]' |
+ jq -s 'add // []' > /tmp/pr-files.json
+
+ gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/labels?per_page=100" \
+ --paginate --jq '[.[].name]' |
+ jq -s 'add // []' > /tmp/pr-labels.json
+
+ jq -n \
+ --slurpfile files /tmp/pr-files.json \
+ --slurpfile labels /tmp/pr-labels.json \
+ '{files: $files[0], labels: $labels[0]}' |
+ node tools/release/check-release-note-gate.mjs
+
lint:
name: Lint
runs-on: ubuntu-latest
@@ -17,7 +92,10 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
+ with:
+ # nx affected needs the merge-base with the PR target branch.
+ fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -31,7 +109,18 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- - name: Lint all projects
+ # PRs lint only affected projects for faster feedback; root config
+ # or lockfile changes make every project affected, so the
+ # module-boundary and max-lines rules cannot be dodged this way.
+ - name: Lint affected projects (PR)
+ if: github.event_name == 'pull_request'
+ run: pnpm nx affected --target=lint --base=origin/${{ github.base_ref }} --head=HEAD --parallel=3 --output-style=static
+ env:
+ CI: true
+ NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
+
+ - name: Lint all projects (master)
+ if: github.event_name != 'pull_request'
run: pnpm nx run-many --target=lint --all --parallel=3 --output-style=static
env:
CI: true
@@ -44,7 +133,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -78,7 +167,7 @@ jobs:
- name: Upload unit coverage artifact
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: unit-coverage
path: |
@@ -87,7 +176,7 @@ jobs:
- name: Upload unit coverage to Codecov
if: always()
- uses: codecov/codecov-action@v6
+ uses: codecov/codecov-action@v7
with:
files: ./coverage/merged/lcov.info,./coverage/merged/cobertura-coverage.xml
flags: unit
diff --git a/.github/workflows/cleanup-pr-draft.yml b/.github/workflows/cleanup-pr-draft.yml
new file mode 100644
index 000000000..695685255
--- /dev/null
+++ b/.github/workflows/cleanup-pr-draft.yml
@@ -0,0 +1,92 @@
+name: Cleanup PR Draft Release
+
+on:
+ pull_request:
+ types: [closed]
+
+# contents: write — delete the draft release; actions: write — cancel the
+# closed PR's still-running build workflow before deleting.
+permissions:
+ actions: write
+ contents: write
+
+jobs:
+ delete-draft:
+ name: Delete PR draft release
+ # Fork PRs never get a draft (the release job skips them) and their
+ # GITHUB_TOKEN is read-only regardless of the permissions block, so
+ # there is nothing to cancel or delete.
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: ubuntu-latest
+ steps:
+ # A closed PR can be reopened while this job is still queued or
+ # waiting; a reopened PR's fresh build must not be cancelled and
+ # its draft must not be deleted. Check the live state up front
+ # (and again right before deleting below).
+ - name: Check PR is still closed
+ id: pr-state
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ set -euo pipefail
+ echo "state=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.state')" >> "${GITHUB_OUTPUT}"
+
+ # A build for this PR may still be running and would recreate the
+ # rolling draft after we delete it. Cancel those runs (dead work
+ # for a closed PR anyway) and wait for them to wind down. The
+ # release job additionally re-checks the live PR state, so this
+ # wait is defense in depth, not the only guard.
+ - name: Cancel in-progress builds for the closed PR
+ if: steps.pr-state.outputs.state == 'closed'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
+ run: |
+ set -euo pipefail
+
+ # --event pull_request: a manually dispatched build on the
+ # same branch is not this PR's work and must not be cancelled.
+ list_active_runs() {
+ gh run list --repo "${GITHUB_REPOSITORY}" \
+ --workflow 'Build and Make Electron App' \
+ --branch "${HEAD_BRANCH}" \
+ --event pull_request \
+ --json databaseId,status \
+ --jq '.[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "requested" or .status == "pending") | .databaseId'
+ }
+
+ for run_id in $(list_active_runs); do
+ echo "Cancelling run ${run_id}"
+ gh run cancel "${run_id}" --repo "${GITHUB_REPOSITORY}" || true
+ done
+
+ # Cancellation is asynchronous; poll until the runs settle.
+ for _ in $(seq 1 18); do
+ if [ -z "$(list_active_runs)" ]; then
+ break
+ fi
+ sleep 10
+ done
+
+ # Draft releases have no real git tag, so a lookup via
+ # releases/tags/ returns 404. List releases and match the
+ # draft by its stored tag_name (test-pr-) instead. The PR state
+ # is re-checked one last time right before deleting, in case the
+ # PR was reopened during the cancellation wait above.
+ - name: Delete draft release for closed PR
+ if: steps.pr-state.outputs.state == 'closed'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ set -euo pipefail
+
+ if [ "$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.state')" != "closed" ]; then
+ echo "PR #${PR_NUMBER} was reopened; keeping its draft."
+ exit 0
+ fi
+
+ gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \
+ --jq ".[] | select(.draft and .tag_name == \"test-pr-${PR_NUMBER}\") | .id" |
+ xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/{}"
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 3359f2dda..dfe88308a 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -14,6 +14,14 @@ on:
schedule:
- cron: '0 20 * * 3'
+# Required so `codeql-action/analyze` can upload its SARIF results. Without an
+# explicit grant the default token is read-only and the upload fails with
+# "Resource not accessible by integration".
+permissions:
+ actions: read
+ contents: read
+ security-events: write
+
jobs:
analyze:
name: Analyze
@@ -30,42 +38,19 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v3
- with:
- # We must fetch at least the immediate parents so that if this is
- # a pull request then we can checkout the head.
- fetch-depth: 2
+ uses: actions/checkout@v7
- # If this run was triggered by a pull request event, then checkout
- # the head of the pull request instead of the merge commit.
- - run: git checkout HEAD^2
- if: ${{ github.event_name == 'pull_request' }}
-
- # Initializes the CodeQL tools for scanning.
+ # Initializes the CodeQL tools for scanning. PR runs analyze the merge
+ # commit checked out above (the modern default); JavaScript is
+ # interpreted, so no build step is needed before analysis.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
- # By default, queries listed here will override any specified in a config file.
+ # By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
- # If this step fails, then you should remove it and run the build manually (see below)
- - name: Autobuild
- uses: github/codeql-action/autobuild@v3
-
- # ℹ️ Command-line programs to run using the OS shell.
- # 📚 https://git.io/JvXDl
-
- # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
- # and modify them (or add more) to build your code if your project
- # uses a compiled language
-
- #- run: |
- # make bootstrap
- # make release
-
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml
index b7c0b8bac..aa235d701 100644
--- a/.github/workflows/deploy-website.yml
+++ b/.github/workflows/deploy-website.yml
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -42,7 +42,7 @@ jobs:
run: pnpm nx build website
- name: Upload Pages artifact
- uses: actions/upload-pages-artifact@v3
+ uses: actions/upload-pages-artifact@v5
with:
path: dist/apps/website
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index ed059fefc..dff445563 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -26,6 +26,14 @@ on:
permissions:
contents: read
+# Superseded PR pushes cancel their still-running image build. Master/tag/
+# manual runs get a unique group (run_id): GitHub keeps at most one pending
+# run per group even with cancel-in-progress: false, so a shared ref group
+# could silently drop a queued publish between two rapid master pushes.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
jobs:
build:
name: Build Docker image
@@ -34,7 +42,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Prepare Docker metadata
id: docker-meta
@@ -109,5 +117,7 @@ jobs:
push: ${{ steps.docker-meta.outputs.publish == 'true' }}
tags: ${{ steps.docker-meta.outputs.tags }}
platforms: ${{ steps.docker-meta.outputs.platforms }}
+ build-args: |
+ BUILD_COMMIT=${{ github.event.pull_request.head.sha || github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max,ignore-error=true
diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml
index fe56b10ce..abd9d577a 100644
--- a/.github/workflows/e2e-tests.yaml
+++ b/.github/workflows/e2e-tests.yaml
@@ -4,9 +4,36 @@ on:
push:
branches:
- master
+ paths-ignore:
+ - '**/*.md'
+ - 'docs/**'
+ - '.plans/**'
+ - '.codex/**'
+ - '.claude/**'
+ - 'apps/website/**'
pull_request:
branches:
- master
+ paths-ignore:
+ - '**/*.md'
+ - 'docs/**'
+ - '.plans/**'
+ - '.codex/**'
+ - '.claude/**'
+ - 'apps/website/**'
+ workflow_dispatch:
+
+# Superseded PR pushes cancel their still-running E2E matrix (the most
+# expensive per-PR runner time). Non-PR runs get a unique group (run_id):
+# GitHub keeps at most one pending run per group even with
+# cancel-in-progress: false, so a shared ref group could silently drop a
+# queued master run between two rapid pushes.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+ contents: read
jobs:
electron-e2e-tests:
@@ -22,7 +49,7 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -60,7 +87,7 @@ jobs:
- name: Upload Electron Test Results
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: playwright-report-electron-${{ matrix.os }}
path: |
@@ -76,7 +103,7 @@ jobs:
NX_SKIP_NX_CACHE: true
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -104,7 +131,7 @@ jobs:
- name: Upload Web Test Results
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: playwright-report-web-ubuntu
path: |
diff --git a/.github/workflows/publish-snap.yaml b/.github/workflows/publish-snap.yaml
new file mode 100644
index 000000000..d74213ec2
--- /dev/null
+++ b/.github/workflows/publish-snap.yaml
@@ -0,0 +1,269 @@
+name: Publish Snap after public release
+
+on:
+ release:
+ types:
+ - published
+
+permissions:
+ contents: read
+
+jobs:
+ verify-snap:
+ name: Verify public-release Snap assets
+ if: ${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ env:
+ SOURCE_ARCHIVE_NAME: linux-frame-copy-runtime-sources.tar.xz
+ outputs:
+ receipt-sha256: ${{ steps.bind-transfer.outputs.receipt-sha256 }}
+
+ steps:
+ - name: Checkout released tooling
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ ref: ${{ github.event.release.tag_name }}
+ persist-credentials: false
+
+ - name: Install release source verifier
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ sudo apt-get update
+ sudo apt-get install --no-install-recommends -y \
+ binutils \
+ squashfs-tools \
+ xz-utils
+
+ - name: Select exact public release assets
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+
+ gh api \
+ --paginate \
+ --slurp \
+ "repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets?per_page=100" \
+ > "${RUNNER_TEMP}/snap-release-assets.json"
+ node tools/packaging/release-snap-assets.cjs select \
+ --assets-json "${RUNNER_TEMP}/snap-release-assets.json" \
+ --output-json "${RUNNER_TEMP}/selected-snap-release-assets.json"
+
+ - name: Download exact public release assets
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+
+ ASSET_DIRECTORY="${RUNNER_TEMP}/snap-release-downloads"
+ rm -rf "${ASSET_DIRECTORY}"
+ mkdir -p "${ASSET_DIRECTORY}"
+ node -e \
+ "const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of [...selected.snapAssets, selected.sourceAsset]) console.log([asset.id, asset.name].join('\\t'));" \
+ "${RUNNER_TEMP}/selected-snap-release-assets.json" |
+ while IFS=$'\t' read -r ASSET_ID ASSET_NAME; do
+ gh api \
+ --header "Accept: application/octet-stream" \
+ "repos/${GITHUB_REPOSITORY}/releases/assets/${ASSET_ID}" \
+ > "${ASSET_DIRECTORY}/${ASSET_NAME}"
+ done
+
+ - name: Verify downloaded public release assets
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ VERIFIED_ASSET_STAGING="${RUNNER_TEMP}/verified-snap-release-assets"
+ SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release"
+ SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets"
+ test -s "${RUNNER_TEMP}/snap-release-downloads/${SOURCE_ARCHIVE_NAME}"
+ test ! -e "${VERIFIED_ASSET_STAGING}"
+ sudo test ! -e "${SEALED_ASSET_PARENT}"
+ node tools/packaging/release-snap-assets.cjs verify \
+ --manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \
+ --directory "${RUNNER_TEMP}/snap-release-downloads" \
+ --repository-revision "$(git rev-parse HEAD)" \
+ --verified-directory "${VERIFIED_ASSET_STAGING}"
+ sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}"
+ sudo mv "${VERIFIED_ASSET_STAGING}" "${SEALED_ASSET_DIRECTORY}"
+ sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}"
+ sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} +
+ sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +
+ sudo chmod 0555 "${SEALED_ASSET_PARENT}"
+
+ - name: Reverify sealed public release assets
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"
+ node tools/packaging/release-snap-assets.cjs verify-sealed \
+ --manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \
+ --directory "${VERIFIED_ASSET_DIRECTORY}" \
+ --receipt "${VERIFIED_ASSET_DIRECTORY}/verified-release-assets.json" \
+ --repository-revision "$(git rev-parse HEAD)"
+
+ - name: Bind verified release transfer
+ id: bind-transfer
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ RECEIPT_PATH="/var/lib/iptvnator-snap-release/assets/verified-release-assets.json"
+ RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"
+ RECEIPT_SHA256="${RECEIPT_RECORD%% *}"
+ [[ "${RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]
+ printf 'receipt-sha256=%s\n' "${RECEIPT_SHA256}" >> "${GITHUB_OUTPUT}"
+
+ - name: Transfer verified release assets
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: verified-snap-release-assets
+ path: /var/lib/iptvnator-snap-release/assets
+ if-no-files-found: error
+ retention-days: 1
+ compression-level: 0
+ include-hidden-files: true
+
+ publish-snap:
+ name: Publish verified public-release Snap to edge
+ needs: verify-snap
+ if: ${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ steps:
+ - name: Download verified release assets
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
+ with:
+ name: verified-snap-release-assets
+ path: ${{ runner.temp }}/verified-snap-release-assets
+
+ - name: Seal transferred public release assets
+ shell: bash
+ env:
+ EXPECTED_RECEIPT_SHA256: ${{ needs.verify-snap.outputs.receipt-sha256 }}
+ run: |
+ set -euo pipefail
+
+ TRANSFERRED_ASSET_DIRECTORY="${RUNNER_TEMP}/verified-snap-release-assets"
+ SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release"
+ SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets"
+ test -d "${TRANSFERRED_ASSET_DIRECTORY}"
+ test ! -L "${TRANSFERRED_ASSET_DIRECTORY}"
+ shopt -s nullglob dotglob
+ TRANSFERRED_FILES=("${TRANSFERRED_ASSET_DIRECTORY}"/*)
+ TRANSFERRED_SNAPS=("${TRANSFERRED_ASSET_DIRECTORY}"/*.snap)
+ test "${#TRANSFERRED_SNAPS[@]}" -gt 0
+ test "${#TRANSFERRED_FILES[@]}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 2 ))"
+ test -f "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"
+ test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"
+ test -f "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
+ test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
+ for ASSET_FILE in "${TRANSFERRED_FILES[@]}"; do
+ test -f "${ASSET_FILE}"
+ test ! -L "${ASSET_FILE}"
+ done
+ RECEIPT_PATH="${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
+ RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"
+ ACTUAL_RECEIPT_SHA256="${RECEIPT_RECORD%% *}"
+ [[ "${EXPECTED_RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]
+ test "${ACTUAL_RECEIPT_SHA256}" = "${EXPECTED_RECEIPT_SHA256}"
+ /usr/bin/jq --exit-status '
+ type == "object" and
+ (keys == ["assets", "repositoryRevision", "schemaVersion"]) and
+ (.schemaVersion == 1) and
+ (.repositoryRevision |
+ type == "string" and test("^[a-f0-9]{40,64}$")) and
+ (.assets | type == "array" and length >= 2) and
+ (.assets | all(.[];
+ type == "object" and
+ (keys == ["id", "name", "sha256", "size"]) and
+ (.id |
+ type == "number" and . > 0 and
+ . <= 9007199254740991 and . == floor) and
+ (.name |
+ type == "string" and length > 0 and
+ . != "." and . != ".." and
+ (contains("/") | not) and
+ (contains("\\") | not) and
+ (explode | all(.[]; . > 31 and . != 127))) and
+ (.sha256 |
+ type == "string" and test("^[a-f0-9]{64}$")) and
+ (.size |
+ type == "number" and . > 0 and
+ . <= 9007199254740991 and . == floor))) and
+ ([.assets[].name] | length == (unique | length)) and
+ ([.assets[] |
+ select(.name == "linux-frame-copy-runtime-sources.tar.xz")] |
+ length == 1) and
+ ([.assets[] | select(.name | endswith(".snap"))] |
+ length >= 1) and
+ (.assets | all(.[];
+ .name == "linux-frame-copy-runtime-sources.tar.xz" or
+ (.name | endswith(".snap"))))
+ ' "${RECEIPT_PATH}" > /dev/null
+ RECEIPT_ASSET_COUNT="$(/usr/bin/jq --raw-output '.assets | length' "${RECEIPT_PATH}")"
+ test "${RECEIPT_ASSET_COUNT}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 1 ))"
+ SIZE_MANIFEST="${RUNNER_TEMP}/verified-release-asset-sizes.tsv"
+ CHECKSUM_MANIFEST="${RUNNER_TEMP}/verified-release-asset-checksums.txt"
+ umask 077
+ /usr/bin/jq --raw-output \
+ '.assets[] | [.name, (.size | tostring)] | @tsv' \
+ "${RECEIPT_PATH}" > "${SIZE_MANIFEST}"
+ while IFS=$'\t' read -r ASSET_NAME EXPECTED_SIZE; do
+ ASSET_PATH="${TRANSFERRED_ASSET_DIRECTORY}/${ASSET_NAME}"
+ ACTUAL_SIZE="$(/usr/bin/stat --format=%s -- "${ASSET_PATH}")"
+ test "${ACTUAL_SIZE}" = "${EXPECTED_SIZE}"
+ done < "${SIZE_MANIFEST}"
+ /usr/bin/jq --raw-output \
+ '.assets[] | "\(.sha256) \(.name)"' \
+ "${RECEIPT_PATH}" > "${CHECKSUM_MANIFEST}"
+ (
+ cd "${TRANSFERRED_ASSET_DIRECTORY}"
+ /usr/bin/sha256sum --strict --check "${CHECKSUM_MANIFEST}"
+ )
+ rm -f "${SIZE_MANIFEST}" "${CHECKSUM_MANIFEST}"
+ shopt -u nullglob dotglob
+ sudo test ! -e "${SEALED_ASSET_PARENT}"
+ sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}"
+ sudo mv "${TRANSFERRED_ASSET_DIRECTORY}" "${SEALED_ASSET_DIRECTORY}"
+ sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}"
+ sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} +
+ sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +
+ sudo chmod 0555 "${SEALED_ASSET_PARENT}"
+
+ - name: Install Snapcraft
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ sudo snap install snapcraft --classic --channel=stable
+
+ - name: Publish all public-release snaps to edge
+ shell: bash
+ env:
+ SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
+ run: |
+ set -euo pipefail
+
+ VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"
+ STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}"
+ unset SNAPCRAFT_STORE_CREDENTIALS
+ shopt -s nullglob dotglob
+ SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap)
+ test "${#SNAP_FILES[@]}" -gt 0
+ for SNAP_FILE in "${SNAP_FILES[@]}"; do
+ SNAP_NAME="${SNAP_FILE##*/}"
+ echo "Publishing public release asset: ${SNAP_NAME}"
+ # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.
+ # GitHub Actions never promotes automatically.
+ SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"
+ done
+ unset STORE_CREDENTIALS
+ shopt -u nullglob dotglob
diff --git a/.gitignore b/.gitignore
index ea63467e3..13da340a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,7 +72,17 @@ Thumbs.db
.gemini
.cursor
.agent
-.claude
+# Agent config stays local, except the skills the repo owns. Claude Code only
+# discovers skills under .claude/skills/, so the release skills are committed
+# there as well as under .codex/skills/. Personal skills in .claude/skills/
+# remain ignored — each shared skill is opted in by name.
+.claude/*
+!.claude/skills/
+.claude/skills/*
+!.claude/skills/release-notes/
+!.claude/skills/release-notes/**
+!.claude/skills/release-cut/
+!.claude/skills/release-cut/**
.codex/*
!.codex/skills/
!.codex/skills/**
@@ -84,4 +94,9 @@ apps/electron-backend/src/app/options/electron-builder.metadata.generated.json
vendor/embedded-mpv/*/bin/
vendor/embedded-mpv/*/include/
vendor/embedded-mpv/*/lib/
+vendor/embedded-mpv/*/notices/
vendor/embedded-mpv/*/runtime-manifest.json
+
+# MemPalace per-project files (issue #185)
+mempalace.yaml
+entities.json
diff --git a/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md b/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md
new file mode 100644
index 000000000..dd616baea
--- /dev/null
+++ b/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md
@@ -0,0 +1,90 @@
+# Linux Embedded MPV Frame-Copy Packaging Plan
+
+## Audited baseline
+
+- Linux frame-copy already has an isolated `iptvnator_mpv_helper`, a frame
+ reader addon, shared controls, native-view fallback, and runtime capability
+ probes.
+- Existing packaged Linux builds intentionally remove the helper/runtime and
+ retain only system `mpv --wid` native-view.
+- Electron, Electron libraries, `embedded_mpv.node`, and
+ `embedded_mpv_frame_reader.node` must never load or link libmpv. Only the
+ helper may link it.
+- Electron Builder produces AppImage, DEB, RPM, Pacman, Snap, and Flatpak
+ Linux targets. The available reproducible native/runtime toolchain is x64.
+
+## Decisions
+
+1. Support official frame-copy artifacts on Linux x64 only. Keep every non-x64
+ artifact marker-only and fail closed to native-view; never accept an
+ architecture override that injects x64 native files.
+2. Use three isolated packaging profiles:
+ - `system`: DEB/RPM/Pacman use declared distribution libmpv/GL dependencies
+ and contain no private `native/lib`.
+ - `portable`: AppImage/Snap contain a pinned LGPL-compatible shared-library
+ closure with `$ORIGIN`-relative helper loading.
+ - `flatpak`: Flatpak contains the same pinned closure, validated in the
+ exact `/app` runtime context.
+3. Treat the package manifest as necessary but insufficient. Frame-copy is
+ available only after exact manifest/schema/profile checks, executable-mode
+ checks, artifact hashes, dependency-closure/process-isolation checks, and a
+ bounded helper runtime probe. No environment flag bypasses this gate.
+4. Publish exact source archives, recursive source identities, build flags,
+ licenses, notices, patches/tooling, and pinned display data for bundled
+ runtimes. Bind every bundled x64 package manifest to the final compliance
+ archive bytes and released repository revision.
+5. Keep Snap Store credentials isolated from release-tag code on a fresh
+ runner. Store publication is edge-only; candidate/stable promotion remains
+ manual after installed-package smoke.
+
+## TDD implementation phases
+
+1. Add failing tests for target/profile partitioning, x64 and marker-only
+ layouts, exact dependency declarations, RPATH/SONAME rules, executable
+ modes, and Electron/libmpv isolation.
+2. Implement profile-aware build and packaging hooks that stage the helper,
+ frame reader, runtime manifest, private closure where applicable, and legal
+ payload without weakening native-view.
+3. Add failing runtime-policy tests for missing/tampered files, wrong
+ architecture/profile, malformed manifests, loader failures, hostile
+ environments, probe timeout/output bounds, and stable fallback reasons.
+4. Implement one sanitized helper environment shared by probe and playback,
+ including Snap graphics-provider handling and Flatpak runtime paths.
+5. Add failing compliance/release tests for exact recursive submodule records,
+ VCS-free source inventory, archive member/type layout, source checksums,
+ license/notices completeness, package-to-source byte binding, sealed asset
+ receipts, and credential boundaries.
+6. Implement deterministic source generation, package bindings, static Snap
+ inspection, fresh-runner artifact transfer, and minimal direct Store
+ upload.
+7. Add packaged x64 smoke for actual frame-copy playback plus missing-runtime
+ native-view fallback. Run fixture-contract tests before the smoke and allow
+ CI llvmpipe through Chromium's GPU blocklist without bypassing the runtime
+ gate.
+8. Update canonical architecture/maintenance documentation and mirrored
+ `AGENTS.md`/`CLAUDE.md` contracts.
+
+## Acceptance and verification matrix
+
+- Local/macOS:
+ - Nx discovery
+ - packaging and Electron backend unit/integration tests
+ - packaged-smoke fixture tests
+ - affected lint targets
+ - production backend build
+ - formatting, syntax, and `git diff --check`
+- Linux x64 CI:
+ - build the pinned runtime/helper/frame reader
+ - verify helper links/resolves libmpv and Electron/addons do not
+ - extract and statically validate all six package families
+ - run system, portable, Snap-installed, and Flatpak application probes
+ - run packaged frame-copy playback and missing-runtime native-view fallback
+ - regenerate and bind the exact compliance source archive
+- Non-x64 CI:
+ - build selected ARM package targets independently
+ - require marker-only layout and absence of every x64 native/runtime artifact
+- Merge gate:
+ - exact-head CI green
+ - no unresolved review findings
+ - fresh code review clean
+ - no automatic Snap promotion beyond edge
diff --git a/AGENTS.md b/AGENTS.md
index 9937811d5..cdf03a35e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,7 +16,8 @@ This file provides guidance to coding agents working in this repository.
- Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.
- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents.
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
-- Repository-specific skills are committed under `.codex/skills/`. If an external agent does not support skills, treat those files as concise ownership docs.
+- ESLint enforces `max-lines` on TypeScript files (target under 300, hard maximum 400). Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline.
+- Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an external agent does not support skills, treat those files as concise ownership docs.
## Documentation After Changes
@@ -33,6 +34,18 @@ This file provides guidance to coding agents working in this repository.
- Repo docs are canonical even when they were originally drafted by an LLM.
- Final task summaries should state whether docs were updated and which doc changed.
+## Release Notes For User-Visible Changes
+
+- Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`.
+- Name it `-.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.
+- 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.
+- Validate before finishing: `pnpm run release:notes:validate`.
+- 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.
+- Final task summaries should state whether a release note was added or why it was skipped.
+
## Regression Prevention And Test Updates
- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
@@ -70,14 +83,14 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
- - `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output
+ - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
+ - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize 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 phase events, 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
-- GPU/compositor debugging:
-
-```bash
-IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend
-```
+- Settings, portal request/response, and trace payloads must use
+ `@iptvnator/shared/logging` or the redacting portal logger before reaching
+ `console.*`; never log raw credentials while debugging.
- If local Nx state gets weird before a rerun:
@@ -128,6 +141,268 @@ 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
+## Shared Player Controls
+
+- `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
+ `WEB_PLAYER_SHARED_CONTROLS` rollout token.
+- 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 Xtream/Stalker detail views through
+ `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.
+- Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox
+ appears only when HTML5, Video.js, or ArtPlayer is selected.
+ `WebPlayerViewComponent` snapshots the preference into
+ `WEB_PLAYER_SHARED_CONTROLS` 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.
+- `Settings.showCaptions` is deliberately outside this rollout gate: it is
+ engine state, not controls UI. HTML5, Video.js, and ArtPlayer apply it in both
+ modes — shared controls through their controls bridge, the preference-off
+ paths through the same helpers without an adapter (`WebVideoSourceTracks` for
+ HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js). Both re-apply the preference
+ as the engine adds or switches text tracks. `WebPlayerViewComponent` reads it
+ from `SettingsStore` rather than a host input, so the M3U player, the
+ Xtream/Stalker live layouts, and the portal detail inline player all inherit
+ it (#1155).
+- The modes differ in how long the preference is enforced. Shared controls are
+ authoritative for the session; user intent arrives through `setSubtitleTrack`
+ and wins until the source changes. Vendor chrome is source-default: the
+ preference seeds each new source and is released once the media element
+ reports `playing`, so the engine's own caption menu keeps working. The mode is
+ selected by the optional `playbackStarted` probe the legacy owners pass to all
+ three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper
+ deselects the track (`subtitleTrack = -1`) instead of hiding it, because
+ `subtitleDisplay` would silently override whatever the vendor menu picks. For
+ DASH the seed happens in `ShakaVideoSession.start()` after the manifest loads,
+ so the helper only stops re-suppressing afterwards.
+- Embedded MPV ignores the web-player preference. Frame-copy always uses shared
+ DOM controls through its component-scoped `EmbeddedMpvControlsAdapter`, while
+ native-view retains the legacy compositor-safe dock and external MPV/VLC
+ retain their own UI. The host must render exactly one controls system for the
+ reported Embedded MPV engine.
+- Frame-copy shared controls own DOM surface interactions, shortcuts,
+ fullscreen, and recording feedback. `showControls=false` detaches the shared
+ surface, modal overlays gate playback shortcuts, fullscreen still triggers
+ bounds sync, 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 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.
+- 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
+ `Channel.drm`, and the shared bridge exposes Shaka audio/text tracks via
+ source kind `shaka`. See the CLAUDE.md "Video Players" feature entry and
+ `docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
+- The built-in HTML5/hls.js player is the second guarded consumer.
+ `HtmlVideoPlayerComponent` provides a component-scoped
+ `WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared
+ with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction,
+ caption preference, and source cleanup.
+ `HtmlVideoElementSession` owns native video-event lifecycle, persisted
+ volume, start-time/time/ended propagation, and legacy post-play caption
+ suppression.
+ `WebPlayerViewComponent.resolvedIsLive` supplies authoritative live/VOD
+ metadata, while a visible playback diagnostic disables both shared surface
+ interaction and shortcuts and exits the HTML5 shell's own fullscreen so the
+ diagnostic actions remain visible. The preference-off path keeps native
+ controls and legacy series navigation unchanged.
+- Video.js is the third guarded consumer. `VjsPlayerComponent` provides a
+ component-scoped `WebVideoControlsAdapter`; its bridge binds the current Tech
+ video, rebinds after `playerreset`, exposes source-stable audio/subtitle IDs,
+ preserves caption preference and explicit subtitle-off state, and reads
+ duration from Video.js. Reset-driven raw MPEG-TS changes pause first,
+ coalesce to the latest desired source, preserve actual volume across
+ Video.js's reset, and restart when authoritative live/VOD metadata changes.
+ The shared-controls path disables native controls, Video.js
+ click/double-click/hotkey actions, and spatial navigation;
+ diagnostic gating and owned-fullscreen exit match HTML5. The preference-off
+ path keeps the existing Video.js skin and legacy series navigation unchanged.
+- 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
+ a destroyed-session guard for delayed `customType` callbacks, while
+ `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared mode uses
+ authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference,
+ MPEG-TS VOD duration correction, and reapplies app volume directly after
+ ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled,
+ and a transparent capture layer gives shared controls exclusive click and
+ double-click ownership. Diagnostic interaction gating and owned-fullscreen
+ exit match the other web players. The preference-off path keeps the legacy
+ ArtPlayer skin, source behavior, and series navigation unchanged.
+- Shared web picture-in-picture stays inside that default-off 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
+ 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
+ no-op command and has no popup/mini-window.
+- `WebVideoControlsAdapter` supplies its current video and binding generation to
+ `WebVideoPictureInPictureController`; the controller reads the video's
+ `ownerDocument`, while browser enter/leave events remain authoritative.
+ Exact-owner exit stays available if request support changes. Request/exit
+ invocation remains synchronous for user activation, one operation is
+ 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.
+ 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.
+- Canonical docs: `docs/architecture/player-controls-contract.md` and
+ `docs/architecture/embedded-mpv-native.md`
+
+## Linux Embedded MPV Packaging
+
+- Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM,
+ Pacman, Snap, and Flatpak are supported; non-x64 Linux packages must remain
+ marker-only and must never inherit x64 native artifacts from environment
+ overrides.
+- Packaging runs three isolated profiles:
+ - `system`: DEB/RPM/Pacman, no private `native/lib`, with package
+ dependencies DEB=`libmpv2,libegl1,libgl1,libgbm1`,
+ RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and
+ Pacman=`mpv,libglvnd,mesa`
+ - `portable`: AppImage/Snap with the pinned LGPL-compatible closure
+ - `flatpak`: Flatpak with the same pinned closure
+- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real
+ Electron ELF so Electron Builder's `electron-wrapper` passes it directly to
+ Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and
+ `iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation.
+- The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04
+ provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the
+ package dependency or advertising frame-copy without a compatible runtime.
+- Only `iptvnator_mpv_helper` may link libmpv. The Electron executable,
+ Electron libraries, `embedded_mpv.node`, and
+ `embedded_mpv_frame_reader.node` must not load or link it. Preserve this
+ process-isolation contract in build, package, and smoke checks.
+- `electron-backend/native{,/**/*}` is excluded from `app.asar`; `afterPack`
+ exclusively writes the profile-normalized unpacked native tree. Layout and
+ final-artifact checks must reject every archived
+ `/electron-backend/native/**` entry so system and marker-only packages cannot
+ hide stale x64 artifacts.
+- Packaged addon, frame-reader, and helper discovery is package-owned
+ `app.asar.unpacked` only. Writable cwd/dist candidates are development-only
+ and must never satisfy packaged native-view support or the frame-copy gate.
+- Pristine afterPack/unpacked layouts scan Electron libraries recursively.
+ Extracted Snap payloads exclude only the package-manager `lib/**` and
+ `usr/lib/**` trees that Snap overlays into the same root; every other
+ directory remains recursive, and Electron-library symlinks still fail
+ closed.
+- Linux frame-copy availability is fail-closed. The packaged manifest,
+ artifact modes, declared bundled hashes/closure, and bounded
+ `--runtime-probe` must all succeed before frame-copy can relax the renderer
+ sandbox. Any failure reports a stable reason and falls back to native-view
+ without crashing; an environment flag never bypasses this gate.
+- Snap is `core22`/strict and uses an exact private `shared-memory` plug plus
+ the `graphics-core22` content plug at an empty mode-0755 `$SNAP/graphics`,
+ with `mesa-core22` as default provider. It declares only the canonical
+ provider layouts: `/usr/share/libdrm` binds from
+ `$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to
+ `$SNAP/graphics/drirc.d`. The provider is external shared content, not part
+ of IPTVnator's package size, source archive, or notices. Installed-Snap CI
+ must prove controlled unavailable exit after disconnect, then reconnect and
+ prove success. The helper links `libGL.so.1` rather than `libOpenGL.so.0`.
+- The probe and playback helper share one sanitized loader environment:
+ ambient audit, preload, library, graphics-driver, and shell-startup overrides
+ are removed; the validated private closure wins; trusted Snap GL,
+ `graphics-core22`, the core22 base x64 root, and exact GNOME-platform roots
+ precede generic in-snap roots. The core22 base must precede GNOME so its
+ `libedit.so.2` cannot be replaced by the older copy requiring
+ `libtinfo.so.5`. The extracted-artifact verifier removes the identical
+ unsafe loader/graphics/shell set before direct helper smoke while preserving
+ feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the
+ wrapper `PATH`, removes exported `BASH_FUNC_*` functions, and launches
+ probe/playback through the regular executable
+ `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or
+ disconnected provider returns `snap-graphics-provider-unavailable` before
+ helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch
+ runs the complete cached manifest/hash/helper gate before BrowserWindow
+ startup and exits with one availability JSON line. A nonzero helper exit
+ keeps top-level reason `helper-probe-failed`; `helperReason` is present only
+ for an exact protocol-v1 line carrying a fixed allowlisted reason, and its
+ optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid
+ detail suppresses both helper fields. Every probe uses an explicit 16 MiB
+ aggregate captured-output ceiling independent of tracing. With
+ `IPTVNATOR_TRACE_PLAYER=1`, a non-empty helper stderr capture is emitted
+ separately as one JSON-escaped stderr line whose `stderr` field is limited
+ to 16,384 characters and whose `truncated` field is always explicit;
+ trace-write failure cannot change the capability result. Installed-Snap CI
+ enables Mesa EGL/GL diagnostics through this bounded channel. Any loader
+ failure remains a stable native-view fallback, never a flag-enabled success.
+- In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop
+ Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL
+ extension loader path comes from the sandbox cache. Flatpak CI must invoke
+ the application-level `--embedded-mpv-runtime-probe`, not a direct helper
+ probe that bypasses capability detection.
+- The packaged x64 Playwright smoke runs its fixture-contract target first and
+ passes Chromium `--ignore-gpu-blocklist` so CI llvmpipe can expose WebGL2.
+ This launch-only flag does not bypass the manifest, hash, loader, or helper
+ capability gate; `--no-sandbox` remains root-only.
+- Bundled Linux releases must publish the exact source archives/git records,
+ checksums, licenses, flags, patches, build scripts, and the pinned hwdata
+ `pnp.ids` input. Each bundled package carries
+ `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and the exact
+ `licenses/**` files. CI may cache immutable source inputs, but regenerates
+ notices and a VCS-metadata-free
+ `linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every
+ run while retaining the exact pinned six recursive libplacebo submodule
+ records. Each record is canonical `full-commit safe/path`; clone-depth
+ dependent `git describe` annotations are discarded and never form part of
+ the provenance identity. Its source index carries the globally sorted libplacebo
+ directory/file/symlink inventory; file hashes, sizes, executable bits, link
+ targets, aggregates, and canonical tree digest must match the trusted pinned
+ checkout. The archive has an exact member/type layout and its
+ `metadata/archive-sha256.txt` records must match the actual source archives.
+ Concatenated tar/xz streams are inspected past every end marker. The final
+ archive's SHA-256 and repository revision are copied into every bundled x64
+ package manifest; system and marker-only packages carry no source-archive
+ binding.
+ Automated Snap Store publication is allowed only after a public `v*` GitHub
+ release contains both the Snap assets and exactly one matching source
+ archive. Before any upload, the workflow hashes and inspects that archive,
+ verifies its exact member/type set and size bounds, clean tag revision,
+ pinned sources including the six recursive submodule records and exact
+ libplacebo tree digest, legal files, and exact released tooling, then
+ performs bounded extraction and static package validation for every Snap.
+ That public-release boundary independently revalidates the exact strict
+ `meta/snap.yaml` graphics/shared-memory contract and enumerates
+ `resources/app.asar`, rejecting any archived
+ `electron-backend/native/**` payload before publication. Its bounded ASAR
+ header reader uses only Node built-ins and released local tooling, so the
+ clean tag checkout does not require `node_modules`.
+ Exactly one x64 Snap must have matching
+ `sourceArchive` and `sourceRuntime`; any non-x64 Snap must remain
+ marker-only. Checkout and the artifact-transfer actions are pinned to full
+ commits; checkout does not persist credentials, and repository credentials
+ are limited to download steps. A secretless verification job copies assets
+ through no-follow descriptors, checks pre/post hashes, writes an exact
+ receipt, repeats the complete source/package verification on a root-owned
+ read-only snapshot, and transfers only that data through the pinned artifact
+ service while its receipt digest travels separately through a job output.
+ The dependent publish job runs on a bounded `ubuntu-latest` runner with no
+ checkout or release-tag code, verifies that digest plus the exact receipt,
+ asset hashes, and file-only layout, root-seals the data again, and installs
+ Snapcraft directly. Store credentials exist only in its final fixed shell
+ step, which resolves no PATH command, executes no released code, and exposes
+ the credential only to each exact
+ `/snap/bin/snapcraft upload --release=edge` process.
+ Candidate/stable promotion is manual after installed-Snap frame-copy and
+ missing-runtime fallback smoke; GitHub Actions never promotes automatically.
+ Canonical maintenance docs:
+ `docs/architecture/embedded-mpv-native.md` and
+ `tools/embedded-mpv/README.md`.
+
## Repo Skills
- `iptvnator-ui-design`
@@ -150,6 +425,11 @@ Key files:
Use when moving heavy database work off the main thread, adding worker-backed SQLite operations, or wiring loading/progress UI for Xtream and playlist DB flows.
File: `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md`
+- `stalker-portal`
+ Repository-specific guidance for Stalker/Ministra catalogs, all three VOD/series modes, cross-surface `is_series` behavior, playback metadata, collections, EPG, and remote control.
+ Use when changing Stalker routes, stores, detail views, playback, favorites/recent activity, EPG, or remote control.
+ File: `.codex/skills/stalker-portal/SKILL.md`
+
- `xtream-electron`
Repository-specific guidance for IPTVnator's Electron-first Xtream implementation, including feature/data-access boundaries, worker-backed DB flows, and Xtream loading/progress UX expectations.
Use when working on Xtream routes, store/data-source logic, or Electron-backed Xtream import/search/delete behavior.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42aacde10..854fbf7e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,16 @@
+# Changelog
+
+Releases **0.13.0 – 0.23.0** were published as
+[GitHub releases](https://github.com/4gray/iptvnator/releases) and
+[website posts](https://4gray.github.io/iptvnator/blog/) rather than collected
+here. This file resumes from the next release onwards; the entries below are
+kept as they were written.
+
+New sections are generated from `.changes/*.md` and inserted directly below
+this marker — see `.changes/README.md`.
+
+
+
# [0.12.0](https://github.com/4gray/iptvnator/compare/v0.11.1...v0.12.0) (2023-03-11)
diff --git a/CLAUDE.md b/CLAUDE.md
index f2a10f93d..223d8db5d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,6 +26,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Repo docs are canonical even when they were originally drafted by an LLM.
- Final task summaries should state whether docs were updated and which doc changed.
+## Release Notes For User-Visible Changes
+
+- Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`.
+- Name it `-.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.
+- 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.
+- Validate before finishing: `pnpm run release:notes:validate`.
+- 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.
+- Final task summaries should state whether a release note was added or why it was skipped.
+
## Regression Prevention And Test Updates
- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, Claude Code must complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
@@ -59,7 +71,7 @@ pnpm nx show projects
- Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.
- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`.
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
-- Repository-specific skills are committed under `.codex/skills/`. If Claude Code does not load skills directly, treat those files as concise ownership docs.
+- Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an agent does not load skills directly, treat those files as concise ownership docs.
### Building and Serving
@@ -127,14 +139,14 @@ 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 launch/reuse/polling debug output
+- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
+- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize 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 phase events, 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
-For GPU/compositor debugging:
-
-```bash
-IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend
-```
+Settings, portal request/response, and trace payloads must use
+`@iptvnator/shared/logging` or the redacting portal logger before reaching
+`console.*`; never log raw credentials while debugging.
If the Nx daemon gets into a bad state before rerunning Electron:
@@ -195,7 +207,7 @@ Before finishing behavior changes or bug fixes, follow `Regression Prevention An
### Linting
```bash
-# Lint all projects (what CI enforces on every PR)
+# Lint all projects (CI runs this on master; PRs lint affected projects)
pnpm run lint
# Lint a single project
@@ -203,12 +215,17 @@ nx lint web
nx lint electron-backend
```
-CI runs lint for every project (`.github/workflows/ci.yml`). This enforces the
+CI lints affected projects on PRs (`nx affected`) and every project on master
+pushes (`.github/workflows/ci.yml`). This enforces the
Nx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint
rule (hard maximum 400 lines per TypeScript file). Pre-existing oversized files
are baselined in `tools/eslint/max-lines-baseline.mjs`; regenerate the baseline
with `node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file.
-Never add new files to the baseline.
+Never add new files to the baseline — the list must only shrink. A new file
+that genuinely cannot be split (for example a function serialized into another
+process) instead carries its own file-wide
+`/* eslint-disable max-lines -- */`; the generator skips those files, so
+a justified exemption never lands in the baseline.
Project `lint` targets that shell out to eslint must quote the glob, e.g.
`eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX
@@ -244,8 +261,10 @@ This is an Nx monorepo with the following structure:
- **portal/shared/{data-access,ui,util}** - Cross-portal shared code
- **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/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/testing** - Shared test helpers
- **ui/components** - Reusable UI components (incl. channel list)
- **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs)
@@ -345,10 +364,11 @@ Key patterns:
- **Factory injection**: `provideXtreamDataSource()` selects Electron or PWA implementation at runtime
Data strategies by environment:
-| Environment | Strategy |
-|-------------|----------|
+
+| Environment | Strategy |
+| ------------ | ------------------------------------------------------- |
| **Electron** | DB-first: Check DB → fetch API if missing → cache to DB |
-| **PWA** | API-only: Always fetch from API, store in memory |
+| **PWA** | API-only: Always fetch from API, store in memory |
**M3U Playlist Module Architecture**:
@@ -424,7 +444,7 @@ State management via NgRx (`libs/m3u-state/`):
- `PlaylistActions`: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist
- `ChannelActions`: setChannels, setActiveChannel, setAdjacentChannelAsActive
- `EpgActions`: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag
-- `FavoritesActions`: updateFavorites, setFavorites
+- `FavoritesActions`: updateFavorites, setFavorites, hydrateFavorites
See `docs/architecture/m3u-playlist-module.md` for complete documentation.
@@ -579,6 +599,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- `favorites` - User favorites
- `recentlyViewed` - Watch history
- `epgChannels`, `epgPrograms` - Persisted EPG data
+ - `epgChannelMappings` (`epg_channel_mappings`) - Manual EPG channel mappings (defined in `epg-mapping.schema.ts`, re-exported by `schema.ts`)
- `playbackPositions` - Resume positions
- `downloads` - Download manager state
- `appState` - Key-value app state (also tracks one-off data migrations)
@@ -597,7 +618,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- **Event handlers**: `apps/electron-backend/src/app/events/`
- `database.events.ts` - Database CRUD operations
- `playlist.events.ts` - Playlist import/update
- - `epg.events.ts` - EPG IPC registration and freshness/fetch orchestration; worker lifecycle lives 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`, worker lifecycle in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts`
- `xtream.events.ts` - Xtream Codes API
- `stalker.events.ts` - Stalker portal API
- `player.events.ts` - External player IPC registration; MPV/VLC lifecycle logic lives in `mpv-session.service.ts`, `vlc-session.service.ts`, and shared `external-player-*` helpers
@@ -608,7 +629,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- 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`)
-- Playlist refresh: `playlist-refresh.worker.ts`
+- 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`)
### Key Features
@@ -620,16 +641,186 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
**Video Players**:
-- Built-in HTML5 player with HLS.js or Video.js
+- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
+- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded
+ Shaka Player source engine inside the HTML5 and ArtPlayer components (no new
+ player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`
+ lines, post-processed into `Channel.drm` by `extractDrmFromRaw()` in
+ `libs/shared/m3u-utils` (hooked in `createPlaylistObject()`, covering all
+ import paths). DASH channels always play inline: `isDashChannel()` bypasses
+ the external-player setting (radio precedent) and routes Video.js/MPV/VLC/
+ embedded-MPV users to the HTML5 player via `playerOverride` (ArtPlayer keeps
+ ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope,
+ need the castLabs Electron fork) surface a DRM playback diagnostic instead
+ of crashing. ClearKey EME works in stock Electron. Engine:
+ `libs/ui/playback/src/lib/shaka-engine/`; details in
+ `docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
- External players: MPV, VLC (via IPC to Electron backend)
-- 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=` 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. 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. 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=` 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. 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
+ `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV
+ experiment flag): a per-session helper renders mpv offscreen (CGL on macOS,
+ EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the
+ preload frame pump uploads them to
+ `