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 + ``. Shared `app-player-controls` owns the DOM + UI; native-view retains the legacy dock. On Linux, only + `iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the + addon, and frame reader must not. Pristine afterPack/unpacked layouts scan + Electron libraries recursively; extracted Snap payloads exclude only the + package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root. + Every other directory remains recursive, and Electron-library symlinks still + fail closed. `electron-backend/native{,/**/*}` is excluded from `app.asar`; + `afterPack` alone owns the profile-normalized unpacked native tree, and + package checks reject every archived `/electron-backend/native/**` entry. + Packaged addon, frame-reader, and helper discovery uses only package-owned + `app.asar.unpacked` paths; cwd/dist candidates remain development-only. + Official x64 packages use three separate profiles: + DEB/RPM/Pacman depend on system libmpv plus the helper's direct + EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and + Flatpak bundles the same 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. Exact system + dependencies are DEB=`libmpv2,libegl1,libgl1,libgbm1`, + RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and + Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+; + Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`. + ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the + fail-closed packaged manifest/file/hash gate and bounded `--runtime-probe`; + any failure keeps the sandbox enabled, records a stable reason, and falls + back to native-view without crashing. Snap is `core22`/strict and uses an + exact private `shared-memory` plug plus the `graphics-core22` content plug at + a real empty mode-0755 `$SNAP/graphics`, with external `mesa-core22` as the + default provider. Its only provider-data layouts bind `/usr/share/libdrm` + from `$SNAP/graphics/libdrm` and symlink `/usr/share/drirc.d` to + `$SNAP/graphics/drirc.d`. Installed-Snap CI requires controlled unavailable + status after disconnect, then reconnects and requires success. The helper + links `libGL.so.1`, and probe/playback share a sanitized loader environment + in which ambient audit, preload, library, graphics-driver, and shell-startup + overrides are removed; the validated private closure plus trusted host GL, + graphics-content, core22 base x64, and exact GNOME-platform roots have + explicit precedence. The core22 base stays ahead of GNOME so the older + `libedit.so.2` requiring `libtinfo.so.5` cannot shadow the base ABI. The + extracted-artifact verifier removes the identical unsafe loader/graphics/ + shell set before direct helper smoke while preserving 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 packaged gate + before BrowserWindow startup and emits 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`, non-empty helper stderr is emitted separately + as one JSON-escaped stderr line with a 16,384-character `stderr` limit and an + explicit `truncated` field; trace-write failure cannot change availability. + Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded + channel. The exact packaged Flatpak `/app` context reconstructs only + Freedesktop Platform 24.08's immutable + `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that + application-level probe instead of the helper directly. The packaged x64 + Playwright smoke runs its fixture-contract target first and passes Chromium + `--ignore-gpu-blocklist` so CI llvmpipe exposes WebGL2; this does not bypass + the runtime gate, and `--no-sandbox` remains root-only. Bundled Linux + packages carry hash-validated + `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`. + CI caches the staged runtime plus immutable source inputs, never finished + notices or the compliance tarball; it regenerates those notices and the + VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current + checkout while preserving 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. Every + bundled x64 package manifest binds the final archive's SHA-256 and repository + revision; system and marker-only packages do not carry that binding. Snap + Store + publication runs only from a public `v*` GitHub release that already + contains the Snap assets and exactly one source archive. Before any upload, + the workflow hashes and checks the archive's exact member/type set and size + bounds, verifies its clean tag revision, pinned sources including the six + recursive submodule records and exact libplacebo tree digest, legal payload, + and exact released tooling, then performs bounded extraction and static + 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 remains marker-only. + Checkout and artifact-transfer actions are pinned to full commits; checkout + does not persist credentials, and repository credentials are scoped to + download steps. A secretless verification job copies assets through + no-follow descriptors, checks them before and after inspection, writes an + exact receipt, fully reverifies a root-owned read-only snapshot, and + transfers only that data through the pinned artifact service while passing + the receipt digest separately through a job output. The dependent publish + job uses 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. + Its final fixed shell step alone receives the Store credential, resolves no + PATH command, executes no released code, and exposes that 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. On Windows, + package validation requires the exact MPV DLL named by the helper's PE import + table beside the executable. + Backend adapter: + `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; + shared-controls adapter: + `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; + helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime + contracts: `docs/architecture/embedded-mpv-native.md` and + `tools/embedded-mpv/README.md`. +- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while 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, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. 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. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. 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 ArtPlayer 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. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`. +- 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. **VOD/Series Detail Pages (two-state layout)**: - Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes) +- The inline player (`PortalInlinePlayerComponent`) renders a full-width **theater stage** (`.player-shell__viewport`): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in `playerAmbientMode` setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style) +- For inline **series** playback on wide windows the stage instead docks the player left and shows an **"Up Next" episode rail** in the leftover column (`app-up-next-rail` in `libs/ui/playback/src/lib/portal-inline-player/`): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by the `playerUpNextRail` setting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill - Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`) +- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort. +- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items: quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again. - Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block - Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream) or TMDB (Stalker) +- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only. - See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout") **Radio Player**: @@ -647,19 +838,21 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - XMLTV format support - Background parsing in worker thread - Stored in database for quick lookup +- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/` **TMDB Metadata Enrichment** (opt-in): - Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works - "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation -- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`) +- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`) - Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint -- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) +- Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday` +- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) - Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts` -- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) -- Match confidence: provider `tmdb_id` trusted fully; otherwise normalized-title + year (±1) search with a strict gate — no confident match means no enrichment +- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) +- Match confidence: a provider `tmdb_id` is a strong hint, not gospel — its payload is weighed against the item (`assessProviderId`: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (`badProviderId:` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment - Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded) -- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting +- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`, plus `DB_GET_TMDB_CACHE_STATS` / `DB_CLEAR_TMDB_METADATA` behind the settings cache panel) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker. - Service layer: `libs/services/src/lib/tmdb/`; store glue: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts` and `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts` (hooked in `withStalkerSelection().setSelectedItem`) - TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About - See `docs/architecture/tmdb-metadata-enrichment.md` @@ -671,7 +864,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use **Internationalization**: -- Uses `@ngx-translate` with 18 language files in `apps/web/src/assets/i18n/` +- Uses `@ngx-translate` with 19 language files in `apps/web/src/assets/i18n/` ## Development Notes @@ -718,6 +911,9 @@ Build configurations in `apps/web/project.json`: **Factory Pattern Implementation**: The factory pattern ensures a single codebase works in both environments without conditional checks scattered throughout the application. All environment-specific logic is encapsulated in the service implementations. +**Build Commit In About**: +CI injects the git commit into `apps/web/src/environments/build-commit.ts` via `tools/build/inject-build-commit.mjs` (same placeholder pattern as the TMDB key inject); `Settings > About` then shows `" ()"`. The semver version itself deliberately stays untouched — a `-sha` suffix would flip electron-updater into prerelease mode and leak into installer/artifact version fields. Local/dev builds keep the placeholder empty and show the plain version. + ### Testing Strategy - **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks` diff --git a/README.md b/README.md index 084cd51bf..9ddc18150 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The application is a cross-platform, open-source project built with Electron and **Playback** - Built-in HTML5 player (HLS.js or Video.js) with a resizable, resumable inline view +- Optional unified IPTVnator controls for HTML5, Video.js, and ArtPlayer, enabled in **Settings → Playback** _(experimental)_ - External players — MPV, VLC, and IINA on macOS (`mpv.app` / `VLC.app` bundle paths supported) _(desktop)_ - Embedded MPV — native mpv rendered inside the app window on macOS, Windows & Linux 🖥️ _(experimental · desktop)_ - Dedicated radio player for `radio="true"` streams 📻 @@ -66,7 +67,7 @@ The application is a cross-platform, open-source project built with Electron and - Cross-platform desktop (Electron) and installable PWA - Desktop auto-updater and mobile remote control _(desktop)_ - Docker self-hosting for the PWA + web backend -- 18 languages ([translation files](apps/web/src/assets/i18n/)), light & dark themes, and keyboard shortcuts +- 19 languages ([translation files](apps/web/src/assets/i18n/)), light & dark themes, and keyboard shortcuts ## Keyboard shortcuts @@ -299,13 +300,6 @@ This redirects the SQLite database, Electron user data, and local config under the given directory. Delete that directory whenever you want a fresh empty state. -If you need to debug renderer freezes or GPU/compositor issues in Electron, you -can disable hardware acceleration for a run: - -``` -$ IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 pnpm run serve:backend -``` - If you need startup diagnostics for a white screen or a frozen route, you can also turn on opt-in Electron tracing. These logs are written to the Electron terminal output so they still help when the renderer DevTools never open: diff --git a/apps/electron-backend-e2e/playwright.packaged.config.ts b/apps/electron-backend-e2e/playwright.packaged.config.ts new file mode 100644 index 000000000..d5f67f26a --- /dev/null +++ b/apps/electron-backend-e2e/playwright.packaged.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from '@playwright/test'; +import baseConfig from './playwright.config'; + +export default defineConfig({ + ...baseConfig, + outputDir: + '../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke', + reporter: [ + ['list'], + [ + 'html', + { + outputFolder: + '../../dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke', + }, + ], + [ + 'json', + { + outputFile: + '../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/results.json', + }, + ], + ], + timeout: 120000, + webServer: [], +}); diff --git a/apps/electron-backend-e2e/playwright.performance.config.ts b/apps/electron-backend-e2e/playwright.performance.config.ts new file mode 100644 index 000000000..58a6ba03b --- /dev/null +++ b/apps/electron-backend-e2e/playwright.performance.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + fullyParallel: false, + reporter: [['list']], + testDir: './src', + testMatch: '**/*.performance.ts', + timeout: 30 * 60 * 1_000, + use: { + testIdAttribute: 'data-test-id', + }, + workers: 1, +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 1e886ba34..ee68cd410 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -12,6 +12,53 @@ "e2e": { "dependsOn": ["electron-backend:build-e2e"] }, + "test-performance-harness": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec tsx --test src/performance/*.spec.ts" + } + }, + "benchmark-m3u-refresh-cancellation": { + "dependsOn": ["electron-backend:build-performance"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts" + } + }, + "benchmark-m3u-import": { + "dependsOn": ["electron-backend:build-performance"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts" + } + }, + "packaged-frame-copy-smoke": { + "dependsOn": ["test-packaged-frame-copy-fixtures"], + "executor": "nx:run-commands", + "cache": false, + "outputs": [ + "{workspaceRoot}/dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke", + "{workspaceRoot}/dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke" + ], + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.packaged.config.ts src/embedded-mpv-frame-copy-packaged.e2e.ts" + } + }, + "test-packaged-frame-copy-fixtures": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec tsx --test src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts new file mode 100644 index 000000000..102cf35f6 --- /dev/null +++ b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts @@ -0,0 +1,277 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Locator, Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + deleteSource, + expect, + launchElectronApp, + openSettings, + openSources, + openWorkspaceSection, + resetMockServers, + restartElectronApp, + sourceRowByTitle, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +/** + * Full backup round-trip through the real UI, DB worker and IPC stack: + * hide a category, export the backup, delete the source, import the file + * back and verify the restored portal hides the same category again after + * its content is re-imported from the mock server (regression for #1017 — + * exported hidden categories lost their xtream IDs and the restore either + * hid everything or nothing). + */ +test.describe('Electron playlist backup round-trip', () => { + test('exports a backup and re-imports it with hidden categories restored', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const portalName = 'Backup Roundtrip Xtream'; + const exportPath = join(dataDir, 'roundtrip-backup.json'); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { name: portalName }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/live/ + ); + + const targetCategory = await pickVisibleCategoryWithContent( + app.mainWindow + ); + + let dialog = await openManageCategoriesDialog(app.mainWindow); + await setManagedCategoryChecked(dialog, targetCategory.name, false); + await dialog + .getByRole('button', { name: 'Save', exact: true }) + .click(); + await app.mainWindow.waitForSelector('mat-dialog-container', { + state: 'detached', + }); + await expect( + sidebarCategoryById(app.mainWindow, targetCategory.id) + ).toHaveCount(0); + + // Export through the real settings flow with the native save + // dialog stubbed to a fixed path inside the test data dir. + await app.electronApp.evaluate(({ dialog: nativeDialog }, path) => { + nativeDialog.showSaveDialog = async () => ({ + canceled: false, + filePath: path, + }); + }, exportPath); + + await openSettings(app.mainWindow); + const backupSection = app.mainWindow.locator('#backup'); + await backupSection + .getByRole('button', { name: 'Export', exact: true }) + .click(); + await expect( + app.mainWindow.getByText('Playlist backup exported.') + ).toBeVisible({ timeout: 15000 }); + + // The exported manifest must reference hidden categories by + // numeric xtream ID — the #1017 regression exported anonymous + // { categoryType } entries. + const manifest = JSON.parse(readFileSync(exportPath, 'utf-8')) as { + playlists: Array<{ + portalType: string; + userState?: { + hiddenCategories?: Array<{ + categoryType?: string; + xtreamId?: unknown; + }>; + }; + }>; + }; + const xtreamEntry = manifest.playlists.find( + (entry) => entry.portalType === 'xtream' + ); + const hiddenCategories = + xtreamEntry?.userState?.hiddenCategories ?? []; + expect(hiddenCategories.length).toBeGreaterThan(0); + expect( + hiddenCategories.every( + (hiddenCategory) => + typeof hiddenCategory.xtreamId === 'number' + ) + ).toBe(true); + + await openSources(app.mainWindow); + await deleteSource(app.mainWindow, portalName); + await expect( + sourceRowByTitle(app.mainWindow, portalName) + ).toHaveCount(0); + + // Import the exported file back through the settings flow; the + // renderer opens a browser file chooser for it. + await openSettings(app.mainWindow); + const fileChooserPromise = + app.mainWindow.waitForEvent('filechooser'); + await backupSection + .getByRole('button', { name: 'Import', exact: true }) + .click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(exportPath); + await expect( + app.mainWindow.getByText(/Backup import finished: 1 imported/) + ).toBeVisible({ timeout: 15000 }); + + // Restart before opening the restored portal: the root-provided + // XtreamStore still holds the deleted portal's in-memory state + // under the same playlist id and would skip content + // initialization in this session. A restart matches the primary + // restore workflow (fresh install) and forces a real re-import. + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + // Opening the restored portal re-imports content from the mock + // server; the pending restore state must hide the same category + // again. + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, portalName).first().click(); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/live/ + ); + await expect( + sidebarCategoryById(app.mainWindow, targetCategory.id) + ).toHaveCount(0); + + // The restored hidden flag must live in the database itself, + // not only in the rendered sidebar state. + const restoredPlaylistId = + app.mainWindow.url().match(/xtreams\/([^/]+)/)?.[1] ?? ''; + expect(restoredPlaylistId).not.toEqual(''); + const restoredDbRows = await app.mainWindow.evaluate( + (playlistId) => + ( + window as unknown as { + electron: { + dbGetAllCategories: ( + id: string, + type: string + ) => Promise< + Array<{ name: string; hidden: boolean }> + >; + }; + } + ).electron.dbGetAllCategories(playlistId, 'live'), + restoredPlaylistId + ); + expect(restoredDbRows.length).toBeGreaterThan(0); + expect( + restoredDbRows + .filter((row) => row.hidden) + .map((row) => row.name) + ).toEqual([targetCategory.name]); + + dialog = await openManageCategoriesDialog(app.mainWindow); + await dialog + .locator('input[type="search"]') + .fill(targetCategory.name); + const restoredRow = dialog.locator('.category-item').first(); + await expect(restoredRow).toBeVisible({ timeout: 15000 }); + await expect( + restoredRow.locator('mat-checkbox input') + ).not.toBeChecked(); + } finally { + await closeElectronApp(app); + } + }); +}); + +async function openManageCategoriesDialog(page: Page): Promise { + await page.getByRole('button', { name: 'Manage categories' }).click(); + const dialog = page.locator('mat-dialog-container').last(); + + await expect(dialog).toBeVisible(); + await expect(dialog.locator('.category-item').first()).toBeVisible({ + timeout: 15000, + }); + return dialog; +} + +async function setManagedCategoryChecked( + dialog: Locator, + categoryName: string, + shouldBeChecked: boolean +): Promise { + await dialog.locator('input[type="search"]').fill(categoryName); + const row = dialog.locator('.category-item').first(); + await expect(row).toBeVisible({ timeout: 15000 }); + const checkbox = row.locator('mat-checkbox input'); + + if (shouldBeChecked) { + await checkbox.check(); + await expect(checkbox).toBeChecked(); + } else { + await checkbox.uncheck(); + await expect(checkbox).not.toBeChecked(); + } +} + +function sidebarCategoryById(page: Page, categoryId: string): Locator { + return page.locator( + `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]` + ); +} + +async function pickVisibleCategoryWithContent( + page: Page +): Promise<{ id: string; name: string }> { + let picked: { id: string; name: string } | null = null; + + await expect + .poll( + async () => { + const categories = page.locator( + 'app-workspace-context-panel .category-item:visible' + ); + const count = await categories.count(); + + for (let index = 0; index < count; index += 1) { + const category = categories.nth(index); + const id = + ( + await category.getAttribute('data-category-id') + )?.trim() ?? ''; + const name = + ( + await category + .locator('.nav-item-label') + .textContent() + )?.trim() ?? ''; + const countText = + ( + await category.locator('.item-count').textContent() + )?.trim() ?? ''; + + if (id && name && (Number.parseInt(countText, 10) || 0) > 0) { + picked = { id, name }; + return true; + } + } + + picked = null; + return false; + }, + { + message: + 'No visible Xtream category with content was found in the sidebar.', + timeout: 15000, + } + ) + .toBe(true); + + return picked!; +} diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts new file mode 100644 index 000000000..553708db6 --- /dev/null +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -0,0 +1,172 @@ +import { createServer, Server } from 'http'; +import { readFileSync } from 'fs'; +import { basename, join } from 'path'; +import { + channelItemByTitle, + closeElectronApp, + expect, + launchElectronApp, + LaunchedElectronApp, + openAddPlaylistDialog, + test, + waitForM3uCatalog, + workspaceRoot, +} from './electron-test-fixtures'; + +/** + * DASH + ClearKey playback in the real Electron runtime — the only automated + * proof that ClearKey EME works in the packaged `file://` renderer (secure + * context). Uses the shared offline fixtures from apps/web-e2e/src/fixtures. + */ + +const FIXTURE_DIR = join(workspaceRoot, 'apps/web-e2e/src/fixtures/dash'); + +const CLEARKEY_KID = '00112233445566778899aabbccddeeff'; +const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100'; + +type DashFixtureServer = { + close: () => Promise; + origin: string; +}; + +/** Serves the DASH fixture directory with HTTP Range support (Shaka fetches + * init segments and the sidx via byte ranges). */ +async function startDashFixtureServer(): Promise { + const server: Server = createServer((request, response) => { + const pathname = (request.url ?? '').split('?')[0]; + const fileName = basename(pathname); + let body: Buffer; + try { + body = readFileSync(join(FIXTURE_DIR, fileName)); + } catch { + response.writeHead(404); + response.end('not found'); + return; + } + + const contentType = fileName.endsWith('.mpd') + ? 'application/dash+xml' + : 'video/mp4'; + const range = /bytes=(\d+)-(\d+)?/.exec( + request.headers.range ?? '' + ); + if (!range) { + response.writeHead(200, { + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes', + 'Content-Length': body.length, + }); + response.end(body); + return; + } + + const start = Number(range[1]); + const end = range[2] ? Number(range[2]) : body.length - 1; + const chunk = body.subarray(start, end + 1); + response.writeHead(206, { + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes', + 'Content-Range': `bytes ${start}-${end}/${body.length}`, + 'Content-Length': chunk.length, + }); + response.end(chunk); + }); + + await new Promise((resolvePromise, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolvePromise(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve dash fixture server address.'); + } + + return { + origin: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolvePromise, reject) => { + server.close((error) => + error ? reject(error) : resolvePromise() + ); + }), + }; +} + +function buildDashPlaylist(origin: string): string { + return [ + '#EXTM3U', + '#EXTINF:-1 tvg-id="ck-dash" group-title="DASH",ClearKey DASH', + '#KODIPROP:inputstream.adaptive.license_type=clearkey', + `#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_KID}:${CLEARKEY_KEY}`, + `${origin}/clearkey.mpd`, + '#EXTINF:-1 tvg-id="wv-dash" group-title="DASH",Widevine DASH', + '#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha', + '#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv', + `${origin}/clearkey.mpd`, + ].join('\n'); +} + +async function importDashPlaylistFromText( + app: LaunchedElectronApp, + playlist: string +): Promise { + await openAddPlaylistDialog(app.mainWindow); + const dialog = app.mainWindow.locator('mat-dialog-container').last(); + await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); + await dialog.locator('textarea').fill(playlist); + await dialog.getByRole('button', { name: 'Import', exact: true }).click(); + await dialog.waitFor({ state: 'detached' }); + await waitForM3uCatalog(app.mainWindow); +} + +test('@electron @dash ClearKey DASH plays inline and unsupported DRM surfaces a diagnostic', async ({ + dataDir, +}) => { + const fixtureServer = await startDashFixtureServer(); + const app = await launchElectronApp(dataDir); + + try { + await importDashPlaylistFromText( + app, + buildDashPlaylist(fixtureServer.origin) + ); + + // Happy path: ClearKey EME decrypts and playback advances. + await channelItemByTitle(app.mainWindow, 'ClearKey DASH') + .first() + .click(); + const video = app.mainWindow + .locator('app-web-player-view video') + .first(); + await expect(video).toBeVisible({ timeout: 15_000 }); + await expect + .poll( + () => + video.evaluate( + (element: HTMLVideoElement) => element.currentTime + ), + { timeout: 20_000 } + ) + .toBeGreaterThan(0.5); + await expect( + app.mainWindow.getByTestId('playback-diagnostic-banner') + ).toBeHidden(); + + // Negative: an unsupported license type must not crash — it shows the + // DRM diagnostic instead. + await channelItemByTitle(app.mainWindow, 'Widevine DASH') + .first() + .click(); + const banner = app.mainWindow.getByTestId( + 'playback-diagnostic-banner' + ); + await expect(banner).toBeVisible({ timeout: 15_000 }); + await expect(banner).toContainText(/encrypted or DRM-protected/i); + } finally { + await closeElectronApp(app); + await fixtureServer.close(); + } +}); diff --git a/apps/electron-backend-e2e/src/database-worker-post-gc.e2e.ts b/apps/electron-backend-e2e/src/database-worker-post-gc.e2e.ts new file mode 100644 index 000000000..14c2dc93e --- /dev/null +++ b/apps/electron-backend-e2e/src/database-worker-post-gc.e2e.ts @@ -0,0 +1,122 @@ +import type { ElectronApplication, Page } from '@playwright/test'; + +import { + closeElectronApp, + expect, + launchElectronApp, + test, + type LaunchedElectronApp, +} from './electron-test-fixtures'; +import { + installMainCapture, + rolloverMainCapture, + startMainCapture, + stopMainCapture, +} from './performance/m3u-refresh-main-capture'; +import type { MainCaptureMetrics } from './performance/m3u-refresh-cancellation-contract'; +import type { RendererWindowIdentity } from './performance/renderer-window-rss-session'; + +test('captures the exact database worker post-GC heap in built Electron', async ({ + dataDir, +}) => { + const launchedApps: LaunchedElectronApp[] = []; + + try { + const app = await launchElectronApp(dataDir, { + args: ['--js-flags=--expose-gc'], + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_CAPTURE: '1', + IPTVNATOR_PERF_WORKER_PROFILING: '1', + }, + }); + launchedApps.push(app); + await installMainCapture(app.electronApp); + const rendererWindowIdentity = await resolveRendererWindowIdentity( + app.electronApp, + app.mainWindow + ); + const captureOptions = { + diagnostic: false, + outputDirectory: dataDir, + rendererWindowIdentity, + } as const; + + await startMainCapture(app.electronApp, captureOptions); + await requestDatabaseWorker(app); + const rollover = await rolloverMainCapture( + app.electronApp, + captureOptions + ); + expect(rollover.nextCaptureStarted).toBe(true); + expect(rollover.nextCaptureUnavailableReason).toBeNull(); + const firstDatabaseWorker = expectExactDatabaseWorker( + rollover.completedCapture + ); + + await requestDatabaseWorker(app); + const secondDatabaseWorker = expectExactDatabaseWorker( + await stopMainCapture(app.electronApp) + ); + expect(secondDatabaseWorker.ordinal).toBe(firstDatabaseWorker.ordinal); + + await startMainCapture(app.electronApp, captureOptions); + const noDatabasePhase = await stopMainCapture(app.electronApp); + expect( + noDatabasePhase.workers.filter( + (worker) => worker.kind === 'database.worker' + ) + ).toHaveLength(0); + } finally { + await Promise.all(launchedApps.map((app) => closeElectronApp(app))); + } +}); + +async function requestDatabaseWorker(app: LaunchedElectronApp): Promise { + await app.mainWindow.evaluate(() => window.electron.dbGetAppPlaylists()); +} + +function expectExactDatabaseWorker(capture: MainCaptureMetrics) { + const databaseWorkers = capture.workers.filter( + (worker) => worker.kind === 'database.worker' + ); + + expect(databaseWorkers).toHaveLength(1); + const databaseWorker = databaseWorkers[0]; + expect(databaseWorker?.ordinal).toBeGreaterThan(0); + expect(databaseWorker?.postGcHeapUnavailableReason).toBeNull(); + expect(databaseWorker?.postGcHeapUsedBytes).toBeGreaterThan(0); + expect(databaseWorker?.requests).toHaveLength(1); + expect(databaseWorker?.requests[0]).toEqual( + expect.objectContaining({ + invalidReason: null, + operation: 'DB_GET_APP_PLAYLISTS', + performanceCaptureUnavailableReason: null, + playlistId: null, + requestId: expect.any(String), + success: true, + }) + ); + return databaseWorker; +} + +async function resolveRendererWindowIdentity( + electronApp: ElectronApplication, + page: Page +): Promise { + const browserWindowHandle = await electronApp.browserWindow(page); + try { + return await browserWindowHandle.evaluate((browserWindow) => { + const exactWindow = browserWindow as unknown as { + readonly id: number; + readonly webContents: { readonly id: number }; + }; + return { + browserWindowId: exactWindow.id, + webContentsId: exactWindow.webContents.id, + }; + }); + } finally { + await browserWindowHandle.dispose(); + } +} diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index 6810cc3c1..fe92a4208 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -1,4 +1,6 @@ -import { mkdirSync, readdirSync } from 'fs'; +import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs'; +import { createServer } from 'http'; +import type { AddressInfo } from 'net'; import { join } from 'path'; import type { Page } from '@playwright/test'; import { @@ -17,6 +19,90 @@ async function openDownloadsPage(page: Page): Promise { await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); } +interface RangeServerRequest { + ifRange?: string; + range?: string; +} + +interface ThrottledRangeServer { + close: () => Promise; + payload: Buffer; + requests: RangeServerRequest[]; + url: string; +} + +const RANGE_SERVER_ETAG = '"e2e-range-etag"'; + +/** + * Serves a payload slowly on the first (full) request so the UI has a wide + * window to pause mid-transfer, and answers Range requests with an immediate + * 206 so the resumed transfer finishes fast. Records Range/If-Range headers. + */ +async function createThrottledRangeServer(): Promise { + const payload = Buffer.from( + Array.from({ length: 96 * 1024 }, (_, index) => + String(index % 10) + ).join('') + ); + const requests: RangeServerRequest[] = []; + + const server = createServer((req, res) => { + const range = req.headers.range; + const ifRange = req.headers['if-range']; + requests.push({ + ifRange: typeof ifRange === 'string' ? ifRange : undefined, + range: typeof range === 'string' ? range : undefined, + }); + + const offset = range + ? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN) + : 0; + if (range && Number.isFinite(offset)) { + res.writeHead(206, { + 'Content-Length': payload.length - offset, + 'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`, + 'Content-Type': 'video/mp4', + ETag: RANGE_SERVER_ETAG, + }); + res.end(payload.subarray(offset)); + return; + } + + res.writeHead(200, { + 'Content-Length': payload.length, + 'Content-Type': 'video/mp4', + ETag: RANGE_SERVER_ETAG, + }); + // First 16 KiB immediately, then a trickle: the transfer stays alive + // for tens of seconds unless it is paused or resumed via Range. + let sent = 16 * 1024; + res.write(payload.subarray(0, sent)); + const timer = setInterval(() => { + if (sent >= payload.length) { + clearInterval(timer); + res.end(); + return; + } + res.write(payload.subarray(sent, sent + 2 * 1024)); + sent += 2 * 1024; + }, 150); + res.on('close', () => clearInterval(timer)); + }); + + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + + return { + close: () => + new Promise((resolve) => server.close(() => resolve())), + payload, + requests, + url: `http://127.0.0.1:${port}/media/e2e-pause-movie.mp4`, + }; +} + /** * On a cold profile the renderer can query SQLite while the DB worker is * still creating tables ("database is locked" / "no such table" on slow CI @@ -163,4 +249,104 @@ test.describe('Electron Downloads', () => { await fileServer.close(); } }); + + test('@downloads @electron pauses a download, retains the partial, and resumes it with an HTTP Range request', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const rangeServer = await createThrottledRangeServer(); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Pause Portal', + username: 'user1', + password: 'pass1', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openDownloadsPage(app.mainWindow); + + const downloadsDir = join(dataDir, 'e2e-pause-downloads'); + mkdirSync(downloadsDir, { recursive: true }); + await app.electronApp.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = async () => + ({ + canceled: false, + filePaths: [folder], + }) as Awaited>; + }, downloadsDir); + await app.mainWindow + .getByRole('button', { name: 'Change Folder' }) + .click(); + await expect( + app.mainWindow.locator('.downloads__folder-inline-path') + ).toContainText('e2e-pause-downloads'); + + const startResult = await app.mainWindow.evaluate( + async ({ url, folder }) => + window.electron?.downloadsStart?.({ + playlistId: 'e2e-playlist', + xtreamId: 7373, + contentType: 'vod', + title: 'E2E Pause Movie', + url, + downloadFolder: folder, + }), + { url: rangeServer.url, folder: downloadsDir } + ); + expect(startResult?.error ?? null).toBeNull(); + + const item = app.mainWindow.locator('.downloads__item'); + await expect(item).toHaveCount(1, { timeout: 20000 }); + await expect(item.locator('.downloads__item-status')).toContainText( + 'Downloading', + { timeout: 20000 } + ); + + // Pause mid-transfer: the row flips to Paused and only a .part + // file exists on disk (final file absent, progress retained). + await item + .getByRole('button') + .filter({ hasText: 'pause' }) + .click(); + await expect(item.locator('.downloads__item-status')).toContainText( + 'Paused', + { timeout: 20000 } + ); + const pausedFiles = readdirSync(downloadsDir); + expect(pausedFiles).toEqual(['E2E Pause Movie.mp4.part']); + const pausedBytes = statSync( + join(downloadsDir, 'E2E Pause Movie.mp4.part') + ).size; + expect(pausedBytes).toBeGreaterThan(0); + expect(pausedBytes).toBeLessThan(rangeServer.payload.length); + + // Resume: the runtime must continue via Range/If-Range instead of + // restarting, and the assembled file must match the payload. + await item + .getByRole('button') + .filter({ hasText: 'play_arrow' }) + .click(); + await expect(item.locator('.downloads__item-status')).toContainText( + 'Completed', + { timeout: 30000 } + ); + + const resumeRequest = rangeServer.requests.find( + (entry) => entry.range + ); + expect(resumeRequest?.range).toMatch(/^bytes=\d+-$/); + expect(resumeRequest?.ifRange).toBe(RANGE_SERVER_ETAG); + + expect(readdirSync(downloadsDir)).toEqual(['E2E Pause Movie.mp4']); + const finalFile = readFileSync( + join(downloadsDir, 'E2E Pause Movie.mp4') + ); + expect(finalFile.equals(rangeServer.payload)).toBe(true); + } finally { + await closeElectronApp(app); + await rangeServer.close(); + } + }); }); diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 48038d7d2..df1f1f025 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -9,14 +9,18 @@ import { } from '@playwright/test'; import { createServer, Server } from 'http'; import { + accessSync, + constants as fsConstants, existsSync, mkdtempSync, + readdirSync, readFileSync, rmSync, + statSync, writeFileSync, } from 'fs'; import { tmpdir } from 'os'; -import { join, resolve } from 'path'; +import { dirname, join, resolve } from 'path'; export const workspaceRoot = resolve(__dirname, '../../..'); export const electronMainPath = join( @@ -70,7 +74,8 @@ type ElectronFixtures = { dataDir: string; }; -type LaunchElectronAppOptions = { +export type LaunchElectronAppOptions = { + args?: readonly string[]; env?: Record; }; @@ -138,7 +143,7 @@ export async function launchElectronApp( } assertPackagedRendererBuildIsElectronSafe(); - const args = [electronMainPath]; + const args = [...(options.args ?? []), electronMainPath]; if (process.platform === 'linux' && process.env['CI']) { args.unshift('--no-sandbox', '--disable-gpu'); @@ -171,7 +176,142 @@ export async function launchElectronApp( }; } -function attachElectronProcessDiagnostics(electronApp: ElectronApplication): void { +/** + * Resolve the x64 unpacked Linux executable produced by electron-builder. + * An explicit path wins so CI can point at an AppImage/Flatpak extraction + * without relying on electron-builder's local output directory names. + */ +export function resolvePackagedLinuxExecutable( + explicitPath = process.env['IPTVNATOR_E2E_PACKAGED_EXECUTABLE'] +): string | undefined { + if (explicitPath?.trim()) { + return resolve(explicitPath.trim()); + } + + const executablesRoot = join(workspaceRoot, 'dist', 'executables'); + if (!existsSync(executablesRoot)) { + return undefined; + } + + const unpackedDirectories = readdirSync(executablesRoot, { + withFileTypes: true, + }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.startsWith('linux') && + entry.name.endsWith('-unpacked') && + !entry.name.includes('arm') + ) + .sort((left, right) => { + const leftPriority = left.name === 'linux-unpacked' ? 0 : 1; + const rightPriority = right.name === 'linux-unpacked' ? 0 : 1; + return ( + leftPriority - rightPriority || + left.name.localeCompare(right.name) + ); + }); + + for (const directory of unpackedDirectories) { + for (const executableName of ['IPTVnator', 'iptvnator']) { + const candidate = join( + executablesRoot, + directory.name, + executableName + ); + try { + accessSync(candidate, fsConstants.X_OK); + if (statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Keep looking for the next unpacked x64 layout. + } + } + } + + return undefined; +} + +export function getPackagedLinuxNativeDir(executablePath: string): string { + return join( + dirname(resolve(executablePath)), + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); +} + +export function resolvePackagedElectronLaunchArgs( + getuid: (() => number) | undefined +): string[] { + const args = ['--ignore-gpu-blocklist']; + if (typeof getuid === 'function' && getuid() === 0) { + args.push('--no-sandbox'); + } + return args; +} + +/** + * Launch a real packaged Linux executable. Unlike the regular source E2E + * launcher, this deliberately keeps Chromium's GPU path enabled and ignores + * its GPU blocklist: the frame-copy smoke sets LIBGL_ALWAYS_SOFTWARE=1, and + * CI's llvmpipe WebGL2 context must prove that the shared-memory frame reaches + * the renderer canvas. + */ +export async function launchPackagedElectronApp( + executablePath: string, + dataDir: string, + options: LaunchElectronAppOptions = {} +): Promise { + if (process.platform !== 'linux') { + throw new Error( + 'The packaged embedded-MPV launcher is available on Linux only.' + ); + } + + const resolvedExecutablePath = resolve(executablePath); + try { + accessSync(resolvedExecutablePath, fsConstants.X_OK); + if (!statSync(resolvedExecutablePath).isFile()) { + throw new Error('not a regular file'); + } + } catch (error) { + throw new Error( + `Packaged Linux executable is not a regular executable file at ${resolvedExecutablePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + const electronApp = await electron.launch({ + executablePath: resolvedExecutablePath, + args: resolvePackagedElectronLaunchArgs(process.getuid), + env: { + ...process.env, + IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: + process.env['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? '1', + ...options.env, + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }, + }); + attachElectronProcessDiagnostics(electronApp); + + const mainWindow = await findMainWindow(electronApp); + await waitForAppReady(mainWindow); + + return { + electronApp, + mainWindow, + }; +} + +function attachElectronProcessDiagnostics( + electronApp: ElectronApplication +): void { if (!process.env['CI']) { return; } @@ -235,10 +375,7 @@ async function waitForPromiseWithTimeout( return await Promise.race([ promise.then(() => true), new Promise((resolvePromise) => { - timeoutId = setTimeout( - () => resolvePromise(false), - timeoutMs - ); + timeoutId = setTimeout(() => resolvePromise(false), timeoutMs); }), ]); } finally { @@ -297,11 +434,11 @@ async function waitForAppReady(page: Page): Promise { } catch (error) { const diagnostics = await page.evaluate(() => ({ appRootLength: - document.querySelector('app-root')?.innerHTML.trim().length ?? 0, + document.querySelector('app-root')?.innerHTML.trim().length ?? + 0, baseHref: - document - .querySelector('base') - ?.getAttribute('href') ?? '', + document.querySelector('base')?.getAttribute('href') ?? + '', readyState: document.readyState, title: document.title, url: location.href, @@ -357,7 +494,7 @@ export async function importM3uPlaylistFromNativeDialog( const fileInput = dialog.locator('input[type="file"][name="playlist"]'); await fileInput.evaluate((element, selectedFilePath) => { - (element as HTMLInputElement).dataset.filePathOverride = + (element as HTMLInputElement).dataset['filePathOverride'] = selectedFilePath; }, filePath); await fileInput.setInputFiles(filePath); @@ -501,15 +638,17 @@ async function clickDialogMethodOption( label: RegExp, legacySelector?: string ): Promise { - const optionByRadio = dialog - .getByRole('radio', { name: label }) - .first(); + const optionByRadio = dialog.getByRole('radio', { name: label }).first(); if ((await optionByRadio.count()) > 0) { await optionByRadio.click(); return; } - for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) { + for (const tablistLabel of [ + 'Source method', + 'Playlist category', + 'M3U source', + ]) { const tablist = dialog .locator(`[role="tablist"][aria-label="${tablistLabel}"]`) .first(); @@ -541,9 +680,7 @@ async function clickDialogMethodOption( } if (!legacySelector) { - throw new Error( - `Could not find dialog option matching ${label}.` - ); + throw new Error(`Could not find dialog option matching ${label}.`); } await dialog.locator(legacySelector).click(); @@ -632,8 +769,23 @@ export async function enableRemoteControl( export async function saveSettings(page: Page): Promise { const saveButton = page.getByTestId('save-settings'); - await saveButton.click(); + // The save control is a native form submit (`