mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/dash-clearkey-shaka
# Conflicts: # CLAUDE.md # libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
This commit is contained in:
commit
5b2dd3b8b0
425 files changed
+59216
-3432
No files matched your search
@@ -75,9 +75,9 @@ ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0
|
||||
|
||||
### Store
|
||||
|
||||
**File**: `libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts`
|
||||
**File**: `libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts`
|
||||
|
||||
Added `reloadCategories()` method to refresh categories from database after visibility changes, ensuring the sidebar updates immediately.
|
||||
`reloadCategories()` (exposed on the `XtreamStore` facade via feature composition) refreshes categories from the database after visibility changes, ensuring the sidebar updates immediately.
|
||||
|
||||
## Behavior Notes
|
||||
|
||||
@@ -133,24 +133,28 @@ apps/electron-backend/src/app/
|
||||
libs/services/src/lib/
|
||||
└── database-electron.service.ts # Service methods (with hidden category support)
|
||||
|
||||
libs/ui/components/src/lib/recent-playlists/
|
||||
└── recent-playlists.component.ts # Stores hidden categories to localStorage on refresh
|
||||
libs/playlist/shared/ui/src/lib/
|
||||
├── recent-playlists/
|
||||
│ └── recent-playlists.component.ts # Persists hidden categories (restore state) via XtreamPendingRestoreService on refresh
|
||||
└── playlist-refresh-action.service.ts # Same restore-state persistence for the header refresh action
|
||||
|
||||
libs/services/src/lib/
|
||||
└── xtream-pending-restore.service.ts # localStorage keyed `xtream-restore-{playlistId}`
|
||||
|
||||
libs/workspace/shell/feature/src/lib/
|
||||
└── workspace-context-panel/
|
||||
└── workspace-context-panel.component.ts # Tune button; lazy-loads the dialog, calls reloadCategories()
|
||||
|
||||
libs/portal/xtream/feature/src/lib/
|
||||
├── category-management-dialog/ # Dialog component
|
||||
│ ├── category-management-dialog.component.ts
|
||||
│ ├── category-management-dialog.component.html
|
||||
│ └── category-management-dialog.component.scss
|
||||
├── xtream-main-container.component.ts # Added button & dialog
|
||||
├── xtream-main-container.component.html
|
||||
├── live-stream-layout/
|
||||
│ ├── live-stream-layout.component.ts # Added button & dialog
|
||||
│ └── live-stream-layout.component.html
|
||||
└── category-management-dialog/ # Dialog component
|
||||
├── category-management-dialog.component.ts
|
||||
├── category-management-dialog.component.html
|
||||
└── category-management-dialog.component.scss
|
||||
|
||||
libs/portal/xtream/data-access/src/lib/
|
||||
├── data-sources/
|
||||
│ └── electron-xtream-data-source.ts # Reads/passes hidden categories on save
|
||||
└── stores/xtream.store.ts # Added reloadCategories method
|
||||
└── stores/features/with-content.feature.ts # reloadCategories() (exposed on XtreamStore)
|
||||
|
||||
apps/web/src/assets/i18n/
|
||||
└── en.json # Added translation keys
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
# Download Manager Architecture
|
||||
|
||||
The download manager is a desktop-only feature that layers a curated queue, progress tracking, storage configuration, and playback controls on top of the existing Xtream (xtream-electron folder) + Stalker viewers. Backend work is handled in the Electron process while the Angular renderer surface exposes a dedicated `/downloads` route, contextual buttons, and theme-aware styling.
|
||||
The download manager is a desktop-only feature that layers a curated queue, progress tracking, storage configuration, and playback controls on top of the existing Xtream (`libs/portal/xtream`) + Stalker (`libs/portal/stalker`) portal views. Backend work is handled in the Electron process while the Angular renderer surface exposes a dedicated `/downloads` route, contextual buttons, and theme-aware styling.
|
||||
|
||||
## Backend responsibilities
|
||||
|
||||
- **Queue control (`apps/electron-backend/src/app/events/database/download-runtime.ts`)**
|
||||
`DownloadTask` mirrors the shared `DownloadItem` table plus transient cancel/progress helpers. Request validation and row creation live in `download-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`.
|
||||
- **electron-dl integration**
|
||||
`startDownload()` calls `electron-dl`'s `download()` helper. Headers (user agent, referer, origin) are attached, and the `onStarted`, `onProgress`, `onCompleted`, and `onCancel` callbacks translate the helper's payload into Drizzle updates. A cancellation requested before `onStarted` is remembered and applied as soon as Electron supplies the `DownloadItem`, so the request cannot be lost in the startup race.
|
||||
`DownloadTask` mirrors a row of the shared `downloads` table (type `Download` in `libs/shared/database/src/lib/schema.ts`) plus transient cancel/pause/progress helpers (shared task types live in `download-task.ts`). Request validation and row creation live in `download-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. The byte transfer itself lives in `download-transfer.ts`, finalization and retained-partial persistence in `download-finalize.ts`, and the renderer update broadcast in `download-broadcast.ts`.
|
||||
- **Range-aware transfer (`download-transfer.ts`)**
|
||||
The transfer streams the response through the backend's validated Axios redirect helper instead of `electron-dl`. Headers (user agent, referer, origin) are persisted in `request_headers` and re-applied through the same allowlist when read back on retry/resume. Active pause/cancel operations abort the current request with `AbortController`; pause keeps the partial file and cancel removes it. Resume checks the existing `.part` size (rejecting anything that is not a regular file, so a symlink planted while paused is never followed) and sends `Range: bytes=<offset>-` plus `If-Range` with the stored entity validator. The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator` for exactly this purpose. A `206 Partial Content` answer must start at the requested offset (`Content-Range` is verified) before bytes are appended; any other 2xx answer — the server ignoring `Range`, or `If-Range` detecting that the remote file changed — restarts the transfer from byte zero over the same `.part` instead of failing the download.
|
||||
- **Destination collision policy**
|
||||
Existing destination files are never overwritten. Before starting Electron's
|
||||
download, the backend atomically reserves a free numbered filename with an
|
||||
exclusive filesystem create. Electron may overwrite that empty reservation,
|
||||
but cannot overwrite a file that existed before the reservation. The selected
|
||||
`filePath` and `fileName` are persisted before transfer begins. Errors,
|
||||
cancellations, and startup recovery remove that exact partial path and clear
|
||||
it from the row; completed downloads replace it with Electron's final values.
|
||||
Existing destination files are never overwritten, inspected, or deleted.
|
||||
Before starting a new transfer, the backend atomically reserves a free
|
||||
numbered `.part` path while leaving the final destination path absent. The
|
||||
selected final `filePath` and `fileName` are persisted before transfer
|
||||
begins. When a retained download's recorded destination got occupied while
|
||||
it was paused or failed (for example by a file the user created), the
|
||||
retained `.part` is renamed aside and finalized to the next free numbered
|
||||
destination (`Movie (1).mp4`) instead of resolving the collision by size or
|
||||
`unlink()`. Completion creates the final `filePath` from the `.part` without
|
||||
overwriting an existing file; cancel and ordinary transfer failures remove
|
||||
the `.part`, while finalization failures and completed-partial failures
|
||||
deliberately retain it (the row keeps `filePath` so a later retry can finish
|
||||
without re-downloading); pause and restart recovery keep it for a later
|
||||
resume. Re-downloading such a failed row from a detail page
|
||||
(`DOWNLOADS_START`) deletes the retained `.part` before the row is reset.
|
||||
- **IPC surface**
|
||||
The backend exposes `DOWNLOADS_*` handlers for list retrieval, start/cancel/retry/remove operations, folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the renderer listens to in order to refresh its signal store.
|
||||
The backend exposes `DOWNLOADS_*` handlers for list retrieval, start/pause/resume/cancel/retry/remove operations, folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the renderer listens to in order to refresh its signal store.
|
||||
|
||||
## Renderer architecture
|
||||
|
||||
- **Downloads service** (`libs/services/src/lib/downloads.service.ts`)
|
||||
Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download the service asks the main process for the authorized folder and calls `downloadsStart`. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path.
|
||||
Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download/resume the service asks the main process for the authorized folder and calls the download IPC command. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `pauseDownload`, `resumeDownload`, `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path.
|
||||
- **Downloads view** (`libs/portal/downloads/feature`)
|
||||
A standalone page exposes the queue, desktop-only messaging, folder picker, and action buttons. `downloads.component.html` now wraps the list inside a scrollable panel (`downloads__list-wrapper`) so long queues stay reachable, and `downloads.component.scss` drives a bold two-tone aesthetic inspired by the frontend-design mandate—gradient cards, floating avatars, and theme-aware variables triggered via `body.dark-theme`.
|
||||
Failed/canceled cards now show retry/delete controls, queued/downloading cards show a cancel icon, and completed cards render inline play/open buttons with `mat-icon` cues. The header also shows the resolved download folder and a `CHANGE FOLDER` action.
|
||||
- **Theme fixes**
|
||||
To keep typography legible in both modes, `app-search-result-item` now inherits color from `:host-context(body.dark-theme)` and `:host-context(body:not(.dark-theme))`, ensuring dense light-theme grids no longer show white text on white backgrounds.
|
||||
A standalone page exposes the queue, desktop-only messaging, folder picker, and action buttons. `downloads.component.html` wraps the list inside a scrollable panel (`downloads__list-wrapper`) so long queues stay reachable, and `downloads.component.scss` drives gradient cards with theme-aware styling through Angular Material system CSS variables (`var(--mat-sys-*)`, `var(--app-*)`, `color-mix`) — theming tracks the active Material theme rather than a `body.dark-theme` hook.
|
||||
Failed/canceled cards show retry/delete controls, queued/downloading cards show pause/cancel controls, paused cards show resume/cancel/delete controls, and completed cards render inline play/open buttons with `mat-icon` cues. Pause/resume/cancel/retry surface backend `success: false` results in a snackbar instead of failing silently. The header also shows the resolved download folder and a `CHANGE FOLDER` action. VOD and episode detail views render a paused download as an active "Resume" button (`DownloadsService.isPaused()` / `resumeDownloadByContent()`) rather than a disabled "Downloading" state.
|
||||
|
||||
## Global API surface
|
||||
|
||||
@@ -37,12 +43,19 @@ The download manager is a desktop-only feature that layers a curated queue, prog
|
||||
## Routing and navigation
|
||||
|
||||
- `/downloads` is available under both portal flavors: the Xtream routes already load `DownloadsComponent`, and the Stalker routes now import the same component so the sidebar link can target `/stalker/:id/downloads` without returning to the startup screen.
|
||||
- The navigation component already points `routerLink="./downloads"` inside the shared nav pane, so both portals reuse the same download page.
|
||||
- Downloads navigation is data-driven: `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts` emits a `downloads` section link (`path: [...root, 'downloads']`) for both portals, so they reuse the same download page.
|
||||
|
||||
## Queuing, persistence, and UX notes
|
||||
|
||||
- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, and Xtream identifiers. On startup, `download-recovery.ts` deletes persisted partial reservations before stale queued/downloading rows become `failed`.
|
||||
- Queue cancellation removes a queued task or records an active cancellation request and calls `downloadItem.cancel()` when the item is available; retries reuse the same database entry, preventing duplicate rows.
|
||||
- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `paused`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, `requestHeaders`, `resumeValidator`, and Xtream identifiers. Existing SQLite tables are rebuilt on startup when their status CHECK still lacks `paused`; the `resume_validator` column is added through the idempotent column migrations.
|
||||
- On startup, `download-recovery.ts` converts stale `downloading` rows with a non-empty `.part` file to `paused`, converts stale `queued` rows to `paused` while keeping any retained `.part` (a resumed download waiting behind an active one persists as `queued` with its partial), and marks stale `downloading` rows without recoverable partial bytes as `failed`.
|
||||
- Queue cancellation removes a queued task or records an active cancellation request and aborts the request when available. Pausing follows the same abort path but persists `paused` and keeps the `.part`. Retries reuse the same database entry: a failed row with a retained `filePath` resumes its `.part` through HTTP Range, otherwise the retry starts from zero. Resume appends to the existing `.part` through HTTP Range with `If-Range` validation.
|
||||
- A `.part` that cannot be deleted (locked, permission denied) never loses its database path: cancel persists `canceled` while retaining `filePath` for later cleanup, and `DOWNLOADS_REMOVE` keeps the row and answers `success: false` (surfaced as a snackbar) so retrying the remove re-attempts the deletion once the lock is released.
|
||||
- Resume claims the row atomically (`paused` → `queued` as a conditional update) and the runtime queue rejects duplicate ids, so two rapid Resume clicks racing the status refresh can never produce two transfers for the same download.
|
||||
- A response that ends cleanly before the advertised representation size (for example a proxy that caps each response) is never committed as completed: the transfer fails with `Transfer ended before the advertised size` while retaining the `.part` and `filePath`, so a retry continues via Range from where it stopped.
|
||||
- Retained `filePath`s recorded in the database stay usable after the user switches download folders — resume/retry of a retained row does not re-require the folder to be the current selection. Fresh downloads still authorize against the currently selected folder.
|
||||
- Startup recovery recognizes a finalization that crashed between creating the final file and committing the row (`downloading` row, no partial, final file present with the recorded size) and marks it `completed` instead of failing it and orphaning the file.
|
||||
- Pause/resume is covered end to end by `apps/electron-backend-e2e/src/downloads.e2e.ts`: a throttled Range-capable mock server verifies the paused `.part` on disk, the `Range`/`If-Range` resume request, and byte-exact assembly of the final file.
|
||||
- The OS downloads path is always authorized. A custom folder becomes
|
||||
authorized only after native folder selection, and the main process persists
|
||||
that selection under Electron `userData`. Renderer settings may display the
|
||||
|
||||
@@ -9,7 +9,9 @@ explicit hardened `webPreferences` object:
|
||||
|
||||
- `contextIsolation: true`
|
||||
- `nodeIntegration: false`
|
||||
- `sandbox: true`
|
||||
- `sandbox: !frameCopyExperiment` — `true` by default; the opt-in Embedded MPV
|
||||
frame-copy experiment is the one path that disables the renderer sandbox
|
||||
(`contextIsolation`/`nodeIntegration` stay hardened regardless)
|
||||
- `webSecurity: true`
|
||||
- `preload: apps/electron-backend/src/app/api/main.preload.ts`
|
||||
|
||||
@@ -98,11 +100,13 @@ produce both `dmg` and `zip` targets, and publish a single merged
|
||||
|
||||
The Angular shell defines a baseline CSP in `apps/web/src/index.html`.
|
||||
|
||||
The policy keeps the application self-hosted for scripts, blocks object and
|
||||
frame embedding, limits forms to the app origin, and allows IPTV playback
|
||||
sources through `media-src` and `connect-src` for `http:`, `https:`, `blob:`,
|
||||
and `data:`. The policy keeps `script-src` self-hosted and currently keeps
|
||||
`unsafe-inline` for existing inline styles.
|
||||
The policy keeps the application self-hosted for scripts, blocks object
|
||||
embedding (`object-src 'none'`) while allowing frames only from
|
||||
`https://www.youtube-nocookie.com` (`frame-src`, used for TMDB trailers),
|
||||
limits forms to the app origin, and allows IPTV playback sources through
|
||||
`media-src` and `connect-src` for `http:`, `https:`, `blob:`, and `data:`. The
|
||||
policy keeps `script-src` self-hosted and currently keeps `unsafe-inline` for
|
||||
existing inline styles.
|
||||
|
||||
Angular production builds must not rely on inline event handlers for stylesheet
|
||||
activation. Keep `web:build:production` and `web:build:pwa` configured without
|
||||
@@ -188,6 +192,26 @@ playlist or EPG source host. The `IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch
|
||||
is only for explicitly trusted providers with invalid or self-signed
|
||||
certificates when the host-scoped UI path is not available.
|
||||
|
||||
## Sensitive Diagnostic Logging
|
||||
|
||||
Settings, portal requests/responses, IPC trace payloads, and remote-request
|
||||
errors can contain provider credentials. Code at those boundaries must pass
|
||||
structured values through `redactSensitiveData` from
|
||||
`@iptvnator/shared/logging`, or through the portal `createLogger`/portal-debug
|
||||
helpers that apply it. Do not send a raw settings object, request params,
|
||||
response, or `Error` directly to `console.*`.
|
||||
|
||||
The redactor preserves non-sensitive diagnostic fields while replacing
|
||||
credential fields case-insensitively, including usernames, passwords, tokens,
|
||||
API keys, authorization/cookie headers, and MAC addresses. It also sanitizes
|
||||
URL query parameters, serialized JSON, nested query values, errors, arrays,
|
||||
and cyclic objects without mutating the original value. Depth, collection,
|
||||
object-key, and string limits keep opt-in debug traces bounded.
|
||||
|
||||
When adding a new logging boundary, extend the closest regression test with a
|
||||
synthetic secret and assert that the exact value is absent from captured log
|
||||
output. Never use a real provider credential to validate logging.
|
||||
|
||||
## Filesystem Capabilities
|
||||
|
||||
Renderer IPC payloads are not filesystem authorization.
|
||||
|
||||
@@ -121,6 +121,29 @@ Contracts:
|
||||
- Entering watch scrolls the shell to the top; leaving keeps the scroll
|
||||
position.
|
||||
|
||||
### Inline player stage (theater + ambient fill)
|
||||
|
||||
`PortalInlinePlayerComponent`
|
||||
(`libs/ui/playback/src/lib/portal-inline-player/`) wraps the projected
|
||||
`WebPlayerViewComponent` in a `.player-shell__viewport` "theater stage".
|
||||
The stage spans the full content width and is capped at
|
||||
`min(70vh, 720px)`; on wide-short windows it becomes wider than 16:9. The
|
||||
player is sized as the largest 16:9 box that fits the stage height and is
|
||||
centered, so the leftover is always the stage's own black background — never
|
||||
a stray strip of app surface. This is the YouTube-style letterbox and is the
|
||||
default behavior for every inline engine.
|
||||
|
||||
The optional `playerAmbientMode` setting (Settings → Playback, default off,
|
||||
shown only for the built-in web players) renders a blurred, dimmed copy of the
|
||||
poster (`ResolvedPortalPlayback.thumbnail`) behind the player via the
|
||||
`--ambient-image` custom property, turning the letterbox margins into
|
||||
atmosphere (YouTube "Ambient mode" / Netflix backdrops). The component also
|
||||
enforces the web-player scope at runtime (HTML5, Video.js, ArtPlayer): with
|
||||
Embedded MPV selected, a persisted `playerAmbientMode=true` never renders the
|
||||
layer, keeping extra DOM out of the native-video compositing path. Live
|
||||
channels are excluded (their `thumbnail` is a logo), and only
|
||||
`http(s):`/`data:` poster URLs are accepted to avoid CSS `url()` breakout.
|
||||
|
||||
Season navigation inside `SeasonContainerComponent` uses season tabs
|
||||
(`SeasonTabsComponent`; a dropdown beyond 6 seasons) instead of the old
|
||||
seasons-grid + "Back to seasons" level. A season is auto-selected
|
||||
@@ -264,6 +287,13 @@ The diagnostic surface covers the inline player viewport when playback fails, wi
|
||||
|
||||
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
|
||||
|
||||
MKV sources are attempted through Chromium's native Matroska path. Video.js
|
||||
receives `video/matroska` for `.mkv` URLs and explicit query metadata such as
|
||||
`extension=mkv` or `container=mkv`; ArtPlayer and HTML5 continue to use their
|
||||
native video paths. This is container support rather than a universal codec
|
||||
guarantee: native source or decode failures still produce the existing
|
||||
diagnostic and explicit MPV/VLC fallback.
|
||||
|
||||
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the inline players unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. Embedded MPV uses the same live decision rule and shows an unknown duration placeholder for VOD/episode snapshots until MPV reports a finite duration. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
|
||||
|
||||
When a diagnostic is actionable in Electron, the diagnostic surface may offer `Open in MPV`, `Open in VLC`, `Copy URL`, technical details, and `Retry`. Web builds only expose copy/help text and retry. MPV/VLC fallback requests carry the original `ResolvedPortalPlayback` payload so headers, referer, origin, user-agent, content metadata, and resume offset stay intact. Retry clears the current diagnostic and rebuilds the active inline player inputs; it does not change the saved player setting.
|
||||
|
||||
@@ -18,7 +18,7 @@ Source files for the embedded MPV integration:
|
||||
- `libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts` defines the shared session and audio-track contract.
|
||||
- `libs/ui/playback/src/lib/embedded-mpv-player/` owns the Angular UI and controls.
|
||||
|
||||
Frame-copy engine sources (experimental, macOS Apple Silicon, Linux and
|
||||
Frame-copy engine sources (experimental, macOS Apple Silicon, Linux x64, and
|
||||
Windows — see the "Frame-Copy Engine" section below):
|
||||
|
||||
- `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper` process (`mpv_frame_helper.cpp`, `frame_helper_render.h`, `frame_helper_gl.h`, `frame_helper_io.h`, `frame_shm.h`).
|
||||
@@ -60,20 +60,76 @@ Linux native Wayland embedding is not implemented. When Electron is started on X
|
||||
|
||||
## Linux Support Matrix
|
||||
|
||||
Embedded MPV on Linux is supported only for x64 desktop builds where Electron runs under X11 or Xwayland and an `mpv` executable is available on `PATH`. Native Wayland embedding is not supported in this implementation.
|
||||
Official Linux frame-copy packaging is x64-only. The native-view and frame-copy
|
||||
engines have different runtime requirements:
|
||||
|
||||
The experimental frame-copy engine (below) is the exception to both requirements: it renders offscreen through headless EGL into a renderer canvas — no window embedding — and the helper links libmpv itself, so neither the X11/Xwayland constraint nor the system-`mpv`-on-`PATH` probe applies while it is active. It is currently a dev-build-only engine on Linux (the helper links the build host's system `libmpv` and is stripped from packaged apps until the bundled-runtime staging lands). Packaged Linux launchers pass `--ozone-platform=x11` so Wayland desktops use Xwayland when it is available, and `main.ts` appends the same switch on Linux when it is absent so direct binary/AppImage launches from a terminal behave like launcher starts. Explicit user intent is never overridden: both a user-provided `--ozone-platform` switch and the `ELECTRON_OZONE_PLATFORM_HINT` environment variable suppress the fallback.
|
||||
| Engine | Display path | MPV runtime |
|
||||
| ----------- | ----------------------------- | -------------------------------------------------------------------------- |
|
||||
| native-view | X11 or Xwayland | An `mpv` executable on `PATH`; playback is an isolated `mpv --wid` process |
|
||||
| frame-copy | Headless EGL; no window embed | A separately linked and capability-probed `iptvnator_mpv_helper` |
|
||||
|
||||
When the `mpv` executable probe fails inside a Flatpak or Snap sandbox (`FLATPAK_ID`/`SNAP` env present), the support reason explains that sandboxed packages cannot access a system mpv instead of asking the user to install it.
|
||||
Native Wayland embedding is not implemented for native-view. Frame-copy itself
|
||||
does not embed a window and can render through EGL on a native Wayland desktop,
|
||||
although packaged launchers still default Electron to X11/Xwayland unless the
|
||||
user explicitly supplies an Ozone choice. A user-provided `--ozone-platform`
|
||||
or `ELECTRON_OZONE_PLATFORM_HINT` is never overridden.
|
||||
|
||||
Current release-announcement wording should stay close to this:
|
||||
Linux packages are built in separate passes because Electron Builder reuses
|
||||
one unpacked application layout per pass:
|
||||
|
||||
- Supported display path: X11 or Xwayland.
|
||||
- Not supported: native Wayland embedding.
|
||||
- Validated locally: Ubuntu 24.04 GNOME Wayland session with Electron forced to X11/Xwayland and system `mpv`.
|
||||
- Validated in CI: Ubuntu 22.04 standard Linux package build and Ubuntu 24.04 Flatpak package build.
|
||||
- Expected standard packages: `.deb` on Ubuntu/Debian, `pacman` on Arch/Manjaro, `.rpm` on RPM-based distributions, and AppImage on x64 glibc systems, all with system `mpv` installed.
|
||||
- Sandbox caveat: Flatpak and Snap packages build and continue to support the normal inline/external-player flows, but embedded MPV is not announced as supported there yet because the Linux backend launches `mpv --wid` and those sandboxed formats do not expose the host `mpv` executable to the app by default.
|
||||
| Profile | Formats | Frame-copy runtime strategy |
|
||||
| ---------- | ---------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `system` | DEB, RPM, Pacman | System `libmpv.so.2` plus the helper's direct EGL/GL/GBM interfaces; exact dependencies are listed below |
|
||||
| `portable` | AppImage, Snap | Bundled pinned LGPL-compatible runtime under `native/lib` |
|
||||
| `flatpak` | Flatpak | The same bundled pinned LGPL-compatible runtime under `native/lib` |
|
||||
|
||||
System package dependencies are fail-closed and format-specific:
|
||||
|
||||
- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1`
|
||||
- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm`
|
||||
- Pacman: `mpv`, `libglvnd`, `mesa`
|
||||
|
||||
The DEB contract deliberately names `libmpv2`, not a loose `libmpv`
|
||||
alternative, and explicitly names the GLVND `libGL.so.1` interface because
|
||||
`libmpv2` does not pull it in for the helper. The helper uses `-lGL`, not
|
||||
`-lOpenGL`; this matches the graphics interface supplied by distributions and
|
||||
Snap's `mesa-core22`. Release CI verifies that contract on Ubuntu 24.04
|
||||
(Noble). Ubuntu 22.04 (Jammy) provides `libmpv1`, so its DEB cannot enable this
|
||||
system-runtime frame-copy path; use the x64 AppImage there instead.
|
||||
|
||||
Every x64 layout contains the addon, frame reader, helper, and a normalized
|
||||
`embedded-mpv-runtime.json`. The Electron executable, Electron libraries,
|
||||
`embedded_mpv.node`, and the frame reader must not link libmpv; only the helper
|
||||
may do so. AppImage, Snap, and Flatpak retain dynamically linked, replaceable
|
||||
runtime libraries and ship the corresponding source/build metadata. Their
|
||||
native directory also contains hash-validated `embedded-mpv-notices.json`,
|
||||
`THIRD_PARTY_NOTICES.txt`, and `licenses/<package>/**`. DEB, RPM, Pacman, and
|
||||
marker-only packages intentionally contain neither a private `native/lib`
|
||||
directory nor the bundled-runtime legal payload.
|
||||
|
||||
The build-time `electron-backend/native` tree is excluded from `app.asar`.
|
||||
`afterPack` is the only owner of
|
||||
`resources/app.asar.unpacked/electron-backend/native`, so each profile receives
|
||||
exactly its normalized payload and ARM packages cannot retain a hidden x64
|
||||
helper, runtime, manifest, or notice copy in the archive. Both unpacked-layout
|
||||
and final Linux artifact verification enumerate `app.asar` and fail if any
|
||||
entry remains below `/electron-backend/native/`.
|
||||
|
||||
The pristine `afterPack` and unpacked-layout checks recursively inspect
|
||||
Electron-owned shared libraries. An extracted Snap has already overlaid its
|
||||
package-manager `lib/**` and `usr/lib/**` runtime trees onto that same payload
|
||||
root, so the post-target verifier excludes exactly those two target-provided
|
||||
trees while continuing to scan every other directory recursively. Electron
|
||||
libraries are still required to be regular files and free of libmpv linkage;
|
||||
Snap runtime symlinks are outside that ownership boundary.
|
||||
|
||||
ARM Linux packages remain marker-only. They never borrow x64 native artifacts,
|
||||
even when build environment variables claim a matching staged architecture.
|
||||
Consequently frame-copy is not advertised there, and the normal inline/external
|
||||
players remain available. On x64, any missing or unusable frame-copy dependency
|
||||
falls back to native-view without crashing; if native-view also lacks X11 or a
|
||||
system `mpv` executable, Embedded MPV is reported unavailable with a stable
|
||||
diagnostic reason.
|
||||
|
||||
The flow is:
|
||||
|
||||
@@ -94,9 +150,10 @@ The flow is:
|
||||
native-view, it starts `mpv --wid=<x11-window>` in a separate process with a
|
||||
private JSON IPC socket. Frame-copy instead uses the per-session helper
|
||||
described below.
|
||||
9. Resize, scroll, and fullscreen changes are measured in Angular and sent
|
||||
through bounds sync. Native-view uses them to align the platform host;
|
||||
frame-copy uses them to resize helper rendering and the canvas frame source.
|
||||
9. Resize, scroll, fullscreen, and devicePixelRatio changes are measured in
|
||||
Angular and sent through bounds sync. Native-view uses them to align the
|
||||
platform host; frame-copy uses them to resize helper rendering and the
|
||||
canvas frame source.
|
||||
10. Playback controls remain IPTVnator-owned Angular UI. Frame-copy uses the
|
||||
shared `app-player-controls` overlay through
|
||||
`EmbeddedMpvControlsAdapter`; native-view keeps its compositor-safe fixed
|
||||
@@ -117,7 +174,16 @@ The renderer never gets direct native-module access. It can only call the preloa
|
||||
- dispose session
|
||||
- subscribe to session updates
|
||||
|
||||
Settings uses the preload support API as an availability and capability check. Unsupported paths return before loading the addon when platform, experiment gating, addon presence, bundled runtime presence, or the Linux `mpv` executable check fails. Supported paths load `embedded_mpv.node` so the renderer can receive capability flags from the actual addon binary. Avoid calling this support API from global workspace startup paths; use an explicit user action or idle preparation path when a renderer surface only needs to reveal optional Embedded MPV UI.
|
||||
Settings uses the preload support API as an availability and capability check.
|
||||
Unsupported paths return before loading the addon when platform, experiment
|
||||
gating, native artifacts, the packaged runtime manifest, the Linux helper
|
||||
probe, or the native-view `mpv` executable check fails. Support diagnostics
|
||||
include a stable `frameCopyUnavailableReason`; it is tracing/support data, not
|
||||
user-facing copy. Supported paths load `embedded_mpv.node` so the renderer can
|
||||
receive capability flags from the actual addon binary. Avoid calling this
|
||||
support API from global workspace startup paths; use an explicit user action
|
||||
or idle preparation path when a renderer surface only needs to reveal optional
|
||||
Embedded MPV UI.
|
||||
|
||||
When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds.
|
||||
|
||||
@@ -143,8 +209,9 @@ the frame-copy canvas.
|
||||
embedded MPV experiment flag) switches macOS/arm64, Linux and Windows to a
|
||||
second rendering engine that replaces the native-view compositing entirely
|
||||
(gate: `isFrameCopyPlatformSupported()` in
|
||||
`embedded-mpv-frame-copy-platform.util.ts`, shared by `main.ts`, the
|
||||
service and the adapter):
|
||||
`embedded-mpv-frame-copy-platform.util.ts`; the adapter imports it directly,
|
||||
while `main.ts` and the service call it transitively through the same util
|
||||
module's `isFrameCopyRuntimeUsable()` / `getFrameCopyRuntimeAvailability()`):
|
||||
|
||||
- `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper`, a
|
||||
one-process-per-session libmpv host. It decodes (hwdec), renders
|
||||
@@ -180,7 +247,7 @@ service and the adapter):
|
||||
`attachEmbeddedMpvFrameView`/`detachEmbeddedMpvFrameView`.
|
||||
- Renderer: `EmbeddedMpvPlayerComponent` renders the canvas when
|
||||
`support.engine === 'frame-copy'` and skips the compositor workarounds —
|
||||
no `HIDDEN_BOUNDS` when dialogs open, no popover bottom cutout; dialogs
|
||||
no `HIDDEN_BOUNDS` when dialogs open; dialogs
|
||||
and the shared `app-player-controls` overlay stack above the canvas as
|
||||
ordinary DOM. The canvas fills the player root; the native dock's reserved
|
||||
controls height is not applied. Legacy embedded-MPV pointer/click,
|
||||
@@ -193,20 +260,128 @@ service and the adapter):
|
||||
bounds sync.
|
||||
|
||||
Enabling it: the `Settings > Playback > Embedded MPV: frame-copy engine`
|
||||
checkbox (shown only when support reports `frameCopyAvailable`) persists to
|
||||
the main-process config store (`electron-conf`), which `main.ts` reads
|
||||
before creating the window and translates into the env flag; an explicitly
|
||||
set env var (including `0`) wins over the stored preference, but cannot bypass
|
||||
the platform/runtime safety gate. Frame-copy can relax the window sandbox only
|
||||
checkbox (shown when support reports `frameCopyAvailable` or the option is
|
||||
already enabled, so it stays visible for turning off) persists to
|
||||
the main-process config store (`electron-conf`), which `main.ts` reads before
|
||||
creating the window and translates into the env flag; an explicitly set env
|
||||
var (including `0`) wins over the stored preference, but cannot bypass the
|
||||
platform/runtime safety gate. Frame-copy can relax the window sandbox only
|
||||
when embedded MPV itself is enabled for the current run (packaged app or the
|
||||
regular development experiment flag) and discovery finds both an executable
|
||||
(`X_OK`) helper and a readable regular frame-reader addon in the same native
|
||||
directory. Packaged discovery is limited to packaged resource locations and
|
||||
never falls through to writable cwd/dist development paths. A disabled base
|
||||
experiment keeps the renderer sandbox enabled and embedded MPV unavailable.
|
||||
When the base feature is enabled, a missing, mode-stripped, or incomplete
|
||||
frame-copy runtime keeps the sandbox enabled and falls back to the native
|
||||
engine.
|
||||
regular development experiment flag) and one process-wide capability decision
|
||||
has succeeded. On Linux x64 that decision validates the profile manifest,
|
||||
regular-file/access modes, the complete declared bundled closure and hashes,
|
||||
then runs `iptvnator_mpv_helper --runtime-probe` with a three-second timeout.
|
||||
The probe loads dependencies through the normal ELF loader, initializes an
|
||||
idle libmpv client, creates EGL/OpenGL plus mpv render contexts, then
|
||||
creates, maps, validates, and destroys a minimal `16x16` shared-memory ring
|
||||
named `/impv-fc-runtime-probe-<pid>`. It never opens media or enters the media
|
||||
or command loops. Shared-memory creation/mapping and header-initialization
|
||||
failures emit the stable helper reasons `shared-memory-create-failed` and
|
||||
`shared-memory-initialize-failed`. The probe must emit exactly one protocol-v1
|
||||
JSON line and return zero. When the helper exits nonzero with an otherwise
|
||||
exact failure line, the application availability diagnostic keeps the
|
||||
fail-closed top-level reason `helper-probe-failed` and may add only the
|
||||
allowlisted helper reason as `helperReason`. An optional `helperDetail` is
|
||||
copied only from the same exact line when it contains 1–1024 printable ASCII
|
||||
characters; an invalid detail rejects both helper fields. Malformed,
|
||||
multi-line, wrong-protocol, or unknown failure output never reaches either
|
||||
field. Every probe uses the same explicit 16 MiB aggregate captured-output
|
||||
ceiling, independent of tracing, so verbose diagnostics do not fall back to
|
||||
Node's smaller implicit buffer. When `IPTVNATOR_TRACE_PLAYER=1`, the probe also
|
||||
emits non-empty captured helper stderr separately as one JSON line. JSON
|
||||
escaping keeps embedded newlines on that single line, the `stderr` field is
|
||||
limited to the first 16,384 characters, and the `truncated` boolean is always
|
||||
present. A missing flag, empty capture, or trace-writer failure produces no
|
||||
trace and never changes the cached availability result or the application
|
||||
diagnostic's stdout protocol.
|
||||
|
||||
The startup probe and every playback helper session use the same sanitized
|
||||
loader environment selected by the validated manifest's cached `runtimeMode`.
|
||||
Both remove ambient ELF audit/preload/origin/library overrides, direct
|
||||
EGL/GBM/GL/VA/Vulkan driver and layer paths, shell startup/options, tracing
|
||||
hooks, and exported Bash functions. The extracted-artifact verifier applies
|
||||
the same deny-set before its direct helper smoke, while preserving
|
||||
feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. The system profile
|
||||
then uses the default system loader without a private path. Bundled profiles
|
||||
put the validated packaged `native/lib` first. AppImage and Flatpak resolve the
|
||||
declared external graphics/audio interfaces through their normal host or
|
||||
sandbox loader.
|
||||
For the exact `com.fourgray.iptvnator` Flatpak payload under `/app`, the helper
|
||||
reconstructs Freedesktop Platform 24.08's immutable
|
||||
`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS` value:
|
||||
`/etc/egl/egl_external_platform.d:/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d:/usr/share/egl/egl_external_platform.d`.
|
||||
All ambient EGL/GBM/GL/VA/Vulkan path overrides remain removed. Freedesktop's
|
||||
GL extension `add-ld-path` is supplied through the sandbox loader cache, so no
|
||||
ambient `LD_LIBRARY_PATH` is needed. Flatpak CI runs the application-level
|
||||
`--embedded-mpv-runtime-probe`; direct helper execution is only a package
|
||||
layout check and cannot substitute for the real gate.
|
||||
The installed-Snap smoke enables `IPTVNATOR_TRACE_PLAYER=1`,
|
||||
`EGL_LOG_LEVEL=debug`, and `LIBGL_DEBUG=verbose`, so GLVND/Mesa loader failures
|
||||
remain observable through the bounded stderr record while the same hostile
|
||||
ambient-path assertions and fail-closed application gate stay active.
|
||||
Inside a genuine Snap mount, filtered absolute `SNAP_LIBRARY_PATH` entries below
|
||||
`/var/lib/snapd/lib/gl` follow `native/lib`. The exact
|
||||
`$SNAP/graphics/usr/lib/x86_64-linux-gnu` content-provider roots come next,
|
||||
followed by the core22 base `/usr/lib/x86_64-linux-gnu`, then the GNOME
|
||||
platform's fixed x64 library, Mesa, DRI, and PulseAudio roots only when
|
||||
`SNAP_DESKTOP_RUNTIME` resolves exactly to `$SNAP/gnome-platform`; generic
|
||||
`$SNAP` roots remain last. Core22 must precede that older desktop content
|
||||
runtime so its compatible `libedit.so.2` wins instead of the GNOME copy that
|
||||
requires unavailable `libtinfo.so.5`. The helper also rebuilds the GBM, GL/VA
|
||||
driver, EGL vendor/platform, and Vulkan layer variables from those trusted
|
||||
roots. Caller-provided triplets, graphics-driver paths, and out-of-root loader
|
||||
entries are ignored.
|
||||
Both the bounded probe and playback execute the helper through
|
||||
`$SNAP/graphics/bin/graphics-core22-provider-wrapper`. Before either launch,
|
||||
the app requires the mounted graphics root to be a real directory and the
|
||||
wrapper to be a regular, non-symlinked, readable executable. A missing or
|
||||
disconnected provider therefore reports the stable
|
||||
`snap-graphics-provider-unavailable` reason instead of attempting a partial
|
||||
loader setup. Because that provider wrapper is a non-interactive Bash script,
|
||||
the child environment also removes shell startup/options, exported
|
||||
`BASH_FUNC_*` functions, and tracing hooks, and fixes `PATH` to core22 system
|
||||
directories. This prevents ambient shell configuration from replacing the
|
||||
probe or its `dirname` lookup before the helper executes.
|
||||
|
||||
The Snap is `base: core22` with strict confinement. It keeps Electron Builder's
|
||||
default plugs and adds an auto-connected private `shared-memory` plug plus the
|
||||
`graphics-core22` content plug targeting a real empty mode-0755
|
||||
`$SNAP/graphics`, with `mesa-core22` as default provider. `mesa-core22`
|
||||
supplies the shared
|
||||
EGL/GL/GLX/GBM/DRM/VA userspace; the existing GNOME content runtime supplies
|
||||
ALSA/PulseAudio. These providers are external shared snaps, so their binaries,
|
||||
source, notices, and installed bytes are not part of the IPTVnator Snap or its
|
||||
compliance archive. CI installs and explicitly connects both providers for a
|
||||
locally installed `--dangerous` artifact, then runs the application-level
|
||||
probe under strict confinement.
|
||||
|
||||
The package carries the empty content target itself because core22 does not
|
||||
create `$SNAP` content targets while packing. Metadata verification rejects a
|
||||
missing, non-directory, symlinked, non-empty, or incorrectly permissioned
|
||||
target. It also requires exactly the canonical provider-data layouts:
|
||||
`/usr/share/libdrm` binds from `$SNAP/graphics/libdrm`, and
|
||||
`/usr/share/drirc.d` symlinks to `$SNAP/graphics/drirc.d`. No additional or
|
||||
duplicate layout entry is accepted.
|
||||
|
||||
Private shared memory gives the app a confined, snap-specific POSIX shm
|
||||
namespace rather than global cross-snap access. The packaging-only
|
||||
`--embedded-mpv-runtime-probe` application switch invokes the same complete
|
||||
manifest, mode, hash, linkage, environment, and bounded helper probe used at
|
||||
startup before any BrowserWindow is created. It writes exactly one availability
|
||||
JSON line and exits zero only when frame-copy is usable. Consequently the
|
||||
installed-Snap smoke validates the actual confinement and shared-memory
|
||||
lifecycle required by playback, not only direct helper execution. The smoke
|
||||
first disconnects `graphics-core22` and requires the application diagnostic to
|
||||
emit `usable:false` with reason `snap-graphics-provider-unavailable` and
|
||||
controlled exit code `1`; it then reconnects the provider and requires the
|
||||
same diagnostic to succeed.
|
||||
Packaged addon, frame-reader, and helper discovery is limited to package-owned
|
||||
`app.asar.unpacked` resource locations and never falls through to writable
|
||||
cwd/dist development paths. Those fallbacks are development-only. A disabled
|
||||
base experiment or any failed capability check keeps the renderer sandbox
|
||||
enabled and falls back to the native engine. The result is cached by
|
||||
helper/manifest identity for the process lifetime, so the startup and service
|
||||
gates cannot disagree.
|
||||
Changing the toggle requires an app restart because web preferences are fixed
|
||||
at window creation.
|
||||
|
||||
@@ -232,14 +407,12 @@ the executable resolves it from its own directory. The after-pack hook
|
||||
restores the POSIX helper's executable mode after the asset copy, and
|
||||
optional/skipped native rebuilds remove stale helper/reader artifacts before
|
||||
reporting frame-copy availability. This cleanup prevents known leftover build
|
||||
output; it is not a compatibility check for a complete but version-mismatched
|
||||
runtime pair. Linux packages deliberately do NOT ship the helper yet: it links
|
||||
the build host's system `libmpv`, which packaged apps cannot assume is
|
||||
installed, so centralized after-pack preparation strips both possible helper
|
||||
basenames and package validation rejects either one if it survives. The
|
||||
support probe therefore reports frame-copy unavailable in Linux packages.
|
||||
The engine is dev-build-only on Linux until bundled-libmpv runtime staging
|
||||
lands (PORTING.md milestone 4).
|
||||
output; the manifest and runtime probe are the compatibility check for a
|
||||
complete Linux runtime. Linux x64 packages retain the helper and frame reader:
|
||||
system packages resolve the declared `libmpv.so.2` through their package
|
||||
manager, while portable and sandboxed profiles resolve the source-built closure
|
||||
through `$ORIGIN/lib`. Foreign-architecture packages remove all native
|
||||
artifacts and contain only the unavailable marker.
|
||||
|
||||
Trade-offs and constraints:
|
||||
|
||||
@@ -251,12 +424,12 @@ Trade-offs and constraints:
|
||||
MessagePort (costs one extra copy + GC churn since Electron ports clone
|
||||
ArrayBuffers) or a WebCodecs-based path.
|
||||
- Scope: on macOS Apple Silicon only by owner decision (2026-07-10);
|
||||
Intel Macs keep the native-view engine. Linux (any arch) is ported —
|
||||
Intel Macs keep the native-view engine. Official Linux frame-copy is x64 —
|
||||
headless EGL, works under native Wayland since nothing embeds into a
|
||||
window; dev builds need `libmpv-dev`, `libegl-dev`, `libgl-dev`,
|
||||
`libopengl-dev` and `libgbm-dev` (the helper links system libmpv, which
|
||||
is legal out-of-process — the in-process libmpv ban still binds the
|
||||
addon). The helper logs the chosen EGL display tier and the GL renderer
|
||||
window; local system builds need `libmpv-dev`, `libegl-dev`, `libgl-dev`,
|
||||
and `libgbm-dev`. The helper links libmpv, which is legal
|
||||
out-of-process; the in-process-libmpv ban still binds the addon and frame
|
||||
reader. The helper logs the chosen EGL display tier and the GL renderer
|
||||
string to stderr. If an early tier selects Mesa software rendering (for
|
||||
example, while a proprietary NVIDIA driver is reachable through the default
|
||||
display or GBM), it probes the remaining tiers and uses software only when
|
||||
@@ -362,7 +535,7 @@ player component stays a view-oriented orchestrator and engine-specific
|
||||
controls host. The renderer files live under
|
||||
`libs/ui/playback/src/lib/embedded-mpv-player/`:
|
||||
|
||||
- `embedded-mpv-format.utils.ts` — pure helpers (`formatTime`, `audioTrackLabel`, `subtitleTrackLabel`, `speedLabel`, `aspectLabel`, `volumeIcon`, `volumeLabel`, `readStoredVolume`, `persistVolume`, `measureBounds`) and preset constants (`SPEED_PRESETS`, `ASPECT_PRESETS`, `HIDDEN_BOUNDS`, `MENU_OPEN_BOTTOM_CUTOUT_PX`).
|
||||
- `embedded-mpv-format.utils.ts` — pure helpers (`formatTime`, `audioTrackLabel`, `subtitleTrackLabel`, `speedLabel`, `aspectLabel`, `volumeIcon`, `volumeLabel`, `readStoredVolume`, `persistVolume`, `measureBounds`) and preset constants (`SPEED_PRESETS`, `ASPECT_PRESETS`, `HIDDEN_BOUNDS`).
|
||||
- `embedded-mpv-controls.adapter.ts` — component-scoped `PlayerController`
|
||||
adapter for frame-copy. Maps session/support/playback signals to shared
|
||||
controls state and capabilities, delegates commands to
|
||||
@@ -383,7 +556,9 @@ controls host. The renderer files live under
|
||||
- `embedded-mpv-shortcuts.ts` — native-view-only `EmbeddedMpvShortcuts` class
|
||||
with `attach(handlers)` / `detach()`. Owns the legacy document keydown
|
||||
listener and routes through a callback interface; the component supplies
|
||||
callbacks for Space/K, F, arrow keys, M, and Escape.
|
||||
callbacks for Space/K, F, arrow keys, M, and Escape. The optional
|
||||
`arrowKeysBlocked` handler suspends the seek/volume arrows while a dock
|
||||
chip panel owns them for chip navigation.
|
||||
- `embedded-mpv-overlay-visibility.service.ts` — singleton service that exposes
|
||||
`overlayActive: signal<boolean>`. Tracks `MatDialog.afterOpened`/
|
||||
`afterAllClosed` for dialog-shaped overlays and falls back to a
|
||||
@@ -391,9 +566,21 @@ controls host. The renderer files live under
|
||||
backdrop-bearing CDK overlays. Native-view uses it to move the platform host
|
||||
off-screen; frame-copy uses it to gate shared playback shortcuts.
|
||||
- `embedded-mpv-ui-state.ts` — legacy native-view
|
||||
`EmbeddedMpvMenuState` (single-open popover state machine) and
|
||||
`EmbeddedMpvMenuState` (single-open menu state machine, incl. the
|
||||
`dockPanelOpen` chip-panel signal that suspends arrow shortcuts) and
|
||||
`EmbeddedMpvFeedback` (transient keypress feedback). They are not the
|
||||
frame-copy shared-controls state.
|
||||
- `embedded-mpv-dock-panels.ts` — native-view `EmbeddedMpvDockPanelState`:
|
||||
builds the active horizontal chip-panel view model (audio, subtitle, speed,
|
||||
aspect) from the menu state, routes chip selection back to the session
|
||||
controller, and restores toggle-button focus after a panel closes.
|
||||
- `embedded-mpv-dock-panel.component.ts` — standalone
|
||||
`app-embedded-mpv-dock-panel` that morphs the dock row inside the
|
||||
fixed-height controls strip: back button + title + horizontally scrollable
|
||||
chip ribbon (`role="menu"` with `aria-orientation="horizontal"`,
|
||||
`menuitemradio` chips, wheel-to-horizontal-scroll mapping, edge fades,
|
||||
active-chip reveal/focus, roving arrow keys, RTL-aware). Keeping the panels
|
||||
inside the strip is what lets menus open without any MPV bounds change.
|
||||
- `embedded-mpv-command-runner.ts` — transport/track/recording IPC delegation; contains addon-side throws; reconciles a returned snapshot only when the current canonical session id and returned snapshot id both match the captured command session id.
|
||||
- `embedded-mpv-session-factory.ts` — side-effect-free loading/error placeholder factories plus `waitForStartupPaint`.
|
||||
- `embedded-mpv-stalled-tracker.ts` — owns the 30-second loading timer and `stalled` signal.
|
||||
@@ -407,24 +594,71 @@ controls host. The renderer files live under
|
||||
|
||||
### Bounds compositing strategy
|
||||
|
||||
The following cutout strategy applies only to the native-view engine. Its video
|
||||
The following strategy applies only to the native-view engine. Its video
|
||||
host paints outside the normal DOM stacking model, so any DOM region it covers
|
||||
cannot reliably receive pointer events and any CSS `z-index` competition is
|
||||
unwinnable. The component compensates with a single `boundsProvider(host)`
|
||||
closure on the controller that returns one of three bound shapes, evaluated
|
||||
closure on the controller that returns one of two bound shapes, evaluated
|
||||
each time the active bounds-sync runs:
|
||||
|
||||
- **Modal overlay open** (any MatDialog, including the command palette) → `HIDDEN_BOUNDS`. The MPV video host moves off-screen so the dialog has the full window.
|
||||
- **Control popover open** (any of the menu states above) → host bounds with `MENU_OPEN_BOTTOM_CUTOUT_PX` (300 px) removed from the bottom. The popover region becomes DOM-receiving while video keeps playing in the upper region.
|
||||
- **Idle** → full host bounds.
|
||||
- **Otherwise** → full host bounds.
|
||||
|
||||
The viewport DOM element also reserves `--embedded-mpv-controls-height` (64 px) at the bottom when controls are enabled, so the controls strip itself is always DOM and always reachable for hover-to-reveal even before the popover-cutout takes effect.
|
||||
Control menus never influence bounds: all five (volume, audio, subtitle,
|
||||
speed, aspect) render horizontally inside the fixed-height controls strip
|
||||
below the video host. Volume expands as an inline horizontal slider next to
|
||||
the mute button; the audio/subtitle/speed/aspect menus morph the dock row
|
||||
into `app-embedded-mpv-dock-panel` — back button, panel title, and a
|
||||
horizontally scrollable chip ribbon (vertical wheel mapped to horizontal
|
||||
scroll, edge fades as continuation hints, auto-reveal and focus of the active
|
||||
chip, roving arrow-key navigation, RTL-aware). Because the strip height never
|
||||
changes, opening or closing a menu sends no new MPV bounds and the video never
|
||||
re-letterboxes. The popover-era 300 px bottom cutout
|
||||
(`MENU_OPEN_BOTTOM_CUTOUT_PX`) is gone; while a chip panel is open, the
|
||||
global arrow-key shortcuts (seek/volume) are suspended so arrows walk the
|
||||
chips instead.
|
||||
|
||||
The viewport DOM element reserves `--embedded-mpv-controls-height` (64 px;
|
||||
88 px under the narrow breakpoint) at the bottom when controls are enabled, so
|
||||
the controls strip — including the in-dock panels — is always DOM and always
|
||||
reachable for hover-to-reveal.
|
||||
|
||||
For frame-copy, `boundsProvider` always returns the measured full host bounds:
|
||||
there is no `HIDDEN_BOUNDS`, popover cutout, or reserved dock height. Dialogs
|
||||
there is no `HIDDEN_BOUNDS` or reserved dock height. Dialogs
|
||||
and controls layer naturally over the canvas, while bounds sync still updates
|
||||
the helper's render size.
|
||||
|
||||
### Coordinate spaces (CSS → native units)
|
||||
|
||||
The renderer measures bounds in CSS pixels (`getBoundingClientRect()`), but
|
||||
the native-view engines position OS windows, not DOM nodes: the win32 child
|
||||
`HWND` (`SetWindowPos`) and the Linux child X11 window (`XMoveResizeWindow`)
|
||||
live in physical pixels, and the macOS `NSView` (`setFrame`) lives in points
|
||||
(device-independent pixels). CSS values match points only at 100% page zoom
|
||||
and match physical pixels only at 100% page zoom AND 100% display scale.
|
||||
`EmbeddedMpvNativeService` therefore converts every native-view bounds payload
|
||||
in the main process (`toNativeViewBounds` in `embedded-mpv-bounds.util.ts`):
|
||||
all platforms scale by the webContents zoom factor, win32/linux additionally
|
||||
by the scale factor of the display hosting the window. The renderer sends
|
||||
unrounded CSS edges (`measureBounds` does not round) and the conversion
|
||||
rounds exactly once, after scaling — edges first, width/height derived from
|
||||
them — so fractional CSS layouts and fractional scales cannot open 1px
|
||||
seams against the surrounding DOM UI. Skipping this conversion is issue #1145: on scaled
|
||||
displays (Windows 125%, Linux fractional scaling, HiDPI TVs) the video landed
|
||||
toward the window's top-left corner at `1/scale` of its size, in windowed and
|
||||
fullscreen mode alike.
|
||||
|
||||
Frame-copy bounds bypass the conversion: the canvas is laid out by the DOM in
|
||||
CSS pixels, and the frame-copy adapter already multiplies the render size by
|
||||
the display scale factor itself.
|
||||
|
||||
Because a monitor change can rescale this mapping without resizing the host
|
||||
element (moving the window to a display with a different scale keeps the DIP
|
||||
layout), the session controller also watches `devicePixelRatio` through a
|
||||
re-armed `matchMedia('(resolution: …dppx)')` query and re-syncs bounds when
|
||||
it changes; page zoom changes are covered by the same watch plus the ordinary
|
||||
resize-driven syncs.
|
||||
|
||||
### Controls ownership by engine
|
||||
|
||||
`EmbeddedMpvPlayerComponent` selects one control owner from
|
||||
@@ -491,39 +725,84 @@ The Electron main process holds an `electron.powerSaveBlocker` of type `prevent-
|
||||
Current development behavior:
|
||||
|
||||
- The addon build supports `darwin`, `win32`, and `linux`; Windows and Linux builds require running on that target OS.
|
||||
- The build script first looks for staged inputs at `vendor/embedded-mpv/<platform>-<arch>/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries; `LIBMPV_INCLUDE_DIR` and `LINUX_NATIVE_LIBRARY_DIR` override the default system paths.
|
||||
- When the staged-input path is used, it must contain `include/mpv/client.h` and `runtime-manifest.json`. macOS and Windows staging also contains the platform runtime files that are bundled into the app.
|
||||
- The build script first looks for staged inputs at `vendor/embedded-mpv/<platform>-<arch>/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries. `LIBMPV_INCLUDE_DIR` overrides the header root. `LINUX_NATIVE_LIBRARY_DIR` is a link-time override and must name a directory already visible to the system dynamic loader; it is never inherited as helper `LD_LIBRARY_PATH`.
|
||||
- When the staged-input path is used, it must contain `include/mpv/client.h`,
|
||||
`runtime-manifest.json`, and the platform runtime/build files. The Linux
|
||||
source builder also stages the complete declared `.so` closure.
|
||||
- The compiled `.node` addon is copied into `dist/apps/electron-backend/native/embedded_mpv.node`.
|
||||
- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/` for macOS and Windows. macOS copies `.dylib` and non-`.dylib` Mach-O dependencies; Windows copies the staged `mpv-2.dll`/`libmpv-2.dll`/`mpv.dll`/`libmpv.dll` runtime name plus import libraries. Linux writes an `external-mpv-process` manifest and intentionally leaves `libmpv.so` out of the package.
|
||||
- Linux does not bundle or load `libmpv` in the Electron process. The addon can compile against staged or system-development MPV headers. Its native engine still depends on an X11/Xwayland window handle plus an `mpv` executable on `PATH`; the dev-only frame-copy helper is a separate process linked to system `libmpv` and renders through headless EGL, so it bypasses those native-engine prerequisites.
|
||||
- Bundled runtime files are copied into
|
||||
`dist/apps/electron-backend/native/lib/`. macOS copies `.dylib` and
|
||||
non-`.dylib` Mach-O dependencies; Windows copies the staged
|
||||
`mpv-2.dll`/`libmpv-2.dll`/`mpv.dll`/`libmpv.dll` runtime name plus import
|
||||
libraries. Linux source-runtime builds copy only the manifest-declared
|
||||
closure.
|
||||
- Linux never bundles or loads libmpv in the Electron process. The native-view
|
||||
addon remains X11/process-only; the inverse rule applies to frame-copy:
|
||||
`iptvnator_mpv_helper` must link exactly the declared `libmpv.so.2`, while
|
||||
the addon and frame reader must not.
|
||||
- `afterPack` copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` on macOS, Windows, and Linux so the addon, manifest, and runtime libraries are filesystem-addressable.
|
||||
- Electron Builder excludes `electron-backend/native{,/**/*}` from `app.asar`;
|
||||
package verification rejects any archived native entry so `afterPack`
|
||||
remains the single profile-aware owner.
|
||||
|
||||
Current release caveat:
|
||||
Linux release profiles:
|
||||
|
||||
- Release packaging requires a `vendored-lgpl` runtime manifest on macOS and Windows, and an `external-mpv-process` manifest on Linux.
|
||||
- The Linux addon is built once per CI host architecture (x64). Linux packages for other architectures (arm64, armv7l) must not ship that foreign addon: `afterPack` replaces the native directory with an `embedded-mpv-unavailable.txt` marker explaining that embedded MPV is not bundled for that architecture, and package-layout verification rejects a foreign-architecture `embedded_mpv.node` while requiring the marker.
|
||||
- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=system` builds DEB, RPM, and Pacman.
|
||||
`afterPack` removes the private `lib` directory, writes a
|
||||
`system-libmpv-frame-copy` manifest, and package metadata requires the exact
|
||||
libmpv plus EGL/GL/GBM package set listed above. The DEB path is verified
|
||||
on Ubuntu 24.04+; Ubuntu 22.04 users need the x64 AppImage because Jammy only
|
||||
provides `libmpv1`.
|
||||
- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=portable` builds AppImage and Snap with
|
||||
the pinned source-built closure and a `bundled-lgpl-frame-copy` manifest.
|
||||
- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=flatpak` builds Flatpak with the same
|
||||
source-built closure and manifest origin. Its app-level probe reconstructs
|
||||
only the exact Freedesktop 24.08 EGL external-platform search path inside the
|
||||
trusted `/app` payload.
|
||||
- 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.
|
||||
- Linux packages for other architectures (arm64, armv7l) must not ship x64
|
||||
native artifacts. `afterPack` replaces the native directory with
|
||||
`embedded-mpv-unavailable.txt`, and package verification requires that marker.
|
||||
- Every packaged manifest names its exact artifacts, profile/targets, libmpv
|
||||
SONAME, loader closure, byte sizes, SHA-256 hashes, package dependencies, and
|
||||
native-view fallback. Artifact modes and ELF dependency isolation are
|
||||
verified after packaging.
|
||||
- macOS release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`.
|
||||
- Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present, no bundled `libmpv.so` files slipped into the package, and no development-only frame-copy helper survived `afterPack`.
|
||||
- Windows release packaging verifies that the platform runtime file is present
|
||||
when Embedded MPV is required.
|
||||
- Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin.
|
||||
|
||||
Before public release, packaging must:
|
||||
Release packaging must:
|
||||
|
||||
- stage an LGPL-compatible `libmpv` runtime for each macOS/Windows release platform/architecture, and stage Linux MPV headers/build metadata for Linux
|
||||
- stage an LGPL-compatible libmpv runtime for each bundled release
|
||||
platform/architecture, including the pinned Linux x64 source runtime
|
||||
- collect indirect macOS dependencies expressed as absolute paths, `@loader_path`, or `@rpath`
|
||||
- rewrite macOS install names and dependency paths to app-relative paths such as `@loader_path`
|
||||
- code-sign and notarize the full macOS dependency set
|
||||
- ensure Windows runtime staging includes both the DLL and the import library used by `node-gyp`
|
||||
- ensure Linux native builds do not gain a direct `libmpv` dependency; the runtime playback path is `mpv --wid` in a separate process
|
||||
- publish the corresponding FFmpeg/libmpv source and build metadata for bundled macOS/Windows runtimes; Linux should document the distribution package versions used as build inputs
|
||||
- ensure Linux Electron/addon/reader binaries do not gain a direct libmpv
|
||||
dependency and the helper does
|
||||
- execute the helper capability probe in each intended x64 package environment
|
||||
- publish corresponding source archives, git/submodule records, checksums,
|
||||
exact flags, local patches, and build scripts for every bundled runtime
|
||||
|
||||
Users on macOS and Windows do not need the MPV GUI application for this architecture. Linux currently requires an `mpv` executable because the supported backend is process-isolated. If the native addon/runtime prerequisites or Linux `mpv` executable are missing, embedded MPV is hidden/unsupported and the existing inline/external players remain available.
|
||||
Users on macOS and Windows do not need the MPV GUI application for this
|
||||
architecture. Linux native-view still requires an `mpv` executable. Linux
|
||||
frame-copy system packages need their declared libmpv and EGL/GL/GBM
|
||||
packages, while AppImage, Snap, and Flatpak carry their own runtime closure.
|
||||
If frame-copy prerequisites are missing, x64 falls back to native-view; if all
|
||||
Embedded MPV prerequisites are unavailable, the existing inline/external
|
||||
players remain available.
|
||||
|
||||
## Runtime Staging
|
||||
|
||||
Runtime staging tooling lives in:
|
||||
|
||||
- `/Users/4gray/Code/iptvnator/tools/embedded-mpv/`
|
||||
- `/Users/4gray/Code/iptvnator/vendor/embedded-mpv/`
|
||||
- `tools/embedded-mpv/`
|
||||
- `vendor/embedded-mpv/`
|
||||
|
||||
Release runtime policy:
|
||||
|
||||
@@ -547,11 +826,83 @@ pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix
|
||||
pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix
|
||||
```
|
||||
|
||||
During temporary PR and `master` artifact testing, CI can restore an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/<platform>-<arch>/` runtime and skip the expensive source build or archive staging path where one exists. The cache only contains `include/`, `lib/`, and `runtime-manifest.json`; it never contains the compiled `embedded_mpv.node` addon because that target depends on Electron headers, ABI, architecture, and build environment. Runtime cache entries are saved only from trusted repository refs, and tagged public macOS release builds continue to rebuild from pinned sources until a dedicated signed and attested runtime artifact flow exists. Windows CI uses a checksum-pinned `win32-x64` runtime archive configured through `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL` and `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256` repository variables or secrets on cache miss. Non-tag artifact builds have a pinned `zhongfly/mpv-winbuild` `mpv-dev-lgpl-x86_64` fallback so PR builds can produce a Windows embedded MPV artifact before repository variables are configured; tagged releases still require explicit repository configuration. The Windows archive helper accepts normal `lib/` + `bin/` prefixes and common `mpv-dev-lgpl` flat archives, including `libmpv-2.dll` names, and preserves the DLL basename expected by the import library; when the archive does not include `runtime-manifest.json`, it generates a minimal manifest from the archive URL/path and checksum. Linux stages Ubuntu package build inputs only; adding pinned source builders for Windows and Linux remains a separate release-hardening task.
|
||||
Linux x64 builds the release runtime from pinned source inputs and stages it
|
||||
before compiling the helper:
|
||||
|
||||
The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes macOS/Windows manifests to `origin: vendored-lgpl`, which release package validation requires on those platforms.
|
||||
```bash
|
||||
pnpm embedded-mpv:build-runtime:linux -- /tmp/embedded-mpv-linux-prefix
|
||||
pnpm embedded-mpv:stage-runtime -- linux x64 /tmp/embedded-mpv-linux-prefix
|
||||
```
|
||||
|
||||
The Electron backend build consumes the staged runtime/build inputs and copies macOS/Windows runtime files into the native build output. Linux consumes staged MPV headers when available or distribution development headers for local builds, writes an `external-mpv-process` manifest, and does not copy `libmpv.so` into the package. macOS additionally rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later.
|
||||
The Linux builder is intentionally host-restricted to Linux x64. It checks
|
||||
minimum build-tool versions, uses an owned staging directory plus atomic
|
||||
publish, rejects host pkg-config/runtime leakage, rewrites every bundled
|
||||
library to an `$ORIGIN` RUNPATH, and enforces the portable ABI ceilings
|
||||
`GLIBC_2.35` and `GLIBCXX_3.4.30`. It also verifies the exact libmpv SONAME,
|
||||
complete dependency closure, and absence of build-prefix paths.
|
||||
|
||||
CI may restore exact-keyed caches for staged
|
||||
`vendor/embedded-mpv/<platform>-<arch>/` runtimes. The Linux cache contains
|
||||
only generated headers, libraries, the runtime manifest, and immutable source
|
||||
inputs: exact archives, a clean recursive libplacebo checkout, and collected
|
||||
license inputs. It never contains `embedded_mpv.node`, generated notices, or
|
||||
the finished source-compliance archive. Runtime cache entries are saved only
|
||||
from trusted repository refs. The Linux cache key covers the builder/stager,
|
||||
notice generator, pinned sources, and toolchain. On every run, including a
|
||||
cache hit, CI validates the cached hashes and clean checkout, regenerates the
|
||||
notices for the current runtime manifest, converts libplacebo into a
|
||||
VCS-metadata-free working-tree snapshot while retaining the validated
|
||||
commit/submodule record, and creates
|
||||
`linux-frame-copy-runtime-sources.tar.xz` for the current repository revision
|
||||
and binary diff with normalized tar metadata.
|
||||
|
||||
The workflow keeps the macOS/Windows package matrix independent from the Linux
|
||||
runtime prerequisite. Only the three Linux profile jobs depend on the runtime
|
||||
builder; both matrices reuse one YAML-anchored step list to prevent packaging
|
||||
logic drift. Draft release assembly still requires both matrices, so a public
|
||||
release cannot silently omit a promised platform.
|
||||
|
||||
Windows CI uses a checksum-pinned `win32-x64` runtime archive configured
|
||||
through `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL` and
|
||||
`IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256`. Non-tag artifact builds have
|
||||
a pinned `zhongfly/mpv-winbuild` `mpv-dev-lgpl-x86_64` fallback; tagged
|
||||
releases require explicit repository configuration. Upstream retains only its
|
||||
latest 30 daily builds, so the fallback and any repository-variable copy must
|
||||
be refreshed as one URL/checksum pair before expiry. A long-lived mirror must
|
||||
publish the matching source/build records and license notices with the binary.
|
||||
The archive helper accepts normal `lib/` + `bin/` prefixes and common flat
|
||||
archives, preserves the DLL basename encoded by the import library, and
|
||||
generates minimal build metadata only when the archive lacks it.
|
||||
|
||||
The Linux builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`,
|
||||
libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, HarfBuzz `8.5.0`,
|
||||
Expat `2.8.2`, Fontconfig `2.16.0`, OpenSSL `3.5.7`, hwdata `0.409`, and
|
||||
libdisplay-info `0.1.1`. FFmpeg disables autodetected external libraries.
|
||||
Libplacebo is checked out at an exact git commit with all required submodules.
|
||||
The hwdata archive and its `pnp.ids` build input are pinned so
|
||||
libdisplay-info cannot silently consume `/usr/share/hwdata` from the builder.
|
||||
The generated manifest records source URLs/checksums or git commits,
|
||||
submodules, licenses, exact flags, build-host/toolchain data, runtime hashes,
|
||||
and the dynamic closure. FFmpeg/mpv remain LGPL-compatible and dynamically
|
||||
linked; codecs outside that build configuration are not implied.
|
||||
|
||||
`generate-linux-runtime-notices.cjs` collects the exact upstream license files
|
||||
for every pinned package and all recursive libplacebo submodules. Generation
|
||||
is fail-closed for a missing, undeclared, symlinked, size-mismatched, or
|
||||
hash-mismatched file. Portable and Flatpak package hooks copy only the
|
||||
validated notice manifest, aggregate notice, and per-package license tree;
|
||||
package-layout verification revalidates that legal payload against the
|
||||
embedded source-runtime manifest.
|
||||
|
||||
The Electron backend build consumes the staged runtime/build inputs. On Linux
|
||||
it links the helper against the verified staged `libmpv.so.2`, never against a
|
||||
generic host `-lmpv`, then verifies the helper's `DT_NEEDED` and
|
||||
`$ORIGIN/lib` RUNPATH with `readelf`. The addon and frame reader are checked
|
||||
for the opposite invariant. The profile-aware packaging hook later retains or
|
||||
removes the private closure. Local Linux builds may still use distribution
|
||||
headers/libraries, but a required package build must use the staged manifest.
|
||||
macOS additionally rewrites Mach-O paths and re-signs modified local binaries;
|
||||
release signing/notarization still happens later.
|
||||
|
||||
For local development before the vendored runtime exists, Homebrew can be used explicitly:
|
||||
|
||||
@@ -593,7 +944,88 @@ For tagged macOS builds, CI must:
|
||||
|
||||
For Windows builds, CI must restore the `win32-x64` staged runtime cache or stage the checksum-pinned runtime archive before `pnpm run build:backend`. The Windows job must set `IPTVNATOR_EMBEDDED_MPV_PLATFORM=win32`, `IPTVNATOR_EMBEDDED_MPV_ARCH=x64`, and `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for backend build, package make, and package-layout verification. CI narrows `electron-builder.json` to x64 Windows targets while only a `win32-x64` runtime is available. The Windows job is pinned to `windows-2022` until the Electron `node-gyp` toolchain can identify Visual Studio 18 from `windows-latest`.
|
||||
|
||||
For Linux builds, CI must set `IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux`, `IPTVNATOR_EMBEDDED_MPV_ARCH=x64`, and `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` after staging the Ubuntu package build inputs. Linux package verification checks the `external-mpv-process` manifest and confirms that no bundled `libmpv.so` files are present.
|
||||
For Linux builds, CI first builds or restores the pinned x64 source runtime and
|
||||
stages it under `vendor/embedded-mpv/linux-x64`. It then runs three isolated
|
||||
packaging passes with `IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux`,
|
||||
`IPTVNATOR_EMBEDDED_MPV_ARCH=x64`,
|
||||
`IPTVNATOR_REQUIRE_EMBEDDED_MPV=1`, and one exact
|
||||
`IPTVNATOR_LINUX_FRAME_COPY_PROFILE`. Each produced artifact is extracted and
|
||||
verified, and the x64 helper probe runs in the intended runtime environment.
|
||||
The packaged x64 Playwright smoke first runs its fixture-contract target and
|
||||
passes Chromium `--ignore-gpu-blocklist` so Mesa llvmpipe can expose WebGL2 in
|
||||
CI. That launch-only flag does not bypass any manifest, hash, loader, or helper
|
||||
capability check; `--no-sandbox` is added only when the runner is root.
|
||||
Bundled package layouts must include the generated notices and exact license
|
||||
tree. The separately uploaded
|
||||
`linux-frame-copy-runtime-sources.tar.xz` contains the exact archive set,
|
||||
the VCS-metadata-free libplacebo working tree plus the exact pinned commit and
|
||||
six recursive submodule records, notice/license inputs, runtime metadata,
|
||||
current revision/diff, and build tooling. Each submodule record is canonical
|
||||
`full-commit safe/path`; clone-depth-dependent `git describe` annotations are
|
||||
discarded. The source index also records a
|
||||
globally sorted exact inventory
|
||||
of every libplacebo directory, regular file, and symlink. File hashes, sizes,
|
||||
normalized executable bits, link targets, aggregate counts/bytes, and the
|
||||
canonical inventory digest must match the trusted pinned v7.360.1 checkout;
|
||||
an arbitrary or incomplete self-declared tree is rejected. Its tar metadata is
|
||||
normalized, its member/type layout is exact, and
|
||||
`metadata/archive-sha256.txt` must describe the actual source archive bytes.
|
||||
Tar listing continues past every end marker, so concatenated xz/tar streams
|
||||
cannot hide undeclared members. ARM artifacts are independently verified as
|
||||
marker-only and never run the x64 helper.
|
||||
|
||||
After constructing the final
|
||||
`linux-frame-copy-runtime-sources.tar.xz`, CI hashes its exact bytes and stages
|
||||
`source-archive-binding.json` beside the x64 runtime. AppImage, Snap, and
|
||||
Flatpak manifests copy that binding unchanged as `sourceArchive`, including the
|
||||
SHA-256 and repository revision. System-package manifests and marker-only
|
||||
non-x64 packages must not carry it, so a portable package cannot advertise
|
||||
source correspondence inherited from another profile or architecture.
|
||||
|
||||
The build workflow creates a draft GitHub release but never publishes Snap in
|
||||
parallel with that draft. The separate Snap workflow runs only for a public
|
||||
`release.published` event whose tag starts with `v`; before any Store upload it
|
||||
requires at least one exact `.snap` asset and exactly one non-empty
|
||||
`linux-frame-copy-runtime-sources.tar.xz` in that public release. It hashes and
|
||||
safely inspects the bounded downloaded archive, requires regular metadata,
|
||||
archive, legal, and tooling member/type set, validates link targets and the
|
||||
archive checksum metadata, and requires the clean checkout and source index to
|
||||
match the released tag. It verifies the actual pinned source-member hashes,
|
||||
the six recursive libplacebo submodule records, license-input and notice
|
||||
hashes, the exact VCS-free libplacebo tree inventory/digest, and byte-identical
|
||||
tooling from the released tag. Checkout and both artifact-transfer actions use
|
||||
full pinned commits, and checkout sets `persist-credentials: false`.
|
||||
The bounded SquashFS preflight and extraction then require the canonical
|
||||
`/usr/lib/iptvnator` layout and reuse the static package validator for every
|
||||
selected Snap. The public-release verifier separately reapplies the exact
|
||||
strict `meta/snap.yaml` graphics/shared-memory/layout contract and enumerates
|
||||
the extracted `resources/app.asar`; any archived
|
||||
`electron-backend/native/**` entry fails before Store publication. The bounded
|
||||
ASAR header reader uses only Node built-ins plus released local tooling, keeping
|
||||
this check runnable from the clean tag checkout without `node_modules`. Exactly
|
||||
one x64 Snap must contain a bundled portable manifest
|
||||
whose exact `sourceArchive` and `sourceRuntime` match the archive; non-x64
|
||||
Snaps must remain marker-only. Repository credentials are scoped to the two
|
||||
GitHub asset steps. The secretless verification job copies downloaded files
|
||||
through no-follow descriptors into a private snapshot, hashes them before and
|
||||
after inspection, writes an exact receipt, root-seals the snapshot, and reruns
|
||||
the complete source/package verifier against those bytes. It then transfers
|
||||
only the sealed data through the pinned artifact service, publishes the exact
|
||||
receipt digest separately as a job output, and terminates.
|
||||
|
||||
The dependent publish job runs on a bounded GitHub-hosted `ubuntu-latest`
|
||||
runner with no checkout or release-tag code. It requires the separately
|
||||
transmitted receipt digest, validates the exact receipt schema and every asset
|
||||
size/hash, accepts only the expected regular `.snap`, source archive, and
|
||||
receipt layout, rejects links and extra entries, and root-seals the transferred
|
||||
files again before installing the official stable Snapcraft snap.
|
||||
Only its final fixed shell step receives the Store credential. That step uses a
|
||||
bounded Bash glob, resolves no PATH command, executes no released code, and
|
||||
passes the credential only to each exact
|
||||
`/snap/bin/snapcraft upload --release=edge` process. Any verification or
|
||||
transfer mismatch aborts before Store credentials are available.
|
||||
Candidate/stable promotion is manual after installed-Snap frame-copy and
|
||||
missing-runtime fallback smoke; GitHub Actions never promotes automatically.
|
||||
|
||||
During temporary artifact tests, CI may also set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for PR and `master` push jobs where a runtime is known to exist. After the artifacts are manually validated, remove temporary conditions so ordinary development builds leave `IPTVNATOR_REQUIRE_EMBEDDED_MPV` unset or `0`. This keeps the native feature in-tree without making every non-release build depend on runtime artifacts.
|
||||
|
||||
@@ -605,7 +1037,8 @@ The feature is still experimental. The largest risks are native-process risks, n
|
||||
- packaging can fail if `libmpv` or one of its platform runtime dependencies is missing, unsigned where signing applies, or linked to the wrong runtime path
|
||||
- macOS graphics behavior can vary across Intel, Apple Silicon, external displays, fullscreen transitions, and hardware decoding paths
|
||||
- Windows `HWND` and Linux X11/Xwayland embedding need packaged-app smoke coverage for focus, resize, and fullscreen behavior
|
||||
- Linux native Wayland is unsupported until a dedicated Wayland embedding path exists
|
||||
- Linux native-view remains unsupported on native Wayland; frame-copy has no
|
||||
window-embedding dependency but still requires a working EGL probe
|
||||
- Homebrew `libmpv` builds can target a newer macOS version than IPTVnator's declared deployment target
|
||||
|
||||
It is reasonable to ship the code in-tree behind the current experiment flag. It is not yet safe to make it the default player. It can be exposed as desktop experimental if support detection is strict, the UI clearly labels it experimental, and fallback to Video.js or external MPV/VLC stays available.
|
||||
@@ -616,8 +1049,18 @@ If an embedded session fails to initialize, the app should keep the user in cont
|
||||
|
||||
Do not expose embedded MPV broadly until these pass on every supported target:
|
||||
|
||||
- macOS/Windows packaged app starts without system `mpv` installed; Linux reports Embedded MPV unsupported with a clear message when system `mpv` is missing
|
||||
- bundled `libmpv` and dependent runtime files pass macOS/Windows package validation; Linux package validation confirms the external-process manifest and absence of bundled `libmpv.so`
|
||||
- macOS/Windows packaged apps start without system `mpv`; Linux x64
|
||||
frame-copy starts in each declared package profile, and a missing frame-copy
|
||||
dependency falls back without crashing
|
||||
- bundled libmpv and dependent runtime files pass package validation;
|
||||
DEB/RPM/Pacman contain no private closure and declare the exact system
|
||||
dependency
|
||||
- Electron, its shipped libraries, `embedded_mpv.node`, and the frame reader
|
||||
have no direct libmpv `DT_NEEDED`; the helper resolves the exact declared
|
||||
libmpv runtime
|
||||
- AppImage, DEB, RPM, Pacman, Snap, and Flatpak payloads pass extraction,
|
||||
manifest/mode/ELF checks and the applicable helper probe; ARM payloads are
|
||||
marker-only
|
||||
- macOS bundled `libmpv` and dependent dylibs pass code signing and notarization
|
||||
- VOD resume starts near the saved offset
|
||||
- series EOF emits `ended` and embedded MPV auto-continues only inside the current season
|
||||
|
||||
@@ -88,10 +88,10 @@ Do not add extra badges, left rails, or second selection systems unless there is
|
||||
|
||||
## Detail Views
|
||||
|
||||
VOD and series detail screens share the `detail-view` Sass mixin from
|
||||
`libs/ui/styles/_detail-view.scss`. Feature-local `styles/detail-view.scss`
|
||||
files should only import that mixin and pass small typography overrides when a
|
||||
provider needs them.
|
||||
VOD and series detail screens share the detail-view Sass mixin (`@mixin base`)
|
||||
from `libs/ui/styles/_detail-view.scss`. Feature-local `styles/detail-view.scss`
|
||||
files should only `@use` that module and `@include detail-view.base(...)` with
|
||||
small typography overrides when a provider needs them.
|
||||
|
||||
Do not copy the full detail-view stylesheet into feature libraries. Add shared
|
||||
layout changes to the mixin, and keep provider-specific differences explicit in
|
||||
@@ -201,7 +201,8 @@ The shared row should be reused instead of rebuilding channel markup per view.
|
||||
### EPG Card
|
||||
|
||||
- Radius:
|
||||
`14px`
|
||||
`11px` (`.epg-timeline__block` in
|
||||
`libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss`)
|
||||
- Neutral cards use low-contrast surface treatment
|
||||
- Current card uses selection surface and selection border
|
||||
- Description should clamp rather than overflow
|
||||
|
||||
@@ -42,36 +42,41 @@ The M3U playlist module provides:
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## M3U Parsing (`iptv-playlist-parser` fork)
|
||||
|
||||
All four parse call sites (Electron `playlist-source.ts` import, `playlist-refresh.worker.ts`, `web-backend` `/parse`, PWA `playlists.service.ts`) use the
|
||||
[4gray/iptv-playlist-parser](https://github.com/4gray/iptv-playlist-parser) fork, pinned by commit SHA in `package.json`. The fork tracks upstream
|
||||
`freearhey/iptv-playlist-parser` (currently synced to v0.15.2) plus two deliberate deltas iptvnator depends on:
|
||||
|
||||
- **`radio` attribute** — `item.radio` (string, `'true'` triggers the radio player, EPG suppression, and external-player gating app-wide). Upstream does not have this field; it must survive every upstream sync.
|
||||
- **Pipe stripping** — `item.url` is cut at the first `|`; `|User-Agent=` / `|Referer=` params still land in `item.http`. Upstream 0.15.0 stopped stripping, but iptvnator consumes `item.url` verbatim in hls.js/mpv/vlc, catch-up URL building, and url-keyed favorites.
|
||||
|
||||
There is intentionally **no URL validation** (upstream removed it in 0.15.0): any non-empty non-`#` line after `#EXTINF` becomes the item URL. This is what fixes issue #1189 (Pluto TV JWT URLs longer than validator's 2084-char IE-era limit used to be rejected, and the stalled item index collapsed the whole playlist into one channel). `#` comment lines and unknown directives are appended to `item.raw` and never treated as URLs.
|
||||
|
||||
The behavioral contract is guarded by `apps/web/src/app/iptv-playlist-parser.contract.spec.ts` (jest maps the module to the real parser source) and by the fork's own test suite.
|
||||
|
||||
## State Management (libs/m3u-state/)
|
||||
|
||||
### State Structure
|
||||
|
||||
```typescript
|
||||
// libs/m3u-state/src/lib/state.ts
|
||||
interface PlaylistState {
|
||||
// Active channel being played
|
||||
active: Channel | undefined;
|
||||
active: Channel | undefined; // Active channel being played
|
||||
activePlaybackUrl: string | null;
|
||||
activeEpgProgram: EpgProgram | undefined;
|
||||
currentEpgProgram: EpgProgram | undefined;
|
||||
epgAvailable: boolean;
|
||||
channelsLoading: boolean; // Route still resolving channel data
|
||||
channels: Channel[]; // All channels from current playlist
|
||||
playlists: PlaylistMetaState; // Playlist metadata (entity adapter)
|
||||
}
|
||||
|
||||
// Whether the current route is still resolving channel data
|
||||
channelsLoading: boolean;
|
||||
|
||||
// All channels from current playlist
|
||||
channels: Channel[];
|
||||
|
||||
// EPG state
|
||||
epg: {
|
||||
epgAvailable: boolean;
|
||||
activeEpgProgram: EpgProgram | undefined;
|
||||
currentEpgProgram: EpgProgram | undefined;
|
||||
};
|
||||
|
||||
// Playlist metadata (entity adapter)
|
||||
playlistsMeta: {
|
||||
ids: string[];
|
||||
entities: Record<string, PlaylistMeta>;
|
||||
selectedId: string | undefined;
|
||||
allPlaylistsLoaded: boolean;
|
||||
selectedFilters: PlaylistSourceFilter[];
|
||||
};
|
||||
// libs/m3u-state/src/lib/playlists.state.ts
|
||||
interface PlaylistMetaState extends EntityState<PlaylistMeta> {
|
||||
selectedId: string;
|
||||
allPlaylistsLoaded: boolean;
|
||||
selectedFilters: string[]; // 'm3u' | 'xtream' | 'stalker'
|
||||
}
|
||||
```
|
||||
|
||||
@@ -101,7 +106,7 @@ selectFavorites; // Favorite channel URLs
|
||||
// Playlist selectors
|
||||
selectAllPlaylistsMeta; // All playlists
|
||||
selectActivePlaylistId; // Selected playlist ID
|
||||
selectCurrentPlaylist; // Active playlist object
|
||||
selectActivePlaylist; // Active playlist object
|
||||
selectPlaylistTitle; // Title with "Global favorites" fallback
|
||||
|
||||
// EPG selectors
|
||||
@@ -121,20 +126,25 @@ channel-list-container/
|
||||
├── channel-list-container.component.html
|
||||
├── channel-list-container.component.scss
|
||||
│
|
||||
├── all-channels-tab/ # Virtual scroll + search
|
||||
│ ├── all-channels-tab.component.ts
|
||||
│ ├── all-channels-tab.component.html
|
||||
│ └── all-channels-tab.component.scss
|
||||
├── all-channels-view/ # Virtual scroll + debounced search
|
||||
│ ├── all-channels-view.component.ts
|
||||
│ ├── all-channels-view.component.html
|
||||
│ └── all-channels-view.component.scss
|
||||
│
|
||||
├── groups-tab/ # Expansion panels + infinite scroll
|
||||
│ ├── groups-tab.component.ts
|
||||
│ ├── groups-tab.component.html
|
||||
│ └── groups-tab.component.scss
|
||||
├── groups-view/ # Expansion panels + infinite scroll
|
||||
│ ├── groups-view.component.ts
|
||||
│ ├── groups-view.component.html
|
||||
│ └── groups-view.component.scss
|
||||
│
|
||||
├── favorites-tab/ # Drag-drop reordering
|
||||
│ ├── favorites-tab.component.ts
|
||||
│ ├── favorites-tab.component.html
|
||||
│ └── favorites-tab.component.scss
|
||||
├── favorites-view/ # Drag-drop reordering
|
||||
│ ├── favorites-view.component.ts
|
||||
│ ├── favorites-view.component.html
|
||||
│ └── favorites-view.component.scss
|
||||
│
|
||||
├── recent-view/ # Recently viewed channels
|
||||
│ ├── recent-view.component.ts
|
||||
│ ├── recent-view.component.html
|
||||
│ └── recent-view.component.scss
|
||||
│
|
||||
└── channel-list-item/ # Individual channel display
|
||||
├── channel-list-item.component.ts
|
||||
@@ -222,14 +232,17 @@ channel-list-container/
|
||||
rendering. Playlist order avoids cloning the full list when no search term is
|
||||
active.
|
||||
|
||||
### EnrichedChannel Pattern
|
||||
### ChannelEpgMetadata Pattern
|
||||
|
||||
For performance optimization, channels are pre-enriched with EPG data:
|
||||
For performance optimization, EPG data is kept in a side-car map instead of
|
||||
being cloned onto every channel (the older `EnrichedChannel` pattern that
|
||||
spread-cloned every channel on every ~30 s tick was removed —
|
||||
`channel-list-container/epg-enrichment.util.ts`):
|
||||
|
||||
```typescript
|
||||
interface EnrichedChannel extends Channel {
|
||||
// libs/ui/components/src/lib/channel-list-container/epg-enrichment.util.ts
|
||||
interface ChannelEpgMetadata {
|
||||
epgProgram: EpgProgram | null | undefined;
|
||||
logo: string; // Playlist tvg-logo first, XMLTV icon fallback second
|
||||
progressPercentage: number; // Pre-computed by parent
|
||||
}
|
||||
```
|
||||
@@ -341,7 +354,7 @@ activation, and the details dialog behave identically to the timeline.
|
||||
`isLivePlayback`, `loading`, `emptyReason`, `selectedDate`, `collapsed`,
|
||||
`summary` and emits `programActivated`, `returnToLive`, `selectedDateChange`,
|
||||
`openEpgSettings`, `retry`, `collapsedChange`. The host layout owns playback,
|
||||
persists the collapse state (`liveEpgPanelState` in localStorage), and (for
|
||||
persists the collapse state (`live-epg-panel-state` in localStorage), and (for
|
||||
the M3U player) the `EpgActions.setCurrentEpgProgram` / `setEpgAvailableFlag`
|
||||
/ `setActiveEpgProgram` dispatches. The timeline owns the **single** panel
|
||||
bar — collapse chevron + channel name on the left, return-to-live / jump /
|
||||
@@ -363,7 +376,8 @@ activation, and the details dialog behave identically to the timeline.
|
||||
no-EPG-anywhere states and while loading. Return-to-live is a playback control
|
||||
(`!isLivePlayback()`) and is independent of EPG state.
|
||||
- **State-driven affordances.** Blocks are coloured past / now / future, with a
|
||||
red "now" playhead. Catch-up "Watch" appears on past blocks only when
|
||||
red "now" playhead. Catch-up "Watch" appears on past blocks — and as a
|
||||
start-over replay button on the currently-airing block — only when
|
||||
`archivePlaybackAvailable` (Xtream `tv_archive`, M3U `catchup-*`); Stalker is
|
||||
schedule-only (dimmed past + a notice, no false buttons). The "i" button opens
|
||||
the shared `app-epg-item-description` dialog with a state-aware action.
|
||||
@@ -543,25 +557,25 @@ These URLs are playlist-scoped by default:
|
||||
#### AllChannelsViewComponent
|
||||
|
||||
- **Inputs**: `channels`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `itemSize`, `activeChannelUrl`, `favoriteIds`
|
||||
- **Outputs**: `channelSelected`, `favoriteToggled`
|
||||
- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `favoriteToggled`, `sidebarToggleRequested`
|
||||
- **Features**: Workspace search, persisted channel sorting, virtual scrolling, no-results placeholder
|
||||
|
||||
#### GroupsViewComponent
|
||||
|
||||
- **Inputs**: Same as AllChannelsTab + `groupedChannels`
|
||||
- **Outputs**: `channelSelected`, `favoriteToggled`
|
||||
- **Inputs**: Same as AllChannelsViewComponent + `groupedChannels`
|
||||
- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `favoriteToggled`, `hiddenGroupTitlesChanged`, sidebar sizing outputs
|
||||
- **Features**: Resizable groups rail, local group search, group visibility management, persisted selected-group channel sorting
|
||||
|
||||
#### FavoritesViewComponent
|
||||
|
||||
- **Inputs**: `favorites`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
|
||||
- **Outputs**: `channelSelected`, `favoriteToggled`, `favoritesReordered`
|
||||
- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `favoriteToggled`, `favoritesReordered`
|
||||
- **Features**: Drag-and-drop reordering with CDK DragDrop, read-only channel details context menu
|
||||
|
||||
#### RecentViewComponent
|
||||
|
||||
- **Inputs**: recent channels, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
|
||||
- **Outputs**: `channelSelected`, `favoriteToggled`, `recentItemRemoved`
|
||||
- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `removeRecent`
|
||||
- **Features**: Read-only channel details context menu, row-level and context-menu removal
|
||||
|
||||
## EPG Integration
|
||||
@@ -791,16 +805,21 @@ interface EpgProgram {
|
||||
|
||||
## Routes
|
||||
|
||||
Routes live in `libs/playlist/m3u/feature-player/src/lib/m3u-workspace.routes.ts`
|
||||
(`createM3uWorkspaceRoutes()`), nested under the workspace shell:
|
||||
|
||||
```
|
||||
/playlists/:id # Video player with playlist
|
||||
/iptv # Default IPTV route
|
||||
/workspace/playlists/:id # M3U player (redirects to .../all)
|
||||
/workspace/playlists/:id/favorites # Favorites collection view
|
||||
/workspace/playlists/:id/recent # Recently viewed collection view
|
||||
/workspace/playlists/:id/:view # Video player with channel list view
|
||||
```
|
||||
|
||||
## Adding New Features
|
||||
|
||||
### To add a new tab to channel list:
|
||||
### To add a new view to channel list:
|
||||
|
||||
1. Create component in `channel-list-container/new-tab/`
|
||||
1. Create component in `channel-list-container/new-view/`
|
||||
2. Accept inputs: `channels`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
|
||||
3. Emit `channelSelected` output
|
||||
4. Add to parent template and imports
|
||||
|
||||
@@ -225,9 +225,29 @@ It owns only transient presentation behavior:
|
||||
- `ControlsVolume` — persisted/optimistic volume state reconciled from
|
||||
controller state;
|
||||
- `ControlsShortcuts` — document keyboard routing;
|
||||
- `ControlsSurface` — pointer/click/double-click surface interactions; and
|
||||
- `ControlsSurface` — pointer/click/double-click surface interactions;
|
||||
- `ControlsTimeline` — scrub state and timeline projections; and
|
||||
- `controls-view-model.ts` — derived display state.
|
||||
|
||||
### Fullscreen media title
|
||||
|
||||
The component accepts an optional `mediaTitle` input
|
||||
(`PlayerMediaTitle { primary, secondary? }`) with display-ready strings — the
|
||||
movie title, channel name, or series name, plus an optional second line such
|
||||
as the `S01E03` episode label. The overlay renders at the top of the player
|
||||
only in fullscreen while the controls are revealed, follows the same
|
||||
auto-hide transition as the bottom bar, and is pointer-transparent. Outside
|
||||
fullscreen the surrounding page chrome already names the content, so the
|
||||
overlay stays hidden.
|
||||
|
||||
Hosts supply the value: `WebPlayerViewComponent` derives a single-line title
|
||||
from the resolved playback (skipping raw stream-URL fallbacks) unless its own
|
||||
`mediaTitle` input was set, and `PortalInlinePlayerComponent` builds the
|
||||
two-line series form from its `seriesTitle` input plus the episode metadata
|
||||
label. The Xtream and Stalker series detail views pass the series name via
|
||||
`seriesTitle`; movie and live hosts need no extra wiring because
|
||||
`playback.title` already names the content.
|
||||
|
||||
### Keyboard ownership
|
||||
|
||||
Unmodified Space/K, F, arrow keys, and M are playback shortcuts. Playback keys
|
||||
@@ -506,8 +526,9 @@ back to the command's stale baseline.
|
||||
|
||||
The native MPV surface paints outside Chromium's DOM stacking model. It keeps
|
||||
the compositor-safe fixed controls dock below the viewport. Modal overlays hide
|
||||
the native surface with `HIDDEN_BOUNDS`, and control popovers reserve a bottom
|
||||
cutout so their DOM region remains interactive.
|
||||
the native surface with `HIDDEN_BOUNDS`; control menus render as horizontal
|
||||
panels inside the fixed-height dock strip, so they stay interactive without
|
||||
any bounds change.
|
||||
|
||||
The transparent BrowserWindow / `NSWindowBelow` tunnel-and-backdrop approach is
|
||||
not the shipped architecture. The shared-controls integration does not add
|
||||
@@ -634,6 +655,7 @@ The guarded ArtPlayer integration lives in:
|
||||
|
||||
```text
|
||||
libs/ui/playback/src/lib/art-player/
|
||||
├── art-player-audio-tracks.ts
|
||||
├── art-player-setup.ts
|
||||
├── art-player-source-session.ts
|
||||
├── art-player-video-session.ts
|
||||
|
||||
@@ -5,7 +5,9 @@ settings screen.
|
||||
|
||||
## Entry Points
|
||||
|
||||
- UI: `/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings.component.ts`
|
||||
- UI: `/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup-section.component.ts`
|
||||
(embedded in `settings.component.html`), with the file read/handoff in
|
||||
`/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup.facade.ts`
|
||||
- Backup service: `/Users/4gray/Code/iptvnator/libs/services/src/lib/playlist-backup.service.ts`
|
||||
- Manifest types: `/Users/4gray/Code/iptvnator/libs/shared/interfaces/src/lib/playlist-backup.interface.ts`
|
||||
- Xtream pending restore storage:
|
||||
@@ -102,7 +104,9 @@ Only EPG source URLs are backed up at the app-settings level.
|
||||
|
||||
## Import Flow
|
||||
|
||||
The settings component hands file contents to `PlaylistBackupService`.
|
||||
The settings backup facade (`settings-backup.facade.ts`, driven by
|
||||
`settings-backup-section.component.ts`) reads the file (`file.text()`) and
|
||||
hands its contents to `PlaylistBackupService.importBackup()`.
|
||||
|
||||
The service:
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Implication:
|
||||
|
||||
Current code paths:
|
||||
|
||||
- `libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts`
|
||||
- `libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts` (favorites + recent, with shared UI from `libs/portal/shared/ui/src/lib/components/favorites-layout/`)
|
||||
- `libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts`
|
||||
- `libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts`
|
||||
|
||||
@@ -113,8 +113,7 @@ Implication:
|
||||
|
||||
Current code paths:
|
||||
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts`
|
||||
- `libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts`
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts` (favorites + recent via `mode` route data) -> `stalker-collection-detail.component.ts`
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
|
||||
- `libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts` (Stalker branch)
|
||||
|
||||
|
||||
@@ -56,6 +56,15 @@ Keep SQL-heavy logic here so the worker entry remains a thin dispatcher:
|
||||
2. `apps/electron-backend/src/app/database/operations/content.operations.ts`
|
||||
3. `apps/electron-backend/src/app/database/operations/playlist.operations.ts`
|
||||
4. `apps/electron-backend/src/app/database/operations/xtream.operations.ts`
|
||||
5. `apps/electron-backend/src/app/database/operations/favorites.operations.ts`
|
||||
6. `apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts`
|
||||
7. `apps/electron-backend/src/app/database/operations/playback-position.operations.ts`
|
||||
8. `apps/electron-backend/src/app/database/operations/content-backdrop.operations.ts`
|
||||
9. `apps/electron-backend/src/app/database/operations/title-match.operations.ts`
|
||||
10. `apps/electron-backend/src/app/database/operations/tmdb.operations.ts`
|
||||
11. `apps/electron-backend/src/app/database/operations/epg-mapping.operations.ts`
|
||||
|
||||
(plus the shared cancellation helper `operation-control.ts` in the same directory)
|
||||
|
||||
## Worker Architecture
|
||||
|
||||
@@ -241,7 +250,20 @@ Xtream-only row shape:
|
||||
short first tokens such as `tv` stay anchored to the start of the title, so
|
||||
`TV Sport` matches but `Test TV` does not. These short first-token queries
|
||||
bypass trigram FTS and use the `idx_content_title` prefix index path because
|
||||
trigram tokenization cannot match 1-2 character terms.
|
||||
trigram tokenization cannot match 1-2 character terms. Punctuation-joined
|
||||
words such as `A&E` or `X-Men` are an exception: tokenization splits them
|
||||
into short fragments, so the intact word is preserved as a "compound word"
|
||||
(`content-search.util.ts`) that additionally matches as an exact substring —
|
||||
a supplemental trigram FTS `MATCH '"a&e"'` query for the Xtream arm (merged
|
||||
and deduped with the prefix-index candidates), intact-word `LIKE` contains
|
||||
patterns for the per-playlist and M3U payload prefilters, and a
|
||||
space-bounded whole-phrase check in the ranking step. All compound arms
|
||||
keep the remaining words of the query as SQL constraints — the FTS
|
||||
supplement AND-s the non-compound tokens as `LIKE` conditions and the
|
||||
`LIKE` prefilters compose per word — so `A&E HD` cannot fill the bounded
|
||||
candidate window with titles that only contain `A&E`. This lets `A&E`
|
||||
find `US: A&E` anywhere in the title while single short tokens stay
|
||||
prefix-anchored (issue #1161).
|
||||
6. `excludeHidden` still filters hidden Xtream categories and also filters M3U
|
||||
channels whose `group.title` is listed in the playlist payload's
|
||||
`hiddenGroupTitles`.
|
||||
@@ -675,11 +697,16 @@ CI=1 NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false pnpm nx run electron-backend:build --s
|
||||
|
||||
These are intentionally still out of scope for this first cut:
|
||||
|
||||
1. request cancellation
|
||||
2. moving network-heavy Xtream fetches off the current path
|
||||
3. migrating every remaining small SQLite IPC handler to the worker
|
||||
4. richer delete progress reporting for bulk destructive operations
|
||||
5. repo-wide Angular/Jest cleanup for the currently failing web test baseline
|
||||
1. moving network-heavy Xtream fetches off the current path
|
||||
2. migrating every remaining small SQLite IPC handler to the worker
|
||||
3. richer delete progress reporting for bulk destructive operations
|
||||
4. repo-wide Angular/Jest cleanup for the currently failing web test baseline
|
||||
|
||||
(Request cancellation, originally listed here, has since shipped — see the
|
||||
"Cancellation contract" section above: `DB_CANCEL_OPERATION` in
|
||||
`apps/electron-backend/src/app/api/main.preload.ts`, `AbortError` production in
|
||||
`database.worker.ts`, and `DatabaseService.cancelOperation` in
|
||||
`libs/services/src/lib/database-electron.service.ts`.)
|
||||
|
||||
## Extending The Worker
|
||||
|
||||
|
||||
@@ -15,9 +15,19 @@ Stalker now uses two EPG paths with different purposes:
|
||||
- The active channel EPG panel uses `get_epg_info` as a bulk endpoint, fetches a
|
||||
7-day window once per playlist session, caches programs by channel id, and
|
||||
renders the selected channel through the shared `app-epg-timeline` component.
|
||||
- Channel rows no longer send preview EPG requests during initial category load.
|
||||
They stay empty until bulk EPG has been fetched once, then derive their
|
||||
current program and progress bar from the cached bulk map.
|
||||
- Channel rows never send per-row EPG requests. The bulk EPG load is triggered
|
||||
**eagerly when a category's channels first render** (a constructor effect in
|
||||
`StalkerLiveStreamLayoutComponent` calls `ensureBulkItvEpg(168)` once ITV
|
||||
channels are present) — not only after the first channel is played — so the
|
||||
row "now playing" previews and the EPG panel populate immediately. Rows derive
|
||||
their current program and progress bar from the cached bulk map.
|
||||
- Effect ordering matters: the eager-EPG effect is registered **after** the
|
||||
playlist-change effect that calls `clearBulkItvEpgCache()`. On a portal
|
||||
switch the cache is cleared first and then refilled; if the order is
|
||||
reversed the clear clobbers the just-loaded bulk EPG on initial render.
|
||||
- `ensureBulkItvEpg` de-duplicates (via `isLoadingBulkItvEpg` /
|
||||
`bulkItvEpgLoaded` + matching playlist/period), so the eager trigger and the
|
||||
play-time `loadEpgForChannel` path never double-fetch.
|
||||
- If a portal does not return usable bulk data for the selected channel, the
|
||||
active panel falls back to `get_short_epg`.
|
||||
|
||||
@@ -263,6 +273,29 @@ advantage of the richer bulk API when it is available. Row previews do not
|
||||
fallback to per-channel requests in this mode; they remain empty until bulk EPG
|
||||
is available.
|
||||
|
||||
## Manual EPG Mapping
|
||||
|
||||
Stalker channels carry no XMLTV identifier, so when the portal's own EPG is
|
||||
missing or wrong the only uploaded-EPG entry point is a **manual mapping**:
|
||||
right-click a channel in the ITV sidebar (or in global favorites) and pick
|
||||
"Map EPG channel" to attach it to a channel from an uploaded XMLTV guide.
|
||||
|
||||
- Mappings are stored in the shared `epg_channel_mappings` table under the
|
||||
playlist-scoped key `stalker:{playlistId}:{channelId}`
|
||||
(`buildStalkerEpgMappingKey` in
|
||||
`libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`).
|
||||
- `withStalkerEpg().applyMappedItvEpg(channelIds)` batch-resolves mappings
|
||||
(one `getEpgMappingsBatch` IPC per new id set) and overlays the mapped
|
||||
XMLTV programs onto `bulkItvEpgByChannel`, so both the active panel and
|
||||
the row previews pick them up with no template changes. Overrides are
|
||||
re-merged whenever `ensureBulkItvEpg` replaces the bulk record and are
|
||||
re-checked after the mapping dialog closes with a change.
|
||||
- The collection views (global favorites/recent) resolve the same keys in
|
||||
`StreamResolverService` (`loadStalkerEpgItems` for the detail panel,
|
||||
`loadStalkerEpgBatch` + `prefetchEpgMappings` for row previews).
|
||||
- Everything is gated behind `supportsEpgMapping`, so the PWA never shows
|
||||
the menu entry.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- add cache refresh / invalidation for long-running live sessions
|
||||
|
||||
@@ -22,7 +22,7 @@ The mock server enables:
|
||||
Per-request random data would break navigation: if category IDs change between calls, content fetched under a category ID won't match the category list. Instead:
|
||||
|
||||
- Data is generated **once per MAC address** on first request, then cached in memory.
|
||||
- `@faker-js/faker` is seeded with a numeric value derived from the MAC address before generation.
|
||||
- `@faker-js/faker` is seeded with the scenario's `seed` value before generation: predefined scenario MACs use fixed seeds from `scenarios.ts`; unknown MACs derive the seed from the MAC via `macToSeed()`.
|
||||
- Same MAC → identical data on every server restart.
|
||||
- Restart the server to reshuffle all data.
|
||||
|
||||
@@ -41,7 +41,7 @@ No files or databases are written. All state (generated content + favorites) liv
|
||||
## Data Generation Pipeline
|
||||
|
||||
```
|
||||
faker.seed(macToNumber(mac))
|
||||
faker.seed(config.seed) // scenario seed; unknown MACs: macToSeed(mac)
|
||||
│
|
||||
├── generateCategories('itv', N) → itvCategories[]
|
||||
│ └── generateChannels() → channels Map<categoryId, channel[]>
|
||||
@@ -123,7 +123,7 @@ marker and an `ffrt4://radio/...` command.
|
||||
"id": "30001-s1",
|
||||
"name": "Season 1",
|
||||
"cmd": "ffrt4://series/30001/season/1",
|
||||
"series": ["30001-s1-e1", "30001-s1-e2", ...],
|
||||
"series": ["1", "2", "3", ...],
|
||||
"screenshot_uri": "https://picsum.photos/seed/30001-s1/300/200",
|
||||
"director": "...",
|
||||
"actors": "...",
|
||||
@@ -217,9 +217,19 @@ interface ScenarioConfig {
|
||||
episodesPerSeason: number;
|
||||
isSeriesFraction: number; // 0–1: fraction of VOD with is_series=1
|
||||
embeddedSeriesFraction: number; // 0–1: fraction of VOD with embedded series[]
|
||||
supportsGetAllChannels?: boolean; // default true; false mimics legacy portals
|
||||
// without the ITV get_all_channels action
|
||||
}
|
||||
```
|
||||
|
||||
The `legacy-pagination` scenario (`00:1A:79:00:00:06`) sets
|
||||
`supportsGetAllChannels: false`: `get_all_channels` then answers with an error
|
||||
payload so clients fall back to the paginated `get_ordered_list` crawl. For
|
||||
supporting scenarios, `get_all_channels` (`get-all-channels.handler.ts`,
|
||||
`type=itv` only) returns the complete ITV channel list in one
|
||||
`{ js: { data, total_items } }` response, excluding channels from censored
|
||||
(adult) genres.
|
||||
|
||||
### Adding a New Scenario
|
||||
|
||||
1. Add an entry to the `SCENARIOS` map in `src/app/scenarios.ts`.
|
||||
@@ -257,7 +267,7 @@ Playwright waits for both servers to be healthy before starting tests. If either
|
||||
|
||||
Each stalker e2e test calls `POST http://localhost:3210/reset` in `beforeEach` to clear in-memory state. This ensures tests don't bleed favorites or other mutable state into each other.
|
||||
|
||||
The generated content (categories, items) is **not** cleared on reset — it's deterministic and doesn't need to be. Only in-memory favorites are cleared.
|
||||
`resetAll()` clears both the generated-content cache and in-memory favorites (`data-store.ts`). Because generation is seed-deterministic, the next request regenerates identical content, so the observable data does not change across resets.
|
||||
|
||||
### Recommended Test Structure
|
||||
|
||||
|
||||
@@ -29,52 +29,56 @@ Stalker support covers:
|
||||
|
||||
## Routing Structure
|
||||
|
||||
Primary route tree lives in `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`.
|
||||
Primary route tree lives in
|
||||
`libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`.
|
||||
|
||||
- `/stalker/:id/vod`
|
||||
- `/stalker/:id/series`
|
||||
- `/stalker/:id/vod` (plus `vod/:categoryId` child)
|
||||
- `/stalker/:id/series` (plus `series/:categoryId` child)
|
||||
- `/stalker/:id/itv`
|
||||
- `/stalker/:id/radio`
|
||||
- `/stalker/:id/favorites`
|
||||
- `/stalker/:id/recent`
|
||||
- `/stalker/:id/search`
|
||||
- `/stalker/:id/downloads` (shared downloads module from Xtream UI)
|
||||
- `/stalker/:id/actor/:personId`
|
||||
- `/stalker/:id/downloads` (shared `DownloadsComponent` from `@iptvnator/portal/downloads/feature`)
|
||||
|
||||
## Runtime Architecture
|
||||
|
||||
1. Angular Stalker screens call methods/resources in `StalkerStore`.
|
||||
2. `StalkerStore` builds request params based on selected content type and current view state.
|
||||
3. Requests go through `DataService.sendIpcEvent(STALKER_REQUEST, ...)` or `StalkerSessionService` (full portal auth).
|
||||
4. Electron main process handles `STALKER_REQUEST` in `/Users/4gray/Code/iptvnator/apps/electron-backend/src/app/events/stalker.events.ts`.
|
||||
5. Axios calls Stalker `load.php` API with required headers/cookies and returns normalized payloads to renderer.
|
||||
4. Electron main process handles `STALKER_REQUEST` in
|
||||
`apps/electron-backend/src/app/events/stalker.events.ts`.
|
||||
5. Axios calls Stalker `load.php` API with required headers/cookies and returns the raw `response.data` to the renderer; normalization happens in the store feature slices.
|
||||
|
||||
## Main UI Components
|
||||
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts`
|
||||
- Category + content layout for `vod` and `series`
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
|
||||
- `CategoryContentViewComponent` from `@iptvnator/portal/catalog/feature` (`libs/portal/catalog/feature`)
|
||||
- Shared category + content layout used by the `vod` and `series` routes (wired in `stalker-feature.routes.ts` via `loadCategoryContentViewComponent`)
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
|
||||
- ITV live playback, radio playback, channel/station navigation, EPG panel integration
|
||||
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
|
||||
- `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
|
||||
- Shared inline audio player used by M3U radio channels and Stalker radio stations
|
||||
- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-series-view/stalker-series-view.component.ts`
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
|
||||
- Season/episode UI for all Stalker series modes
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts`
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts`
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts`
|
||||
- Favorites and recently-viewed collection views (`mode = 'favorites' | 'recent'` route data), rendering `stalker-collection-detail.component.ts`
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
|
||||
|
||||
## Store and Data Flow
|
||||
|
||||
Stalker store is now feature-composed:
|
||||
|
||||
- Facade: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker.store.ts`
|
||||
- Feature slices: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stores/features/*`
|
||||
- Shared helpers: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/*`
|
||||
- Facade: `libs/portal/stalker/data-access/src/lib/stalker.store.ts`
|
||||
- Feature slices: `libs/portal/stalker/data-access/src/lib/stores/features/*`
|
||||
- Shared helpers: `libs/portal/stalker/data-access/src/lib/*`
|
||||
|
||||
Important store responsibilities:
|
||||
|
||||
- Selected content/category/item state
|
||||
- Category and paginated content resources
|
||||
- ITV channel list + pagination
|
||||
- ITV channel list + pagination (full-list session cache when the portal
|
||||
supports it, legacy 14-per-page lazy loading otherwise)
|
||||
- Radio category/station list + pagination
|
||||
- Regular series seasons resource
|
||||
- VOD-series (`is_series=1`) seasons + episodes resources
|
||||
@@ -140,6 +144,9 @@ The Stalker live route and radio route intentionally share
|
||||
- `itv` uses `type=itv&action=get_ordered_list`, stores results in
|
||||
`itvChannels`, resolves playback through `resolveItvPlayback(...)`, and keeps
|
||||
the EPG panel visible.
|
||||
- ITV additionally loads the COMPLETE channel list once per portal session (see
|
||||
"Full ITV channel list cache" below), so category views and search are not
|
||||
limited to the lazily loaded 14-item pages.
|
||||
- `radio` uses `type=radio&action=get_ordered_list`, stores results in
|
||||
`radioChannels`, resolves playback through `resolveRadioPlayback(...)`, and
|
||||
renders `AudioPlayerComponent` instead of a video player.
|
||||
@@ -155,6 +162,107 @@ The Stalker live route and radio route intentionally share
|
||||
falls back to a synthetic `PORTALS.ALL_RADIO` category with
|
||||
`category_id: '*'` so the station list can still be loaded.
|
||||
|
||||
## Full ITV Channel List Cache
|
||||
|
||||
Stalker portals paginate `get_ordered_list` with a server-side page size
|
||||
(typically 14 items), so lazy loading alone can never power a complete local
|
||||
search — this used to limit ITV search to whatever pages the user had scrolled
|
||||
through. `StalkerItvCacheService`
|
||||
(`libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts`) fixes
|
||||
this with a per-portal, in-memory session cache of the complete live channel
|
||||
list:
|
||||
|
||||
- Load strategy: first try the Ministra `get_all_channels` action (`type=itv`,
|
||||
returns ALL channels in one response — the same call STB clients use); if
|
||||
the portal does not implement it, crawl `get_ordered_list` pages
|
||||
(`category=*`, `genre=*`, concurrency 4, one retry per page, early stop on
|
||||
an empty page **or a page that adds no new channel ids** — some portals
|
||||
ignore `p` and repeat — 30k-channel hard cap) with progress reporting. The
|
||||
assembled list is de-duplicated by channel id (both strategies) so it never
|
||||
collides with the template's `track item.id`. The loading strategy itself is
|
||||
a stateless helper (`stalker-itv-channel-loader.ts`); the service owns state.
|
||||
- Outcomes: a well-formed but unusable response marks the portal
|
||||
`unsupported` for the session (legacy paged flow stays in charge); a
|
||||
transient failure (network, or a page that failed both attempts) is retried
|
||||
later but throttled by a per-portal cooldown (`ERROR_COOLDOWN_MS`, 30s) so a
|
||||
deterministically-failing page can't trigger an unbounded re-crawl loop.
|
||||
- Per-portal reactivity: the "cache ready / refreshed" trigger is a
|
||||
**per-portal** version signal (`versionFor(playlist)`), not one global
|
||||
counter, and the content resource reads it **only for ITV**. This is
|
||||
load-bearing: a global counter re-fired the resource for whatever was on
|
||||
screen (radio, another portal), and the legacy paged branch appends at
|
||||
`pageIndex > 1`, so an unrelated load completing duplicated the visible page
|
||||
(colliding `track item.id` → NG0955). The `isCurrentRequest` guard is scoped
|
||||
the same way.
|
||||
- Integration: the `getContentResource` loader in
|
||||
`with-stalker-content.feature.ts` serves ITV categories from the cache when
|
||||
ready (local `tv_genre_id` filtering via `filterItvChannelsByGenre`,
|
||||
`hasMoreChannels=false`), and otherwise runs the legacy paged fetch while
|
||||
`ensureLoaded()` fills the cache in the background; the resource re-fires
|
||||
via the `cacheVersion` signal once the full list arrives.
|
||||
- UI: `StalkerLiveStreamLayoutComponent` windows the rendered list
|
||||
(100-item chunks extended by the existing scroll handler) so multi-thousand
|
||||
channel lists do not blow up the DOM; the header count and search cover the
|
||||
whole category; a refresh button re-loads the list in place; a progress line
|
||||
shows crawl status.
|
||||
- Loading state contract (important — regressions here strand the sidebar on a
|
||||
skeleton): in full-list mode the content loader serves the filtered list
|
||||
**synchronously** from the cache. The category-change reset effect therefore
|
||||
must NOT `setItvChannels([])` while `itvFullListActive()` is true — it runs
|
||||
after the store resource and would clobber the freshly served list, leaving
|
||||
every category after the first stuck on a skeleton. The initial-loading
|
||||
skeleton (`isInitialChannelsLoading`) must key off an actual in-flight load
|
||||
(`itvFullListLoading()` or `isPaginatedContentLoading()`), not merely an empty
|
||||
channel list; an empty result once loading has settled is an empty category
|
||||
and renders `PORTALS.NO_CHANNELS_IN_CATEGORY`, not a spinner.
|
||||
- Search: with the cache active, the header search spans the ENTIRE portal
|
||||
(all genres) — filtering the store's `itvFullChannelList`, not just the
|
||||
selected category — so searching "CNN" while a "Sports" genre is selected
|
||||
still finds it; clearing the term returns to the selected category. The
|
||||
workspace shell drops the `degraded-loaded-only` / "loaded only" status for
|
||||
Stalker ITV once `itvFullListActive`; radio (no full-list cache) always keeps
|
||||
the loaded-only hint (`workspace-shell-search.service.ts`).
|
||||
- Windowed selection: remote channel-up/down and numeric select operate over
|
||||
the full filtered category, so the render window (`renderLimit`) grows to
|
||||
include a selection beyond it (`ensureChannelWithinRenderWindow`) instead of
|
||||
drifting off-screen.
|
||||
- Category count badges: the context panel shows per-genre channel counts on
|
||||
Stalker **Live TV** categories (like Xtream/M3U), fed by the store computed
|
||||
`itvCategoryItemCounts` (the full list grouped by numeric `tv_genre_id`; the
|
||||
`'*'` "All" row's total is stored under the `NaN` key that
|
||||
`Number('*')` produces). Badges are ITV-only — VOD/series/radio still page
|
||||
lazily so their per-category totals are unknown — and show a loading shimmer
|
||||
while the full list is still loading (`workspace-context-panel` →
|
||||
`stalkerShowCounts` / `stalkerCountDisplayMode`).
|
||||
- Censored (adult) genres: portals typically EXCLUDE these channels from
|
||||
`get_all_channels` (sometimes without even flagging the genre `censored` in
|
||||
`get_genres`), so the cache legitimately has zero channels for them. The
|
||||
content loader therefore serves a genre from the cache only when the
|
||||
genre-filtered result is non-empty; otherwise it falls back to the legacy
|
||||
paged `get_ordered_list` fetch, which still returns those channels. The
|
||||
store computed `itvSelectedCategoryFromCache` is the single source of truth
|
||||
for this mode — the live layout keys windowing/infinite-scroll/`loadMore`
|
||||
and the category-change reset off it, NOT off `itvFullListActive`. Count
|
||||
badges: genres with no cached channels get NO map entry and the category
|
||||
view omits their badge (`omitMissingCounts`) instead of showing a
|
||||
misleading "0". The mock server ships a censored `For adults` ITV category
|
||||
(id 1099) to exercise this path.
|
||||
- Eager preload + all-channels view (Xtream parity): entering the Live TV
|
||||
section immediately starts the full-list load (`preloadItvChannels()`, fired
|
||||
from an effect in `StalkerLiveStreamLayoutComponent` — not from the first
|
||||
category click), so the count badges and the all-channels view are available
|
||||
right away. Before a category is selected, the main area shows
|
||||
`StalkerItvAllItemsComponent` — a paginated card grid of every channel in
|
||||
the portal (client-side pagination only; it must never touch the store's
|
||||
legacy `page` state, which would re-fire portal requests). Clicking a card
|
||||
runs the same `playChannel` flow as the sidebar. Portals without a usable
|
||||
full list keep the "select a category" placeholder.
|
||||
- Scope: ITV only. VOD/series keep server-side search; radio keeps legacy
|
||||
paging (station lists are small).
|
||||
- The stalker-mock-server implements `get_all_channels` and provides the
|
||||
`legacy-pagination` scenario MAC (`00:1A:79:00:00:06`) to exercise the
|
||||
crawl fallback.
|
||||
|
||||
## VOD/Series Modes
|
||||
|
||||
Stalker has multiple real-world data shapes. The current implementation supports all three:
|
||||
@@ -185,6 +293,9 @@ Stalker has multiple real-world data shapes. The current implementation supports
|
||||
- For unloaded VOD-series seasons, the CTA target label is derived from season
|
||||
metadata and rendered as `SxxE01` until episode details are loaded.
|
||||
- Uses unique generated tracking IDs for episode playback position compatibility.
|
||||
- Quick-start actions preserve both their translation key and interpolation
|
||||
parameters when adapted for the Stalker CTA. Dropping `labelParams` exposes
|
||||
the raw `{{episode}}` placeholder.
|
||||
|
||||
Series inline playback behavior is shared across all three modes:
|
||||
|
||||
@@ -194,11 +305,30 @@ Series inline playback behavior is shared across all three modes:
|
||||
- Inline series autoplay is enabled by default. On player EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list.
|
||||
- Autoplay and Next stop at the last episode of the current season. They do not jump to the next season and do not lazy-load an unloaded `is_series=1` season. Quick start remains the only flow that may load another VOD-series season before playback.
|
||||
- Previous is disabled on the first episode of the current season and otherwise switches directly to the previous episode.
|
||||
- Before either inline or external playback starts, the resolved content info
|
||||
includes the parent `seriesXtreamId` and the mapped `seasonNumber` /
|
||||
`episodeNumber`. Future playback-position rows therefore carry enough
|
||||
metadata for workspace surfaces to render an episode badge. Existing rows
|
||||
without those fields are intentionally not migrated and remain badge-less
|
||||
until the episode is played again.
|
||||
- Ministra payloads may omit `season_number`. Episode mapping and lazy
|
||||
quick-start labels share the same naturally ordered season fallback so later
|
||||
seasons are not persisted as season 1.
|
||||
|
||||
The VOD-series contract is cross-surface:
|
||||
|
||||
- Favorites and recently viewed records preserve the raw `is_series` flag and
|
||||
VOD origin so reopening still uses the lazy Ministra resources.
|
||||
- `extractStalkerItemType()` normalizes those activity records to dashboard
|
||||
type `series`.
|
||||
- The dashboard resolves episode progress by the parent `seriesXtreamId` and
|
||||
renders the saved season/episode metadata. It does not infer episode numbers
|
||||
from provider payloads.
|
||||
|
||||
Core decision logic and normalization are centralized in:
|
||||
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts`
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/models/*.ts`
|
||||
- `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts`
|
||||
- `libs/portal/stalker/data-access/src/lib/models/*.ts`
|
||||
|
||||
## Favorites and Recently Viewed
|
||||
|
||||
@@ -213,10 +343,10 @@ Current implementation is shared via Stalker-specific helpers:
|
||||
|
||||
Where this is used:
|
||||
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts`
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts`
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
|
||||
- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts`
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts` (favorites + recently viewed)
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts`
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts`
|
||||
|
||||
Navigation rule to preserve:
|
||||
|
||||
@@ -264,7 +394,7 @@ Import rule:
|
||||
|
||||
Stalker live remote control is implemented in:
|
||||
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
|
||||
|
||||
Supported today:
|
||||
|
||||
@@ -279,7 +409,7 @@ See full backend and web-remote flow in [Remote Control Architecture](./remote-c
|
||||
Stalker ITV now splits EPG usage:
|
||||
|
||||
- active channel panel: bulk `get_epg_info` cached once per playlist and rendered
|
||||
through shared `app-epg-list`
|
||||
through the shared EPG panel (`app-epg-timeline`, or `app-epg-list-view` in list mode)
|
||||
- channel row preview: no pre-playback network requests; previews are derived
|
||||
from cached bulk EPG only after the first active-channel fetch succeeds
|
||||
- active panel fallback: `get_short_epg` when bulk EPG is missing or unsupported
|
||||
@@ -299,9 +429,12 @@ This reduces duplicate UI logic across portal types and keeps compatibility beha
|
||||
|
||||
## Regression Coverage
|
||||
|
||||
Focused regression tests for Stalker VOD mode branching live in:
|
||||
Focused regression tests for Stalker VOD mode branching and the cross-surface
|
||||
series contract live in:
|
||||
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts`
|
||||
- `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts`
|
||||
- `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts`
|
||||
- `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts`
|
||||
|
||||
Covered scenarios include:
|
||||
|
||||
@@ -310,3 +443,7 @@ Covered scenarios include:
|
||||
- VOD-backed series favorites keep VOD-series loading semantics when opened from
|
||||
favorites/global favorites
|
||||
- Favorite toggle helper path invokes the expected add/remove flow
|
||||
- Quick-start episode labels interpolate their episode number
|
||||
- Inline and external episode handoffs carry resolved season/episode metadata
|
||||
- Dashboard activity classifies `is_series` VOD as series and resolves its
|
||||
saved episode position
|
||||
@@ -34,19 +34,19 @@ without creating dependency cycles (`portal/shared/data-access` already
|
||||
depends on `portal/xtream/data-access`, so it cannot host code the Xtream
|
||||
store imports):
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `tmdb-config.ts` | API/image base URLs, embedded default API key, cache TTLs, app-language → TMDB-language mapping |
|
||||
| `tmdb.types.ts` | TMDB v3 response shapes (search, details with credits) |
|
||||
| `tmdb-api.service.ts` | Thin `fetch`-based client (TMDB supports CORS; works in Electron renderer and PWA). Accepts v3 keys (`api_key` param) and v4 tokens (Bearer) |
|
||||
| `tmdb-matcher.ts` | Title normalization, year extraction, and the match-confidence gate (pure functions) |
|
||||
| `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory LRU capped at 300 entries) with caller-supplied TTLs |
|
||||
| `tmdb-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) |
|
||||
| `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution |
|
||||
| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups |
|
||||
| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:<id>` rows) |
|
||||
| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:<id>\|season:<n>` rows) |
|
||||
| `tmdb-trending.service.ts` | Weekly trending (movie + tv merged by popularity, `trending:week` rows, 1-day TTL) |
|
||||
| File | Responsibility |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `tmdb-config.ts` | API/image base URLs, embedded default API key, cache TTLs, app-language → TMDB-language mapping |
|
||||
| `tmdb.types.ts` | TMDB v3 response shapes (search, details with credits) |
|
||||
| `tmdb-api.service.ts` | Thin `fetch`-based client (TMDB supports CORS; works in Electron renderer and PWA). Accepts v3 keys (`api_key` param) and v4 tokens (Bearer) |
|
||||
| `tmdb-matcher.ts` | Title normalization, year extraction, and the match-confidence gate (pure functions) |
|
||||
| `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory LRU capped at 300 entries) with caller-supplied TTLs |
|
||||
| `tmdb-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) |
|
||||
| `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution |
|
||||
| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups |
|
||||
| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:<id>` rows) |
|
||||
| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:<id>\|season:<n>` rows) |
|
||||
| `tmdb-trending.service.ts` | Weekly trending (movie + tv merged by popularity, `trending:week` rows, 1-day TTL) |
|
||||
|
||||
Integration glue per portal:
|
||||
|
||||
@@ -65,8 +65,11 @@ Integration glue per portal:
|
||||
|
||||
Components read the selection through signals and re-render when the merged
|
||||
item lands. Enriched cast (`tmdb_cast` with profile photos) renders as
|
||||
avatar chips in the detail views; a "check key" button in the settings
|
||||
section validates the API key against `/configuration`.
|
||||
avatar chips in the detail views, and so do directors/creators
|
||||
(`tmdb_directors`: movie directors from `credits.crew` with
|
||||
`job === 'Director'`, series creators from `created_by`) — both chip kinds
|
||||
carry `tmdbPersonId` and open the same person page. A "check key" button
|
||||
in the settings section validates the API key against `/configuration`.
|
||||
|
||||
## Match Confidence
|
||||
|
||||
@@ -101,7 +104,7 @@ The year filter is applied client-side rather than via TMDB's strict
|
||||
when the provider's year is off by one.
|
||||
|
||||
**Non-Latin titles**: TMDB matches translated titles but returns `title` in
|
||||
the *request* language, so a Cyrillic query issued with `en-US` would come
|
||||
the _request_ language, so a Cyrillic query issued with `en-US` would come
|
||||
back with an English title and fail the exact-match gate.
|
||||
`tmdbSearchLanguageForTitle` detects Cyrillic queries and issues the search
|
||||
with `ru-RU` (unless the app language is already Cyrillic-based); details
|
||||
@@ -173,10 +176,33 @@ detail views lazily fetch `/tv/{tmdbId}/season/{n}` via
|
||||
- episodes without a TMDB counterpart (by episode number) pass through
|
||||
untouched
|
||||
|
||||
The season number `{n}` is the provider's episode season number, with one
|
||||
correction (`resolveEnrichmentSeasonNumber` in
|
||||
`libs/shared/interfaces/src/lib/season-marker.util.ts`): providers often
|
||||
slice a show into per-season catalog items ("The Mandalorian (2 season)",
|
||||
"Пацаны 2 сезон", "The Boys S05") and renumber the single contained season
|
||||
to 1. When the item contains exactly ONE season and the raw title carries
|
||||
an explicit season marker (`extractSeasonFromTitle`: `s02`, `season 2`,
|
||||
`2 season`, `2nd season`, `сезон 2`, `2-й сезон`, `staffel`/`temporada`/
|
||||
`saison` forms, bracketed or not) that differs from the provider's number,
|
||||
the marker wins and that TMDB season is fetched. Multi-season items always
|
||||
keep provider numbering. `normalizeTitleKeys` strips the same markers
|
||||
(including number-first forms like "2 сезон") from search titles, so the
|
||||
show-level match is unaffected by them.
|
||||
|
||||
Wiring: Xtream — `XtreamStore.enrichSelectedSerialSeason(seasonKey)` fired
|
||||
from the serial detail's `(seasonSelected)`; Stalker — the series view
|
||||
keeps a `${tmdbId}|${seasonKey}`-keyed map and overlays it inside its
|
||||
`mappedSeasons` computed. Without a show-level match or with enrichment
|
||||
`mappedSeasons` computed. Each Stalker entry records the RESOLVED season
|
||||
it was fetched for: per-season slices of one show share
|
||||
(tmdbId, provider key "1") but resolve to different seasons, and a fetch
|
||||
made with stale detail-to-detail navigation context is overwritten once
|
||||
the real context re-resolves. The fetch effect gates on coherence rather
|
||||
than timing: it waits while the season resource reloads and requires the
|
||||
selected key to exist in the map with episodes. The retained season
|
||||
selection deliberately survives navigation — the season container
|
||||
deduplicates `seasonSelected` emissions, so items sharing one season-key
|
||||
set would otherwise never re-trigger enrichment. Without a show-level match or with enrichment
|
||||
disabled everything is a no-op — the `SeasonContainer` UI already renders
|
||||
every episode field conditionally.
|
||||
|
||||
@@ -187,7 +213,11 @@ Cast chips carry the TMDB person id (`tmdbPersonId` on
|
||||
current portal. The page loads `/person/{id}?append_to_response=
|
||||
combined_credits` via `TmdbEnrichmentService.getPersonDetails` (cached
|
||||
under `person:{id}` with media_type `person`) and renders the shared
|
||||
`ActorViewComponent` (`libs/ui/shared-portals`).
|
||||
`ActorViewComponent` (`libs/ui/shared-portals`). The filmography merges
|
||||
acting credits (`combined_credits.cast`) with directing/creating credits
|
||||
(`combined_credits.crew`, jobs `Director`/`Creator`) into one list —
|
||||
acting wins the per-title dedup, directing-only titles show the job in
|
||||
the character slot — so the page serves actors and directors alike.
|
||||
|
||||
Filmography has two scopes:
|
||||
|
||||
@@ -215,8 +245,8 @@ Single table with two row kinds discriminated by `lookup_key` prefix:
|
||||
```
|
||||
tmdb_metadata (
|
||||
media_type 'movie' | 'tv' | 'person',
|
||||
lookup_key 'id:<tmdbId>' -- details payload row
|
||||
'title:<normalized>|year:<y>' -- search resolution row
|
||||
lookup_key 'id:<tmdbId>|v2' -- details payload row
|
||||
'title:<normalized>|year:<y>|v2' -- search resolution row
|
||||
'person:<personId>' -- person payload row
|
||||
language TEXT, -- TMDB language code
|
||||
tmdb_id INTEGER, -- NULL on a search row = negative cache
|
||||
@@ -229,6 +259,15 @@ tmdb_metadata (
|
||||
TTLs (enforced at read time in `TmdbCacheService.isFresh`): details and
|
||||
positive matches 30 days, negative matches 7 days.
|
||||
|
||||
Search and details keys carry a `|v2` version suffix (`buildDetailsLookupKey`
|
||||
in `tmdb-matcher.ts`): for search rows so normalization changes cannot reuse
|
||||
stale positive or negative resolutions, for details rows because payloads now
|
||||
include videos via `append_to_response` and pre-videos cache rows had to be
|
||||
invalidated. Database startup deletes the obsolete
|
||||
unversioned search rows once and records
|
||||
`migration:tmdb-search-lookup-v2-cache-cleanup:v1` in `app_state`; details and
|
||||
person cache rows are unaffected.
|
||||
|
||||
Electron IPC path (follows the standard DB worker contract, see
|
||||
[SQLite DB Worker](./sqlite-db-worker.md)):
|
||||
|
||||
|
||||
@@ -12,8 +12,12 @@ Related:
|
||||
- The dashboard is the default `/workspace` landing page.
|
||||
- It is a **rail-based** content surface (Netflix / Apple TV pattern), not a
|
||||
customizable widget grid.
|
||||
- Layout is static and curated — there is no edit mode, drag-drop, size
|
||||
stepper, show/hide toggle, or persisted layout. Rails auto-hide when empty.
|
||||
- Layout order is static and curated — there is no edit mode, drag-drop, or
|
||||
size stepper. Each rail has a persisted show/hide toggle
|
||||
(`Settings.dashboardRails`, `DashboardRailsSettings` in
|
||||
`libs/shared/interfaces/src/lib/settings.interface.ts`, surfaced under
|
||||
Settings → Dashboard); every template rail is gated by
|
||||
`dashboardRails().<key>`. Rails additionally auto-hide when empty.
|
||||
- First-run users see the shared welcome empty-state with a single primary
|
||||
CTA to add their first playlist.
|
||||
|
||||
@@ -38,14 +42,23 @@ Core implementation:
|
||||
│ Continue Watching · See all → │
|
||||
│ [poster][poster][poster][poster] →→ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Live now on your favorites / Continue with live TV · See all → │
|
||||
│ Live now on your favorites · See all → │
|
||||
│ [channel][channel][channel][channel] →→ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Recently watched live TV · See all → │
|
||||
│ [channel][channel][channel][channel] →→ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Favorite movies & series · See all → │
|
||||
│ [poster][poster][poster][poster] →→ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Recently Used Sources · See all → │
|
||||
│ [tile][tile][tile][tile] →→ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Recently Added on Xtream (aggregated across providers) │
|
||||
│ [poster][poster][poster] →→ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Trending this week (TMDB, opt-in, Electron-only) │
|
||||
│ [poster][poster][poster] →→ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -55,7 +68,7 @@ Render rules:
|
||||
page no longer uses `dashboardReady()` as a page-wide skeleton gate.
|
||||
Initial hero/recent/favorites loading states render scoped skeletons so one
|
||||
slow rail does not hide already available content.
|
||||
2. `hasPlaylists() === false` → render `<app-empty-state type="welcome">`
|
||||
2. `hasPlaylists() === false` → render `<app-empty-state [type]="'welcome-dashboard'">`
|
||||
full-bleed. All rails and the hero are skipped.
|
||||
3. `hero()` = `globalRecentItems()[0]`. If present, render the hero panel.
|
||||
4. Each rail is emitted via `@if (cards.length > 0)`. Empty rails are hidden
|
||||
@@ -63,9 +76,11 @@ Render rules:
|
||||
5. The continue-watching hero prefers a stored Xtream `backdrop_url`; when it
|
||||
is missing the UI falls back to a blurred poster treatment instead of
|
||||
showing a flat panel.
|
||||
6. The mixed global favorites rail is not rendered on the dashboard. Live
|
||||
favorites are promoted into the live rail, while mixed favorites stay on
|
||||
`/workspace/global-favorites`.
|
||||
6. Live favorites are promoted into their own live rail; movie/series
|
||||
favorites render in a separate `Favorite movies & series` rail
|
||||
(`favoriteMoviesAndSeriesCards`, `data-test-id="dashboard-favorite-vod-rail"`,
|
||||
mapped from `globalFavoriteItems()` filtered to movie/series). Full mixed
|
||||
favorites management stays on `/workspace/global-favorites`.
|
||||
7. The live favorites rail keeps its scoped skeleton until the initial global
|
||||
favorites load has completed for both Xtream-backed and playlist-backed
|
||||
favorites. This avoids first-paint partial counts such as a single Stalker
|
||||
@@ -93,18 +108,23 @@ Render rules:
|
||||
2. It derives the dashboard surface via `computed()`:
|
||||
1. `hero` — first item of `globalRecentItems()`.
|
||||
2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series
|
||||
cover cards. Xtream playback positions are bulk-loaded per playlist so
|
||||
cover cards. Portal playback positions are bulk-loaded per playlist so
|
||||
hero and cards can show progress, remaining time, and series season/
|
||||
episode badges. Series lookup uses keyed maps for both direct episode ids
|
||||
and series ids; card renders must not scan the full playback-position map.
|
||||
episode badges. This includes Stalker VOD activity normalized to series
|
||||
through `is_series`. Series lookup uses keyed maps for both direct
|
||||
episode ids and parent series ids; card renders must not scan the full
|
||||
playback-position map. The badge uses saved `seasonNumber` /
|
||||
`episodeNumber` metadata and does not infer it from provider payloads;
|
||||
legacy rows without that metadata remain badge-less until replay.
|
||||
Dashboard-originated Xtream series clicks also carry that exact episode
|
||||
target through the global-recent inline-detail handoff. Once the series
|
||||
metadata and playback positions load, the detail player consumes the
|
||||
target once and resumes the saved episode. Opening the same item normally
|
||||
from the global recent grid remains a detail-only action.
|
||||
3. `liveOnFavoritesCardsEnriched` — maps favorited live channels first,
|
||||
falling back to recently watched live channels when no live favorites
|
||||
exist. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV
|
||||
3. `liveFavoriteCardsEnriched` and `recentLiveCardsEnriched` — two
|
||||
independent rails (`dashboard-live-favorites-rail` and
|
||||
`dashboard-recent-live-rail`); there is no fallback from one to the
|
||||
other. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV
|
||||
fallback order (`tvg-id` -> `tvg-name` -> channel name); EPG enrichment
|
||||
must use that key before falling back to the card title.
|
||||
4. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail
|
||||
@@ -126,7 +146,10 @@ Render rules:
|
||||
3. `DashboardDataService` is passive on construction. The dashboard feature
|
||||
owns the initial reloads for recent items, favorites, and Xtream recently
|
||||
added rows on page entry.
|
||||
4. No `Layout` state, no localStorage keys, no migrations.
|
||||
4. No dashboard-local `Layout` state, no localStorage keys, no migrations.
|
||||
Per-rail visibility is the one persisted preference, and it lives in the
|
||||
global settings store (`Settings.dashboardRails`), not in a
|
||||
dashboard-owned layout blob.
|
||||
5. Navigation state + deep-link targets come from the existing
|
||||
`getRecentItemLink()` / `getGlobalFavoriteLink()` / `getPlaylistLink()`
|
||||
helpers on `DashboardDataService` and reuse the workspace navigation
|
||||
@@ -158,7 +181,7 @@ Render rules:
|
||||
## Empty State
|
||||
|
||||
The welcome state is rendered via the existing
|
||||
`EmptyStateComponent` (`type="welcome"`) from
|
||||
`EmptyStateComponent` (`type="welcome-dashboard"`) from
|
||||
`libs/playlist/shared/ui`:
|
||||
|
||||
1. Illustration + headline + description from the existing M3U welcome
|
||||
@@ -185,8 +208,9 @@ The welcome state is rendered via the existing
|
||||
7. `Recently Used Sources` reflects recent source usage across all provider
|
||||
types, not just recent imports.
|
||||
8. The live rail title key must match the rendered source: favorites use
|
||||
`WORKSPACE.DASHBOARD.LIVE_FAVORITES`; recently watched fallback uses
|
||||
`WORKSPACE.DASHBOARD.LIVE_RECENT`.
|
||||
`WORKSPACE.DASHBOARD.LIVE_FAVORITES`; the recently-watched-live rail uses
|
||||
`WORKSPACE.DASHBOARD.RECENTLY_WATCHED_LIVE_TV`
|
||||
(`liveRailTitleKeyForSource` in `rails/dashboard-rail.utils.ts`).
|
||||
|
||||
## Adding Or Changing Rails
|
||||
|
||||
@@ -206,8 +230,9 @@ Current workflow:
|
||||
|
||||
Intentionally out of scope:
|
||||
|
||||
1. Customizable layout (drag/drop, resize, show/hide toggles, layout
|
||||
persistence). Removed in favor of a curated, opinionated order.
|
||||
1. Customizable layout (drag/drop, resize, freeform reordering). The rail
|
||||
order stays curated and opinionated. (Per-rail show/hide toggles have
|
||||
since shipped via `Settings.dashboardRails` — see Summary.)
|
||||
2. Freeform widget grid with collision management.
|
||||
3. External data rails such as RSS, sports, or news adapters.
|
||||
4. Per-user A/B variants of rail ordering.
|
||||
@@ -38,10 +38,15 @@ Core implementation:
|
||||
Current workspace routes:
|
||||
|
||||
1. `/` -> `/workspace`
|
||||
2. `/workspace` -> `/workspace/dashboard`
|
||||
2. `/workspace` -> functional redirect `workspaceEntryRedirect`
|
||||
(`WorkspaceStartupPreferencesService.resolveInitialWorkspacePath()`;
|
||||
`/workspace/dashboard` by default, `/workspace/sources` when the dashboard
|
||||
is disabled, or the last restorable route under
|
||||
`StartupBehavior.RestoreLastView` — `dashboard` itself is guarded by
|
||||
`dashboardAccessGuard`)
|
||||
3. `/workspace/dashboard`
|
||||
4. `/workspace/sources`
|
||||
5. `/workspace/playlists/:id/:view`
|
||||
5. `/workspace/playlists/:id/:view` (plus `favorites` and `recent` siblings)
|
||||
6. `/workspace/global-favorites`
|
||||
7. `/workspace/global-recent`
|
||||
8. `/workspace/search`
|
||||
|
||||
@@ -111,3 +111,12 @@ playable. MPEG-TS is preferred before HLS when the provider allows it because
|
||||
some portals return a valid HLS manifest while the first media segment fails in
|
||||
Chromium/video.js. PWA fallback keeps the REST MPEG-TS URL when no Electron
|
||||
probe API is available.
|
||||
|
||||
Catch-up is offered from the Xtream Live TV tab and from the unified
|
||||
collection surfaces (per-playlist and global Favorites and Recent). The
|
||||
`tv_archive` / `tv_archive_duration` columns are carried through the
|
||||
favorites and recently-viewed DB projections and mapped onto
|
||||
`UnifiedCollectionItem.tvArchive` / `tvArchiveDuration` so the shared live
|
||||
tab can gate the timeline's archive window. `tv_archive_duration` is
|
||||
interpreted as **days** everywhere, matching
|
||||
`live-stream-layout.controlledArchiveDays` (issue #1138).
|
||||
@@ -0,0 +1,658 @@
|
||||
# Linux Embedded MPV Frame-Copy Packaging Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship a verified Linux x64 frame-copy runtime in AppImage, DEB, RPM, Pacman, Snap, and Flatpak while preserving out-of-process libmpv isolation and honest native-view fallback.
|
||||
|
||||
**Architecture:** Split official Linux packaging into system-runtime and bundled-runtime passes because Electron Builder reuses one unpacked layout per pass. A versioned manifest plus a real helper `--runtime-probe` gates frame-copy before BrowserWindow creation; only the helper may link libmpv, and foreign-architecture packages retain the unavailable marker.
|
||||
|
||||
**Tech Stack:** Electron 41, Node/TypeScript, C++17/N-API, libmpv render API, EGL/OpenGL/GBM, ELF/RPATH tooling, electron-builder 26, Nx/Jest/node:test, Playwright, GitHub Actions, AppImage/DEB/RPM/Pacman/Snap/Flatpak.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Runtime contracts and staging
|
||||
|
||||
- Create `tools/embedded-mpv/linux-runtime-manifest.cjs`
|
||||
- Parse, normalize, and validate Linux frame-copy runtime manifests.
|
||||
- Create `tools/embedded-mpv/build-linux-runtime.mjs`
|
||||
- Build the pinned LGPL-compatible FFmpeg/libass/libplacebo/libmpv prefix.
|
||||
- Modify `tools/embedded-mpv/stage-runtime.mjs`
|
||||
- Stage Linux shared libraries and reject incomplete release manifests.
|
||||
- Modify `apps/electron-backend/build-embedded-mpv.js`
|
||||
- Build against staged Linux libmpv, copy the bundled closure, and emit the
|
||||
profile-neutral build manifest.
|
||||
- Modify `apps/electron-backend/native/binding.gyp`
|
||||
- Keep helper RPATH relative and remove build-host RPATH.
|
||||
- Modify `package.json`
|
||||
- Expose the Linux runtime build command.
|
||||
|
||||
### Packaging profiles and validation
|
||||
|
||||
- Create `tools/packaging/linux-frame-copy-profile.cjs`
|
||||
- Own profile names, target sets, manifest origins, and package dependencies.
|
||||
- Create `tools/packaging/linux-frame-copy-profile.test.mjs`
|
||||
- Verify profile/target/dependency mapping and invalid combinations.
|
||||
- Modify `electron-builder.json`
|
||||
- Declare DEB/RPM/Pacman libmpv dependencies.
|
||||
- Modify `tools/packaging/embedded-mpv-frame-copy-files.cjs`
|
||||
- Package Linux helper/reader and select/remove private runtime by profile.
|
||||
- Modify `tools/packaging/embedded-mpv-packaging.cjs`
|
||||
- Validate Linux ELF linkage, manifest, files, modes, RPATH, and isolation.
|
||||
- Modify `tools/packaging/embedded-mpv-arch.test.mjs`
|
||||
- Cover system, bundled, malformed, and foreign-architecture layouts.
|
||||
- Modify `tools/packaging/electron-after-pack.cjs`
|
||||
- Pass the required Linux profile into preparation and validation.
|
||||
- Modify `tools/packaging/verify-electron-package-layout.mjs`
|
||||
- Verify the expected profile for every unpacked layout.
|
||||
- Modify `tools/packaging/project.json`
|
||||
- Add new tests and source inputs.
|
||||
|
||||
### Runtime capability probe
|
||||
|
||||
- Modify `apps/electron-backend/native/helper/frame_helper_gl.h`
|
||||
- Provide a context-only probe that does not create a playback session.
|
||||
- Modify `apps/electron-backend/native/helper/mpv_frame_helper.cpp`
|
||||
- Implement the versioned `--runtime-probe` JSON protocol.
|
||||
- Create `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts`
|
||||
- Validate manifest/files and run/cache the bounded helper probe.
|
||||
- Create `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts`
|
||||
- Cover all fail-closed paths and success caching.
|
||||
- Modify `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts`
|
||||
- Resolve an artifact set and delegate usability to the runtime probe.
|
||||
- Modify `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts`
|
||||
- Keep path/security coverage and add manifest/probe integration cases.
|
||||
- Modify `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`
|
||||
- Surface stable fallback diagnostics without changing native-view safety.
|
||||
|
||||
### Linux CI, package smoke, and documentation
|
||||
|
||||
- Create `tools/packaging/verify-linux-frame-copy-runtime.mjs`
|
||||
- Inspect real package payload ELF/modes/manifest and invoke the helper probe.
|
||||
- Create `tools/packaging/verify-linux-frame-copy-runtime.test.mjs`
|
||||
- Unit-test verifier parsing and failure reporting with fixtures.
|
||||
- Create `apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts`
|
||||
- Exercise packaged capability, a deterministic media frame, and fallback.
|
||||
- Modify `.github/workflows/build-and-make.yaml`
|
||||
- Build/cache runtime, split profiles, inspect every format, and run sandbox
|
||||
and container smoke coverage.
|
||||
- Modify `docs/architecture/embedded-mpv-native.md`
|
||||
- Modify `tools/embedded-mpv/README.md`
|
||||
- Modify `vendor/embedded-mpv/README.md`
|
||||
- Modify `AGENTS.md`
|
||||
- Modify `CLAUDE.md`
|
||||
- Document the final contract and verification matrix.
|
||||
|
||||
## Task 1: Define Linux Packaging Profiles
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tools/packaging/linux-frame-copy-profile.cjs`
|
||||
- Create: `tools/packaging/linux-frame-copy-profile.test.mjs`
|
||||
- Modify: `electron-builder.json`
|
||||
- Modify: `tools/packaging/project.json`
|
||||
|
||||
- [ ] **Step 1: Write failing profile tests**
|
||||
|
||||
Cover the exact public API:
|
||||
|
||||
```js
|
||||
assert.deepEqual(resolveLinuxFrameCopyProfile('system'), {
|
||||
name: 'system',
|
||||
runtimeMode: 'system',
|
||||
targets: ['deb', 'rpm', 'pacman'],
|
||||
manifestOrigin: 'system-libmpv-frame-copy',
|
||||
});
|
||||
assert.deepEqual(resolveLinuxFrameCopyProfile('portable').targets, [
|
||||
'appimage',
|
||||
'snap',
|
||||
]);
|
||||
assert.deepEqual(resolveLinuxFrameCopyProfile('flatpak').targets, ['flatpak']);
|
||||
assert.throws(() => resolveLinuxFrameCopyProfile('standard'), /Unsupported/);
|
||||
assert.deepEqual(LINUX_SYSTEM_PACKAGE_DEPENDENCIES, {
|
||||
deb: 'libmpv2',
|
||||
rpm: 'mpv-libs',
|
||||
pacman: 'mpv',
|
||||
});
|
||||
assert.deepEqual(validateLinuxProfileTargets('system', ['deb', 'AppImage']), [
|
||||
'Linux frame-copy profile "system" cannot build target "appimage".',
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run RED**
|
||||
|
||||
```bash
|
||||
node --test tools/packaging/linux-frame-copy-profile.test.mjs
|
||||
```
|
||||
|
||||
Expected: FAIL because the profile module does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the profile module and package dependencies**
|
||||
|
||||
Export immutable `LINUX_FRAME_COPY_PROFILES`,
|
||||
`LINUX_SYSTEM_PACKAGE_DEPENDENCIES`, `resolveLinuxFrameCopyProfile()`, and
|
||||
`validateLinuxProfileTargets()`. Add `deb.depends += libmpv2`,
|
||||
`rpm.depends += mpv-libs`, and `pacman.depends += mpv` without replacing
|
||||
Electron Builder's existing defaults.
|
||||
|
||||
- [ ] **Step 4: Register and run GREEN**
|
||||
|
||||
Add the new test to `packaging:test`, then run:
|
||||
|
||||
```bash
|
||||
pnpm nx test packaging --skip-nx-cache
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add electron-builder.json tools/packaging
|
||||
git commit -m "feat(packaging): define Linux frame-copy profiles"
|
||||
```
|
||||
|
||||
## Task 2: Stage A Pinned LGPL Linux Runtime
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tools/embedded-mpv/linux-runtime-manifest.cjs`
|
||||
- Create: `tools/embedded-mpv/linux-runtime-manifest.test.mjs`
|
||||
- Create: `tools/embedded-mpv/build-linux-runtime.mjs`
|
||||
- Modify: `tools/embedded-mpv/stage-runtime.mjs`
|
||||
- Modify: `package.json`
|
||||
- Modify: `tools/packaging/project.json`
|
||||
|
||||
- [ ] **Step 1: Write failing manifest/staging tests**
|
||||
|
||||
Use temporary prefixes to prove:
|
||||
|
||||
```js
|
||||
assert.equal(validateLinuxRuntimeManifest(validManifest).length, 0);
|
||||
assert.match(
|
||||
validateLinuxRuntimeManifest({
|
||||
...validManifest,
|
||||
ffmpeg: { configureFlags: ['--enable-gpl'] },
|
||||
})[0],
|
||||
/--enable-gpl/
|
||||
);
|
||||
assert.match(
|
||||
validateLinuxRuntimeManifest({
|
||||
...validManifest,
|
||||
mpv: { mesonFlags: ['-Dgpl=true'] },
|
||||
})[0],
|
||||
/-Dgpl=false/
|
||||
);
|
||||
```
|
||||
|
||||
Exercise `stage-runtime.mjs linux x64 <prefix>` and assert it copies
|
||||
`libmpv.so.2` plus all manifest-declared `.so` files and records byte sizes.
|
||||
|
||||
- [ ] **Step 2: Run RED**
|
||||
|
||||
```bash
|
||||
node --test tools/embedded-mpv/linux-runtime-manifest.test.mjs
|
||||
```
|
||||
|
||||
Expected: FAIL because the validator/build/staging contract is absent.
|
||||
|
||||
- [ ] **Step 3: Implement the source builder**
|
||||
|
||||
Reuse the pinned package versions already used by the macOS builder. Linux
|
||||
FFmpeg flags must include:
|
||||
|
||||
```text
|
||||
--enable-shared --disable-static --disable-programs --disable-doc
|
||||
--disable-debug --disable-autodetect --disable-gpl --disable-nonfree
|
||||
--enable-pic --enable-pthreads
|
||||
```
|
||||
|
||||
Linux mpv flags must include:
|
||||
|
||||
```text
|
||||
-Dgpl=false -Dlibmpv=true -Dcplayer=false -Dtests=false
|
||||
-Dlua=disabled -Djavascript=disabled -Dcplugins=disabled
|
||||
-Dlibarchive=disabled -Dlibbluray=disabled -Ddvdnav=disabled
|
||||
-Dcdda=disabled -Ddvbin=disabled -Dvulkan=disabled
|
||||
-Dplain-gl=enabled -Degl=enabled -Dgbm=enabled
|
||||
```
|
||||
|
||||
Record downloaded SHA-256 values, git commits/submodules, exact flags, runtime
|
||||
file names/sizes, and source-distribution obligations. Pin the hwdata v0.409
|
||||
archive and record its `pnp.ids` as a build input to libdisplay-info 0.1.1.
|
||||
Stage private `hwdata.pc` metadata and run libdisplay-info's Meson setup with a
|
||||
prefix-only pkg-config environment so the upstream
|
||||
`/usr/share/hwdata/pnp.ids` fallback is unreachable. Include the exact hwdata
|
||||
archive and its `GPL-2.0-or-later OR XFree86-1.0` notice in the release source
|
||||
bundle.
|
||||
|
||||
- [ ] **Step 4: Implement Linux staging**
|
||||
|
||||
Require `include/mpv/client.h`, a versioned `libmpv.so.*`, and a valid manifest.
|
||||
Copy only manifest-declared shared libraries and preserve SONAME symlinks as
|
||||
materialized regular files so Electron Builder cannot lose them.
|
||||
|
||||
- [ ] **Step 5: Run GREEN and static policy checks**
|
||||
|
||||
```bash
|
||||
pnpm nx test packaging --skip-nx-cache
|
||||
node --check tools/embedded-mpv/build-linux-runtime.mjs
|
||||
node --check tools/embedded-mpv/stage-runtime.mjs
|
||||
```
|
||||
|
||||
Expected: PASS and no GPL/nonfree-enabling flag in generated policy fixtures.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json tools/embedded-mpv tools/packaging/project.json
|
||||
git commit -m "feat(embedded-mpv): stage LGPL Linux runtime"
|
||||
```
|
||||
|
||||
## Task 3: Build A Relocatable Isolated Helper
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/electron-backend/native/binding.gyp`
|
||||
- Modify: `apps/electron-backend/build-embedded-mpv.js`
|
||||
- Test: `apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Extend source-policy tests**
|
||||
|
||||
Assert the Linux helper has `$ORIGIN/lib` and no absolute build-host RPATH,
|
||||
the addon does not use `-lmpv`, the staged closure is copied, and the build
|
||||
manifest identifies both allowed package modes.
|
||||
|
||||
- [ ] **Step 2: Run RED**
|
||||
|
||||
```bash
|
||||
pnpm nx test electron-backend --skip-nx-cache --runInBand \
|
||||
--testPathPatterns=embedded-mpv-native-source.spec
|
||||
```
|
||||
|
||||
Expected: FAIL on the current absolute `LINUX_NATIVE_LIBRARY_DIR` RPATH and
|
||||
`external-mpv-process`-only manifest.
|
||||
|
||||
- [ ] **Step 3: Update native build integration**
|
||||
|
||||
Link the helper against staged `libmpv.so`, retain only:
|
||||
|
||||
```text
|
||||
-Wl,--enable-new-dtags
|
||||
-Wl,-rpath,$ORIGIN/lib
|
||||
```
|
||||
|
||||
Copy the staged shared-library closure to build output for downstream bundled
|
||||
profiles, but keep `embedded_mpv.node` dynamically independent of libmpv.
|
||||
Write a build manifest that carries the runtime metadata without prematurely
|
||||
choosing `system` versus `portable`.
|
||||
|
||||
- [ ] **Step 4: Run GREEN**
|
||||
|
||||
```bash
|
||||
pnpm nx test electron-backend --skip-nx-cache --runInBand \
|
||||
--testPathPatterns=embedded-mpv-native-source.spec
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/electron-backend/native/binding.gyp \
|
||||
apps/electron-backend/build-embedded-mpv.js \
|
||||
apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts
|
||||
git commit -m "feat(embedded-mpv): build relocatable Linux helper"
|
||||
```
|
||||
|
||||
## Task 4: Package And Validate Each Runtime Mode
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/packaging/embedded-mpv-frame-copy-files.cjs`
|
||||
- Modify: `tools/packaging/embedded-mpv-packaging.cjs`
|
||||
- Modify: `tools/packaging/embedded-mpv-arch.test.mjs`
|
||||
- Modify: `tools/packaging/electron-after-pack.cjs`
|
||||
- Modify: `tools/packaging/verify-electron-package-layout.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing package-layout tests**
|
||||
|
||||
Create realistic temp layouts and prove:
|
||||
|
||||
- system mode requires executable helper, reader, system manifest, no
|
||||
`native/lib/libmpv*`;
|
||||
- bundled mode requires all manifest files and rejects missing/runtime-prefix
|
||||
links;
|
||||
- helper mode `0644` is rejected;
|
||||
- reader symlinks/directories are rejected;
|
||||
- Linux addon or Electron `DT_NEEDED libmpv` is rejected;
|
||||
- helper without `DT_NEEDED libmpv.so.2` is rejected;
|
||||
- foreign-arch layout contains only the unavailable marker.
|
||||
|
||||
- [ ] **Step 2: Run RED**
|
||||
|
||||
```bash
|
||||
pnpm nx test packaging --skip-nx-cache
|
||||
```
|
||||
|
||||
Expected: FAIL because Linux helpers are deleted and validation forbids them.
|
||||
|
||||
- [ ] **Step 3: Implement profile-aware preparation**
|
||||
|
||||
For x64:
|
||||
|
||||
- restore helper mode `0755`;
|
||||
- always retain the frame reader;
|
||||
- `system`: remove private runtime and write normalized system manifest;
|
||||
- `portable`/`flatpak`: retain only manifest-declared closure and write bundled
|
||||
manifest;
|
||||
- reject missing `IPTVNATOR_LINUX_FRAME_COPY_PROFILE` when Embedded MPV is
|
||||
required.
|
||||
|
||||
For foreign architectures, keep the current unavailable marker behavior and
|
||||
remove every native artifact.
|
||||
|
||||
- [ ] **Step 4: Implement ELF/package validation**
|
||||
|
||||
Use `readelf -d` for `NEEDED` and RPATH/RUNPATH inspection. Resolve bundled
|
||||
closure recursively from the private directory and permit only a documented
|
||||
glibc/driver/system allowlist outside it. Keep validation host-aware: pure
|
||||
manifest/mode checks run everywhere; ELF inspection is required on Linux CI.
|
||||
|
||||
- [ ] **Step 5: Run GREEN**
|
||||
|
||||
```bash
|
||||
pnpm nx test packaging --skip-nx-cache
|
||||
pnpm nx test electron-backend --skip-nx-cache --runInBand \
|
||||
--testPathPatterns=embedded-mpv-native-source.spec
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/packaging
|
||||
git commit -m "feat(packaging): ship Linux frame-copy artifacts"
|
||||
```
|
||||
|
||||
## Task 5: Add The Real Runtime Capability Probe
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/electron-backend/native/helper/frame_helper_gl.h`
|
||||
- Modify: `apps/electron-backend/native/helper/mpv_frame_helper.cpp`
|
||||
- Create: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts`
|
||||
- Create: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts`
|
||||
- Modify: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts`
|
||||
- Modify: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts`
|
||||
- Modify: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing TypeScript probe tests**
|
||||
|
||||
Inject filesystem and `spawnSync` collaborators. Cover:
|
||||
|
||||
```ts
|
||||
expect(probeRuntime(validArtifacts, successSpawn).usable).toBe(true);
|
||||
expect(probeRuntime(validArtifacts, timeoutSpawn).reason).toBe(
|
||||
'helper-probe-timeout'
|
||||
);
|
||||
expect(probeRuntime(validArtifacts, nonzeroSpawn).reason).toBe(
|
||||
'helper-probe-failed'
|
||||
);
|
||||
expect(probeRuntime(validArtifacts, invalidJsonSpawn).reason).toBe(
|
||||
'helper-probe-invalid-output'
|
||||
);
|
||||
expect(probeRuntime(missingManifest, successSpawn).reason).toBe(
|
||||
'runtime-manifest-missing'
|
||||
);
|
||||
expect(successSpawn).toHaveBeenCalledTimes(1);
|
||||
```
|
||||
|
||||
The last assertion calls the public probe twice and proves process-lifetime
|
||||
caching.
|
||||
|
||||
- [ ] **Step 2: Run RED**
|
||||
|
||||
```bash
|
||||
pnpm nx test electron-backend --skip-nx-cache --runInBand \
|
||||
--testPathPatterns=embedded-mpv-frame-copy-runtime.spec
|
||||
```
|
||||
|
||||
Expected: FAIL because the runtime probe module does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement helper `--runtime-probe`**
|
||||
|
||||
Emit exactly one line:
|
||||
|
||||
```json
|
||||
{ "protocol": 1, "usable": true, "libmpv": "2.x", "renderApi": "egl" }
|
||||
```
|
||||
|
||||
Exit nonzero with a JSON `reason` when `mpv_create`, `mpv_initialize`, or the
|
||||
EGL/OpenGL context probe fails. Do not create shared memory, open a URL, or
|
||||
enter the normal command loop.
|
||||
|
||||
- [ ] **Step 4: Implement fail-closed main-process probing**
|
||||
|
||||
Validate manifest/artifacts first, then run:
|
||||
|
||||
```ts
|
||||
spawnSync(helperPath, ['--runtime-probe'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
env: probeEnvironment,
|
||||
});
|
||||
```
|
||||
|
||||
Cache by helper/manifest identity. Never throw across startup; return a stable
|
||||
reason and make `isFrameCopyRuntimeUsable()` depend on `.usable`.
|
||||
|
||||
- [ ] **Step 5: Run GREEN and related regression tests**
|
||||
|
||||
```bash
|
||||
pnpm nx test electron-backend --skip-nx-cache --runInBand \
|
||||
--testPathPatterns='embedded-mpv-frame-copy-(runtime|platform).util.spec|app.spec|embedded-mpv-native.service.spec'
|
||||
```
|
||||
|
||||
Expected: PASS; unavailable runtime keeps sandbox enabled and selects native.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/electron-backend/native/helper \
|
||||
apps/electron-backend/src/app/services
|
||||
git commit -m "feat(embedded-mpv): probe Linux frame-copy runtime"
|
||||
```
|
||||
|
||||
## Task 6: Split Linux CI And Inspect Every Artifact
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tools/packaging/verify-linux-frame-copy-runtime.mjs`
|
||||
- Create: `tools/packaging/verify-linux-frame-copy-runtime.test.mjs`
|
||||
- Modify: `.github/workflows/build-and-make.yaml`
|
||||
- Modify: `tools/packaging/project.json`
|
||||
|
||||
- [ ] **Step 1: Write failing verifier tests**
|
||||
|
||||
Test payload discovery for `.AppImage`, `.deb`, `.rpm`, Pacman archive,
|
||||
`.snap`, and `.flatpak`, plus clear errors for a missing helper, wrong mode,
|
||||
wrong profile, direct addon libmpv linkage, and unresolved helper dependency.
|
||||
|
||||
- [ ] **Step 2: Run RED**
|
||||
|
||||
```bash
|
||||
node --test tools/packaging/verify-linux-frame-copy-runtime.test.mjs
|
||||
```
|
||||
|
||||
Expected: FAIL because the verifier does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement artifact verification**
|
||||
|
||||
Provide `--artifact <path> --profile <name>`. Extract/mount into a temp
|
||||
directory, find the native layout, run manifest/mode/ELF checks, and execute
|
||||
`iptvnator_mpv_helper --runtime-probe` in the package's intended environment.
|
||||
Always clean temporary mounts/directories.
|
||||
|
||||
- [ ] **Step 4: Split and harden CI**
|
||||
|
||||
Change Linux matrix entries to:
|
||||
|
||||
```yaml
|
||||
- os: linux
|
||||
linux_profile: system
|
||||
- os: linux
|
||||
linux_profile: portable
|
||||
- os: linux
|
||||
linux_profile: flatpak
|
||||
```
|
||||
|
||||
Filter targets exactly per profile and set
|
||||
`IPTVNATOR_LINUX_FRAME_COPY_PROFILE`. Build/cache the pinned runtime once per
|
||||
source/tool hash. Add format-specific extraction/installation tools and invoke
|
||||
the verifier for every produced artifact.
|
||||
|
||||
Run system formats in matching containers with `libmpv2`, `mpv-libs`, or
|
||||
`mpv`; run AppImage directly with extraction fallback; install and probe Snap
|
||||
and Flatpak inside their sandboxes.
|
||||
|
||||
- [ ] **Step 5: Run GREEN and workflow source regressions**
|
||||
|
||||
```bash
|
||||
pnpm nx test packaging --skip-nx-cache
|
||||
pnpm nx test electron-backend --skip-nx-cache --runInBand \
|
||||
--testPathPatterns=embedded-mpv-native-source.spec
|
||||
```
|
||||
|
||||
Expected: PASS and source tests prove all three profiles and six formats.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/build-and-make.yaml tools/packaging
|
||||
git commit -m "ci: verify Linux frame-copy packages"
|
||||
```
|
||||
|
||||
## Task 7: Add Packaged Playback And Fallback Smoke Coverage
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts`
|
||||
- Modify: `.github/workflows/build-and-make.yaml`
|
||||
|
||||
- [ ] **Step 1: Write the packaged E2E**
|
||||
|
||||
Use the existing Electron test fixtures and a generated two-second local media
|
||||
fixture. Assert the support response reports `frameCopyAvailable: true`,
|
||||
activate the engine, load the fixture, observe a nonzero frame generation and
|
||||
playing/paused snapshot, then relaunch with the runtime hidden and assert
|
||||
native engine selection without a main-process crash.
|
||||
|
||||
- [ ] **Step 2: Run the closest local parse/list check**
|
||||
|
||||
```bash
|
||||
pnpm nx lint electron-backend-e2e
|
||||
pnpm nx show project electron-backend-e2e
|
||||
```
|
||||
|
||||
Expected: PASS on macOS; the actual test is Linux-packaged-only.
|
||||
|
||||
- [ ] **Step 3: Wire the Linux packaged smoke**
|
||||
|
||||
Run the spec against the unpacked x64 bundled layout under software EGL
|
||||
(`LIBGL_ALWAYS_SOFTWARE=1`) and keep a hardware-enabled smoke as a separate
|
||||
non-blocking diagnostic when the CI runner exposes DRI.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts \
|
||||
.github/workflows/build-and-make.yaml
|
||||
git commit -m "test(embedded-mpv): smoke packaged Linux frame-copy"
|
||||
```
|
||||
|
||||
## Task 8: Update Canonical Documentation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/architecture/embedded-mpv-native.md`
|
||||
- Modify: `tools/embedded-mpv/README.md`
|
||||
- Modify: `vendor/embedded-mpv/README.md`
|
||||
- Modify: `AGENTS.md`
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Replace the dev-only Linux contract**
|
||||
|
||||
Document x64 support across all six formats, the three profiles, dependency
|
||||
names, runtime manifest/probe, codec baseline, source obligations, fallback,
|
||||
and ARM unavailable behavior. Keep `AGENTS.md` and `CLAUDE.md` synchronized.
|
||||
|
||||
- [ ] **Step 2: Verify documentation consistency**
|
||||
|
||||
```bash
|
||||
rg -n "dev-build-only|stripped from packages|must not bundle libmpv" \
|
||||
AGENTS.md CLAUDE.md docs/architecture/embedded-mpv-native.md \
|
||||
tools/embedded-mpv/README.md vendor/embedded-mpv/README.md
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: no stale Linux frame-copy shipping claim; any remaining
|
||||
“must not bundle” text applies specifically to Electron/addon or system
|
||||
profiles.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add AGENTS.md CLAUDE.md docs/architecture/embedded-mpv-native.md \
|
||||
tools/embedded-mpv/README.md vendor/embedded-mpv/README.md
|
||||
git commit -m "docs: document Linux frame-copy packages"
|
||||
```
|
||||
|
||||
## Task 9: Final Verification Matrix
|
||||
|
||||
**Files:** No production edits unless verification exposes a defect.
|
||||
|
||||
- [ ] **Step 1: Run local project discovery and affected checks**
|
||||
|
||||
```bash
|
||||
pnpm nx show projects
|
||||
pnpm nx test packaging --skip-nx-cache
|
||||
pnpm nx test electron-backend --skip-nx-cache --runInBand
|
||||
pnpm nx lint packaging --skip-nx-cache
|
||||
pnpm nx lint electron-backend --skip-nx-cache
|
||||
pnpm nx lint electron-backend-e2e --skip-nx-cache
|
||||
pnpm nx build electron-backend --configuration=production --skip-nx-cache
|
||||
```
|
||||
|
||||
Expected: PASS or an explicitly recorded platform-only native-build skip on
|
||||
macOS without a vendored runtime.
|
||||
|
||||
- [ ] **Step 2: Verify isolation source and local artifacts**
|
||||
|
||||
```bash
|
||||
rg -n -- '-lmpv|libmpv' apps/electron-backend/native/binding.gyp \
|
||||
tools/packaging apps/electron-backend/build-embedded-mpv.js
|
||||
git diff --check origin/master...HEAD
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: only the helper target links libmpv; no unstaged/unexplained files.
|
||||
|
||||
- [ ] **Step 3: Record the exact evidence matrix**
|
||||
|
||||
Report separately:
|
||||
|
||||
- verified locally on macOS: Node/Jest/lint/build/static/package-policy tests;
|
||||
- structurally verified but not executable locally: Linux runtime builder and
|
||||
artifact extraction code;
|
||||
- requires Linux CI: ELF resolution, actual six-format packages, package
|
||||
managers, Snap/Flatpak confinement, EGL/GBM, frame production, and fallback
|
||||
with libmpv removed.
|
||||
|
||||
- [ ] **Step 4: Stop before publication**
|
||||
|
||||
Do not push, open a PR, publish artifacts, or merge. Leave the fully checked
|
||||
local branch ready for explicit user confirmation in a new task.
|
||||
@@ -0,0 +1,738 @@
|
||||
# Flatpak Zypak Launcher Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use
|
||||
> superpowers:subagent-driven-development (recommended) or
|
||||
> superpowers:executing-plans to implement this plan task-by-task. Steps use
|
||||
> checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Keep the Flatpak `iptvnator` entry as the real Electron ELF so
|
||||
Electron Builder passes it directly to Zypak, while preserving the existing
|
||||
Linux sandbox wrapper for every other package target.
|
||||
|
||||
**Architecture:** A small CommonJS launcher-layout contract resolves the
|
||||
Electron binary name from normalized target names and rejects mixed Flatpak
|
||||
passes. The afterPack hook, unpacked-layout validators, final-artifact
|
||||
validator, and CI all consume the same target-dependent contract.
|
||||
|
||||
**Tech Stack:** Node.js CommonJS/ESM, `node:test`, Nx packaging targets,
|
||||
Electron Builder 26, Flatpak/Zypak, GitHub Actions.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add the shared launcher contract and fix afterPack
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tools/packaging/linux-launcher-layout.cjs`
|
||||
- Create: `tools/packaging/linux-after-pack.test.mjs`
|
||||
- Modify: `tools/packaging/linux-after-pack.cjs`
|
||||
- Modify: `tools/packaging/electron-after-pack.cjs`
|
||||
- Modify: `tools/packaging/project.json`
|
||||
|
||||
- [ ] **Step 1: Write the failing isolated-Flatpak hook test**
|
||||
|
||||
Create a temporary executable with ELF magic, call the real hook, and assert
|
||||
that Flatpak retains the exact original file:
|
||||
|
||||
```js
|
||||
test('preserves the Electron ELF for an isolated Flatpak target', async (t) => {
|
||||
const fixture = createLauncherFixture();
|
||||
t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true }));
|
||||
|
||||
await linuxAfterPack(createAfterPackParams(fixture.appOutDir, ['flatpak']));
|
||||
|
||||
assert.deepEqual(
|
||||
fs.readFileSync(fixture.executablePath),
|
||||
fixture.executableBytes
|
||||
);
|
||||
assert.equal(fs.existsSync(`${fixture.executablePath}.bin`), false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tools/packaging/linux-after-pack.test.mjs
|
||||
```
|
||||
|
||||
Expected: FAIL because the current hook replaces `iptvnator` with a Bash
|
||||
script and creates `iptvnator.bin`.
|
||||
|
||||
- [ ] **Step 3: Add the pure launcher-layout resolver**
|
||||
|
||||
Implement a strict resolver with this public contract:
|
||||
|
||||
```js
|
||||
function resolveLinuxLauncherLayout(targets, executableName = 'iptvnator') {
|
||||
if (!Array.isArray(targets) || targets.length === 0) {
|
||||
throw new Error(
|
||||
'Linux launcher layout requires at least one Electron Builder target.'
|
||||
);
|
||||
}
|
||||
|
||||
const targetNames = targets.map((target) => {
|
||||
const value = typeof target === 'string' ? target : target?.name;
|
||||
const name = String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!name) {
|
||||
throw new Error(
|
||||
'Linux launcher targets must expose a non-empty name.'
|
||||
);
|
||||
}
|
||||
return name;
|
||||
});
|
||||
|
||||
if (new Set(targetNames).size !== targetNames.length) {
|
||||
throw new Error('Linux launcher targets must be unique.');
|
||||
}
|
||||
|
||||
const flatpak = targetNames.includes('flatpak');
|
||||
if (flatpak && targetNames.length !== 1) {
|
||||
throw new Error(
|
||||
'Flatpak must be packaged in an isolated Electron Builder pass so Zypak receives the Electron ELF directly.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
targetNames,
|
||||
electronBinaryName: flatpak ? executableName : `${executableName}.bin`,
|
||||
wrapperRequired: !flatpak,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Export `resolveLinuxLauncherLayout`.
|
||||
|
||||
- [ ] **Step 4: Make the Linux hook preserve isolated Flatpak**
|
||||
|
||||
Resolve the layout before any filesystem mutation. For Flatpak, log that the
|
||||
ELF is preserved and return. For other targets, rename to the resolved
|
||||
`electronBinaryName` and create the unchanged sandbox wrapper:
|
||||
|
||||
```js
|
||||
async function afterPackHook(params, { targetNames = params.targets } = {}) {
|
||||
if (params.electronPlatformName !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = resolveLinuxLauncherLayout(
|
||||
targetNames,
|
||||
params.packager.executableName
|
||||
);
|
||||
if (!layout.wrapperRequired) {
|
||||
log('preserving Flatpak Electron ELF for direct Zypak launch');
|
||||
return;
|
||||
}
|
||||
|
||||
const executable = path.join(
|
||||
params.appOutDir,
|
||||
params.packager.executableName
|
||||
);
|
||||
const electronBinary = path.join(
|
||||
params.appOutDir,
|
||||
layout.electronBinaryName
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.rename(executable, electronBinary);
|
||||
await fs.writeFile(
|
||||
executable,
|
||||
createLoaderScript({
|
||||
executableName: params.packager.executableName,
|
||||
productName: params.packager.appInfo.productName,
|
||||
})
|
||||
);
|
||||
await fs.chmod(executable, 0o755);
|
||||
} catch (error) {
|
||||
log(`failed to create launcher wrapper: ${error.message}`);
|
||||
throw new Error('Failed to create launcher wrapper');
|
||||
}
|
||||
|
||||
log('Linux launcher sandbox fix applied');
|
||||
}
|
||||
```
|
||||
|
||||
Pass `linuxPackagingContext?.targetNames` from `electron-after-pack.cjs` so
|
||||
the hook consumes the already validated afterPack target names:
|
||||
|
||||
```js
|
||||
await linuxAfterPack(params, {
|
||||
targetNames: linuxPackagingContext?.targetNames,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add non-Flatpak and mixed-target regression cases**
|
||||
|
||||
Use the real hook to prove:
|
||||
|
||||
```js
|
||||
test('keeps the sandbox wrapper for non-Flatpak targets', async (t) => {
|
||||
for (const targetName of ['appimage', 'deb', 'rpm', 'pacman', 'snap']) {
|
||||
const fixture = createLauncherFixture();
|
||||
t.after(() =>
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true })
|
||||
);
|
||||
await linuxAfterPack(
|
||||
createAfterPackParams(fixture.appOutDir, [targetName])
|
||||
);
|
||||
assert.deepEqual(
|
||||
fs.readFileSync(`${fixture.executablePath}.bin`),
|
||||
fixture.executableBytes
|
||||
);
|
||||
assert.match(
|
||||
fs.readFileSync(fixture.executablePath, 'utf8'),
|
||||
/exec "\$SCRIPT_DIR\/iptvnator\.bin"/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a mixed Flatpak pass before mutating the executable', async (t) => {
|
||||
const fixture = createLauncherFixture();
|
||||
t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true }));
|
||||
await assert.rejects(
|
||||
linuxAfterPack(
|
||||
createAfterPackParams(fixture.appOutDir, ['flatpak', 'appimage'])
|
||||
),
|
||||
/Flatpak must be packaged in an isolated Electron Builder pass/
|
||||
);
|
||||
assert.deepEqual(
|
||||
fs.readFileSync(fixture.executablePath),
|
||||
fixture.executableBytes
|
||||
);
|
||||
assert.equal(fs.existsSync(`${fixture.executablePath}.bin`), false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Register and run the focused test**
|
||||
|
||||
Add `linux-after-pack.test.mjs` to the explicit `packaging:test` command and
|
||||
inputs in `tools/packaging/project.json`.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tools/packaging/linux-after-pack.test.mjs
|
||||
```
|
||||
|
||||
Expected: all launcher tests PASS.
|
||||
|
||||
- [ ] **Step 7: Commit Task 1**
|
||||
|
||||
```bash
|
||||
git add tools/packaging/linux-launcher-layout.cjs \
|
||||
tools/packaging/linux-after-pack.cjs \
|
||||
tools/packaging/linux-after-pack.test.mjs \
|
||||
tools/packaging/electron-after-pack.cjs \
|
||||
tools/packaging/project.json
|
||||
git commit -m "fix(packaging): preserve Flatpak Electron ELF"
|
||||
```
|
||||
|
||||
### Task 2: Make unpacked-layout validation target-aware
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/packaging/embedded-mpv-packaging.cjs`
|
||||
- Modify: `tools/packaging/embedded-mpv-arch.test.mjs`
|
||||
- Modify: `tools/packaging/verify-electron-package-layout.mjs`
|
||||
- Modify: `tools/packaging/electron-package-identity.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Change the Flatpak fixture and verify RED**
|
||||
|
||||
In `prepares portable and Flatpak manifests with the exact bundled closure`,
|
||||
rename only the Flatpak fixture's Electron binary:
|
||||
|
||||
```js
|
||||
fs.renameSync(
|
||||
join(flatpak.appOutDir, 'iptvnator.bin'),
|
||||
join(flatpak.appOutDir, 'iptvnator')
|
||||
);
|
||||
```
|
||||
|
||||
Teach the test ELF inspector about both legitimate basenames:
|
||||
|
||||
```js
|
||||
['iptvnator', { needed: ['libc.so.6'], rpath: [], runpath: [] }],
|
||||
['iptvnator.bin', { needed: ['libc.so.6'], rpath: [], runpath: [] }],
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test --test-name-pattern='prepares portable and Flatpak manifests' \
|
||||
tools/packaging/embedded-mpv-arch.test.mjs
|
||||
```
|
||||
|
||||
Expected: FAIL with a missing `iptvnator.bin` validation error.
|
||||
|
||||
- [ ] **Step 2: Resolve the pristine Electron ELF through the shared contract**
|
||||
|
||||
Require `resolveLinuxLauncherLayout` in
|
||||
`embedded-mpv-packaging.cjs`. In `inspectLinuxElfIsolation`, resolve from
|
||||
`options.targetNames` and use its `electronBinaryName`:
|
||||
|
||||
```js
|
||||
let launcherLayout;
|
||||
try {
|
||||
launcherLayout = resolveLinuxLauncherLayout(
|
||||
options.targetNames,
|
||||
options.executableName ?? 'iptvnator'
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Unable to resolve Linux launcher layout: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const inspectedPaths = {
|
||||
electron: path.join(
|
||||
path.dirname(resourceDir),
|
||||
launcherLayout.electronBinaryName
|
||||
),
|
||||
addon: path.join(nativeDir, linuxFrameCopyArtifacts.addon.name),
|
||||
reader: path.join(nativeDir, linuxFrameCopyArtifacts.frameReader.name),
|
||||
helper: path.join(nativeDir, linuxFrameCopyArtifacts.helper.name),
|
||||
};
|
||||
for (const [index, libraryPath] of listElectronShippedLinuxLibraries(
|
||||
resourceDir,
|
||||
{ artifactFormat: options.artifactFormat }
|
||||
).entries()) {
|
||||
inspectedPaths[`electronLibrary:${index}`] = libraryPath;
|
||||
}
|
||||
```
|
||||
|
||||
Pass the normalized `targetNames` already calculated by
|
||||
`validateLinuxPackagedEmbeddedMpv` into the inspection options.
|
||||
|
||||
- [ ] **Step 3: Make the general package-layout verifier profile-aware**
|
||||
|
||||
Require the same resolver in `verify-electron-package-layout.mjs`, change
|
||||
`verifyLinuxLauncher` to accept `targetNames`, and call it with
|
||||
`linuxTargetNames`.
|
||||
|
||||
For Flatpak:
|
||||
|
||||
```js
|
||||
if (!layout.wrapperRequired) {
|
||||
if (fileExists(`${launcherPath}.bin`)) {
|
||||
errors.push(
|
||||
`Flatpak must not contain the Linux sandbox wrapper binary: ${launcherPath}.bin`
|
||||
);
|
||||
}
|
||||
if (!fileHasElfMagic(launcherPath)) {
|
||||
errors.push(
|
||||
`Flatpak launcher target must be an ELF executable: ${launcherPath}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const launcherBinaryPath = path.join(appDir, layout.electronBinaryName);
|
||||
if (!fileExists(launcherBinaryPath)) {
|
||||
errors.push(
|
||||
`Missing Linux launcher binary in ${appDir}: ${path.basename(launcherBinaryPath)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!fileExists(launcherPath)) {
|
||||
errors.push(
|
||||
`Missing Linux launcher wrapper in ${appDir}: ${path.basename(launcherPath)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const launcherScript = fs.readFileSync(launcherPath, 'utf8');
|
||||
const requiredMarkers = [
|
||||
'SCRIPT_PATH="${BASH_SOURCE[0]}"',
|
||||
'readlink -f "$SCRIPT_PATH"',
|
||||
`exec "$SCRIPT_DIR/${linuxExecutableName}.bin"`,
|
||||
];
|
||||
const missingMarkers = requiredMarkers.filter(
|
||||
(marker) => !launcherScript.includes(marker)
|
||||
);
|
||||
if (missingMarkers.length > 0) {
|
||||
errors.push(
|
||||
[
|
||||
`Linux launcher wrapper is missing symlink-safe logic in ${launcherPath}.`,
|
||||
'Missing markers:',
|
||||
...missingMarkers.map((marker) => `- ${marker}`),
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Implement `fileHasElfMagic` with one four-byte `fs.readSync` call and always
|
||||
close the descriptor:
|
||||
|
||||
```js
|
||||
function fileHasElfMagic(filePath) {
|
||||
const descriptor = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const magic = Buffer.alloc(4);
|
||||
return (
|
||||
fs.readSync(descriptor, magic, 0, magic.length, 0) ===
|
||||
magic.length &&
|
||||
magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
|
||||
);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Extend the package-identity source contract test**
|
||||
|
||||
Assert that the general verifier imports the shared resolver, calls
|
||||
`verifyLinuxLauncher(resourceDir, linuxTargetNames, errors)`, checks ELF magic,
|
||||
and does not unconditionally set `launcherBinaryPath` before resolving target
|
||||
layout.
|
||||
|
||||
- [ ] **Step 5: Run targeted validation**
|
||||
|
||||
```bash
|
||||
node --test --test-name-pattern='prepares portable and Flatpak manifests' \
|
||||
tools/packaging/embedded-mpv-arch.test.mjs
|
||||
node --test --test-name-pattern='package layout verifier uses' \
|
||||
tools/packaging/electron-package-identity.test.mjs
|
||||
```
|
||||
|
||||
Expected: both commands PASS.
|
||||
|
||||
- [ ] **Step 6: Commit Task 2**
|
||||
|
||||
```bash
|
||||
git add tools/packaging/embedded-mpv-packaging.cjs \
|
||||
tools/packaging/embedded-mpv-arch.test.mjs \
|
||||
tools/packaging/verify-electron-package-layout.mjs \
|
||||
tools/packaging/electron-package-identity.test.mjs
|
||||
git commit -m "fix(packaging): validate Flatpak launcher ELF"
|
||||
```
|
||||
|
||||
### Task 3: Update final-artifact verification, CI, and documentation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/packaging/verify-linux-frame-copy-runtime.mjs`
|
||||
- Modify: `tools/packaging/verify-linux-frame-copy-runtime.test.mjs`
|
||||
- Modify: `.github/workflows/build-and-make.yaml`
|
||||
- Modify: `tools/packaging/configure-linux-frame-copy-build.test.mjs`
|
||||
- Modify: `docs/architecture/embedded-mpv-native.md`
|
||||
- Modify: `tools/embedded-mpv/README.md`
|
||||
- Modify: `docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md`
|
||||
- Modify: `AGENTS.md`
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Add a failing extracted-Flatpak regression**
|
||||
|
||||
Allow the fixture helper to select the Electron filename:
|
||||
|
||||
```js
|
||||
function createSystemPayload({
|
||||
architecture = 'x64',
|
||||
electronBinaryName = 'iptvnator.bin',
|
||||
} = {}) {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'iptvnator-verifier-layout-')
|
||||
);
|
||||
const appDir = path.join(root, 'opt', 'IPTVnator');
|
||||
const resourceDir = path.join(appDir, 'resources');
|
||||
const nativeDir = path.join(
|
||||
resourceDir,
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
fs.mkdirSync(nativeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(appDir, electronBinaryName),
|
||||
elfHeader(architecture)
|
||||
);
|
||||
|
||||
if (architecture === 'x64') {
|
||||
fs.writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', {
|
||||
mode: 0o644,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(nativeDir, 'embedded_mpv_frame_reader.node'),
|
||||
'reader',
|
||||
{ mode: 0o644 }
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nativeDir, 'iptvnator_mpv_helper'),
|
||||
'helper',
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nativeDir, 'embedded-mpv-runtime.json'),
|
||||
`${JSON.stringify(SYSTEM_MANIFEST, null, 2)}\n`,
|
||||
{ mode: 0o644 }
|
||||
);
|
||||
} else {
|
||||
fs.writeFileSync(
|
||||
path.join(nativeDir, 'embedded-mpv-unavailable.txt'),
|
||||
`Unavailable for ${architecture}\n`
|
||||
);
|
||||
}
|
||||
|
||||
return { root, appDir, resourceDir, nativeDir };
|
||||
}
|
||||
```
|
||||
|
||||
Use a foreign-architecture marker fixture so this test isolates launcher
|
||||
selection without needing a bundled x64 manifest:
|
||||
|
||||
```js
|
||||
test('validates a marker-only Flatpak with an unwrapped Electron ELF', () => {
|
||||
const fixture = createSystemPayload({
|
||||
architecture: 'arm64',
|
||||
electronBinaryName: 'iptvnator',
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(
|
||||
verifyExtractedLinuxFrameCopyRuntime({
|
||||
resourceDir: fixture.resourceDir,
|
||||
artifactFormat: 'flatpak',
|
||||
profileName: 'flatpak',
|
||||
packageDependencies: [],
|
||||
elfInspector: validElfInspector,
|
||||
probeRunner() {
|
||||
assert.fail(
|
||||
'foreign Flatpak must not run the helper probe'
|
||||
);
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test --test-name-pattern='marker-only Flatpak with an unwrapped' \
|
||||
tools/packaging/verify-linux-frame-copy-runtime.test.mjs
|
||||
```
|
||||
|
||||
Expected: FAIL because the verifier reads `iptvnator.bin`.
|
||||
|
||||
- [ ] **Step 2: Resolve every final-artifact Electron path consistently**
|
||||
|
||||
Require `resolveLinuxLauncherLayout` and add:
|
||||
|
||||
```js
|
||||
function resolveElectronBinaryPath(resourceDir, artifactFormat) {
|
||||
const layout = resolveLinuxLauncherLayout([artifactFormat]);
|
||||
return path.join(path.dirname(resourceDir), layout.electronBinaryName);
|
||||
}
|
||||
```
|
||||
|
||||
Use it in:
|
||||
|
||||
- `validateElectronIsolation`;
|
||||
- `verifyExtractedLinuxFrameCopyRuntime` architecture detection;
|
||||
- the architecture returned from `verifyLinuxFrameCopyArtifact`.
|
||||
|
||||
Keep Snap/AppImage/DEB/RPM/Pacman expectations on `iptvnator.bin`.
|
||||
|
||||
- [ ] **Step 3: Add an outer artifact-verifier Flatpak regression**
|
||||
|
||||
Create a temporary `.flatpak` file, inject an extractor that writes
|
||||
`iptvnator` ELF and the marker-only native directory under its supplied
|
||||
destination, and assert:
|
||||
|
||||
```js
|
||||
assert.deepEqual(
|
||||
verifyLinuxFrameCopyArtifact({
|
||||
artifactPath,
|
||||
profileName: 'flatpak',
|
||||
extractArtifact({ destination }) {
|
||||
const appDir = path.join(
|
||||
destination,
|
||||
'files',
|
||||
'lib',
|
||||
'com.fourgray.iptvnator'
|
||||
);
|
||||
const resourceDir = path.join(appDir, 'resources');
|
||||
const nativeDir = path.join(
|
||||
resourceDir,
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
fs.mkdirSync(nativeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(appDir, 'iptvnator'),
|
||||
elfHeader('arm64')
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nativeDir, 'embedded-mpv-unavailable.txt'),
|
||||
'Unavailable for arm64\n'
|
||||
);
|
||||
return destination;
|
||||
},
|
||||
metadataReader: () => ({
|
||||
declaredArch: 'arm64',
|
||||
dependencies: [],
|
||||
}),
|
||||
elfInspector: validElfInspector,
|
||||
probeRunner() {
|
||||
assert.fail('foreign Flatpak must not probe');
|
||||
},
|
||||
}),
|
||||
{
|
||||
artifactPath: path.resolve(artifactPath),
|
||||
format: 'flatpak',
|
||||
profileName: 'flatpak',
|
||||
architecture: 'arm64',
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Invert the installed-Flatpak CI layout assertion**
|
||||
|
||||
Inside the existing sandbox shell check:
|
||||
|
||||
```bash
|
||||
LAUNCHER_PATH="$(readlink -f /app/bin/iptvnator)"
|
||||
test -f "${LAUNCHER_PATH}"
|
||||
test ! -e "${LAUNCHER_PATH}.bin"
|
||||
ELF_MAGIC="$(od -An -tx1 -N4 "${LAUNCHER_PATH}" | tr -d "[:space:]")"
|
||||
test "${ELF_MAGIC}" = "7f454c46"
|
||||
```
|
||||
|
||||
Capture the application-level probe output and fail on the historical Zypak
|
||||
diagnostics:
|
||||
|
||||
```bash
|
||||
PROBE_OUTPUT="$(
|
||||
xvfb-run -a dbus-run-session -- flatpak run \
|
||||
--env=LIBGL_ALWAYS_SOFTWARE=1 \
|
||||
com.fourgray.iptvnator \
|
||||
--embedded-mpv-runtime-probe 2>&1
|
||||
)"
|
||||
printf '%s\n' "${PROBE_OUTPUT}"
|
||||
if printf '%s\n' "${PROBE_OUTPUT}" |
|
||||
grep -Eq 'not an ELF file|Zypak needs to be called directly'; then
|
||||
echo "::error::Flatpak launched a wrapper instead of the Electron ELF."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
Update `configure-linux-frame-copy-build.test.mjs` to require the ELF magic
|
||||
check, `.bin` rejection, warning guard, and absence of the old wrapper-marker
|
||||
greps.
|
||||
|
||||
- [ ] **Step 5: Update canonical launcher documentation**
|
||||
|
||||
Document the exact invariant in all listed documentation:
|
||||
|
||||
```text
|
||||
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.
|
||||
```
|
||||
|
||||
In the earlier frame-copy design, replace the unconditional
|
||||
`Electron executable (iptvnator.bin)` wording with
|
||||
`iptvnator for Flatpak; iptvnator.bin for other Linux targets`.
|
||||
|
||||
- [ ] **Step 6: Run targeted tests and formatting**
|
||||
|
||||
```bash
|
||||
node --test --test-name-pattern='Flatpak|Linux CI verifies' \
|
||||
tools/packaging/verify-linux-frame-copy-runtime.test.mjs \
|
||||
tools/packaging/configure-linux-frame-copy-build.test.mjs
|
||||
pnpm prettier --check \
|
||||
tools/packaging/verify-linux-frame-copy-runtime.mjs \
|
||||
tools/packaging/verify-linux-frame-copy-runtime.test.mjs \
|
||||
tools/packaging/configure-linux-frame-copy-build.test.mjs \
|
||||
.github/workflows/build-and-make.yaml \
|
||||
docs/architecture/embedded-mpv-native.md \
|
||||
tools/embedded-mpv/README.md \
|
||||
docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md \
|
||||
AGENTS.md CLAUDE.md
|
||||
```
|
||||
|
||||
Expected: tests and formatting PASS.
|
||||
|
||||
- [ ] **Step 7: Commit Task 3**
|
||||
|
||||
```bash
|
||||
git add tools/packaging/verify-linux-frame-copy-runtime.mjs \
|
||||
tools/packaging/verify-linux-frame-copy-runtime.test.mjs \
|
||||
.github/workflows/build-and-make.yaml \
|
||||
tools/packaging/configure-linux-frame-copy-build.test.mjs \
|
||||
docs/architecture/embedded-mpv-native.md \
|
||||
tools/embedded-mpv/README.md \
|
||||
docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md \
|
||||
AGENTS.md CLAUDE.md
|
||||
git commit -m "test(packaging): enforce direct Flatpak Zypak launch"
|
||||
```
|
||||
|
||||
### Task 4: Verify the integrated fix
|
||||
|
||||
**Files:**
|
||||
|
||||
- Verify only; do not add unrelated changes.
|
||||
|
||||
- [ ] **Step 1: Run the complete packaging tests**
|
||||
|
||||
```bash
|
||||
pnpm nx test packaging --skip-nx-cache
|
||||
```
|
||||
|
||||
Expected: all tests PASS, including the new launcher tests.
|
||||
|
||||
- [ ] **Step 2: Run packaging lint**
|
||||
|
||||
```bash
|
||||
pnpm nx lint packaging --skip-nx-cache
|
||||
```
|
||||
|
||||
Expected: zero ESLint errors.
|
||||
|
||||
- [ ] **Step 3: Run repository formatting checks for changed files**
|
||||
|
||||
```bash
|
||||
pnpm prettier --check \
|
||||
tools/packaging/linux-launcher-layout.cjs \
|
||||
tools/packaging/linux-after-pack.cjs \
|
||||
tools/packaging/linux-after-pack.test.mjs \
|
||||
tools/packaging/electron-after-pack.cjs \
|
||||
tools/packaging/embedded-mpv-packaging.cjs \
|
||||
tools/packaging/embedded-mpv-arch.test.mjs \
|
||||
tools/packaging/verify-electron-package-layout.mjs \
|
||||
tools/packaging/electron-package-identity.test.mjs \
|
||||
tools/packaging/verify-linux-frame-copy-runtime.mjs \
|
||||
tools/packaging/verify-linux-frame-copy-runtime.test.mjs \
|
||||
tools/packaging/configure-linux-frame-copy-build.test.mjs \
|
||||
tools/packaging/project.json \
|
||||
.github/workflows/build-and-make.yaml \
|
||||
docs/architecture/embedded-mpv-native.md \
|
||||
tools/embedded-mpv/README.md \
|
||||
docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md \
|
||||
docs/superpowers/specs/2026-07-18-flatpak-zypak-launcher-design.md \
|
||||
docs/superpowers/plans/2026-07-18-flatpak-zypak-launcher.md \
|
||||
AGENTS.md CLAUDE.md
|
||||
```
|
||||
|
||||
Expected: all changed files use repository formatting.
|
||||
|
||||
- [ ] **Step 4: Inspect the final diff**
|
||||
|
||||
```bash
|
||||
git diff 8fdac824..HEAD --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no whitespace errors and only scoped launcher, validator, CI, test,
|
||||
plan, and documentation changes.
|
||||
@@ -0,0 +1,243 @@
|
||||
# Hide Homogeneous Catalog Type Badges Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove redundant `LIVE`, `VOD`, and `SERIES` badges from homogeneous Stalker and Xtream catalog grids while preserving type badges in mixed-content cards.
|
||||
|
||||
**Architecture:** Remove the type-badge presentation from the shared `GridListComponent`, whose current consumers are homogeneous catalog grids. Keep its `type` input because artwork placeholders and live-title normalization still depend on it; do not modify `ContentCardComponent`, which owns badges for mixed-content surfaces.
|
||||
|
||||
**Tech Stack:** Angular standalone components, Angular signal inputs, SCSS, Jest through Nx, Playwright E2E through Nx.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Bootstrap the Nx workspace
|
||||
|
||||
**Files:**
|
||||
- Verify only: `package.json`
|
||||
- Verify only: `pnpm-lock.yaml`
|
||||
|
||||
- [ ] **Step 1: Install the locked workspace dependencies**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Expected: exit 0 without changing `pnpm-lock.yaml`.
|
||||
|
||||
- [ ] **Step 2: Verify Nx project discovery**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx show projects
|
||||
```
|
||||
|
||||
Expected: exit 0 and output containing `portal-shared-ui`,
|
||||
`portal-catalog-feature`, and `web-e2e`.
|
||||
|
||||
### Task 1: Remove the redundant grid-list badge
|
||||
|
||||
**Files:**
|
||||
- Modify: `libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts`
|
||||
- Modify: `libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts`
|
||||
- Modify: `libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss`
|
||||
|
||||
- [ ] **Step 1: Write the failing regression test**
|
||||
|
||||
In `grid-list.component.spec.ts`, change the existing live-logo test so it only
|
||||
asserts logo-card rendering, then add this parameterized test inside
|
||||
`describe('GridListComponent', ...)`:
|
||||
|
||||
```typescript
|
||||
it.each(['live', 'vod', 'series'] as const)(
|
||||
'does not render a redundant %s type badge in homogeneous grids',
|
||||
(type) => {
|
||||
fixture.componentRef.setInput('items', [
|
||||
{
|
||||
name: 'Catalog item',
|
||||
stream_icon: 'catalog-item.png',
|
||||
},
|
||||
]);
|
||||
fixture.componentRef.setInput('type', type);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.type-badge'))
|
||||
).toBeNull();
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
The updated live-logo test must retain these assertions:
|
||||
|
||||
```typescript
|
||||
expect(card.nativeElement.classList).toContain('grid-card--logo');
|
||||
expect(image.nativeElement.getAttribute('src')).toBe('channel-logo.png');
|
||||
```
|
||||
|
||||
It must no longer query or assert `.type-badge`.
|
||||
|
||||
- [ ] **Step 2: Run the focused test to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test portal-shared-ui --testPathPattern=grid-list.component.spec.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: FAIL for `live`, `vod`, and `series` because
|
||||
`GridListComponent` still renders `.type-badge`.
|
||||
|
||||
- [ ] **Step 3: Remove the badge markup**
|
||||
|
||||
In the inline template in `grid-list.component.ts`, remove only this block:
|
||||
|
||||
```html
|
||||
@if (type()) {
|
||||
<div
|
||||
class="type-badge"
|
||||
[class.live]="type() === 'live'"
|
||||
[class.movie]="type() === 'vod'"
|
||||
[class.series]="type() === 'series'"
|
||||
>
|
||||
{{ type() }}
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
Keep the `type` input and every remaining use of `type()` unchanged.
|
||||
|
||||
- [ ] **Step 4: Remove the now-unused grid badge styles**
|
||||
|
||||
In `grid-list.component.scss`, delete the entire `.type-badge` rule, including
|
||||
its `.live`, `.movie`, and `.series` variants:
|
||||
|
||||
```scss
|
||||
.type-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 1;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: #fff;
|
||||
|
||||
&.live {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
&.movie {
|
||||
color: #42a5f5;
|
||||
}
|
||||
|
||||
&.series {
|
||||
color: #66bb6a;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Do not change the separate content-card badge mixin or
|
||||
`content-card.component.*`.
|
||||
|
||||
- [ ] **Step 5: Run the focused test to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test portal-shared-ui --testPathPattern=grid-list.component.spec.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: PASS, including the three type-badge regression cases and the
|
||||
existing placeholder/title behavior.
|
||||
|
||||
- [ ] **Step 6: Run affected unit and lint targets**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test portal-shared-ui
|
||||
pnpm nx test portal-catalog-feature
|
||||
pnpm nx lint portal-shared-ui
|
||||
```
|
||||
|
||||
Expected: all commands exit 0 with no failed tests or lint errors.
|
||||
|
||||
- [ ] **Step 7: Verify mixed-content badge ownership is untouched**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff -- libs/portal/shared/ui/src/lib/components/content-card libs/portal/xtream/feature/src/lib/search-results libs/portal/stalker/feature/src/lib/stalker-search
|
||||
```
|
||||
|
||||
Expected: no output. `ContentCardComponent` and both mixed search surfaces
|
||||
remain unchanged.
|
||||
|
||||
- [ ] **Step 8: Commit the implementation**
|
||||
|
||||
```bash
|
||||
git add \
|
||||
libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts \
|
||||
libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts \
|
||||
libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss
|
||||
git commit -m "fix(portal): remove redundant catalog type badges"
|
||||
```
|
||||
|
||||
Expected: one commit containing only the grid-list behavior and regression
|
||||
coverage.
|
||||
|
||||
### Task 2: Validate the portal workflows
|
||||
|
||||
**Files:**
|
||||
- Verify only: `apps/web-e2e/src/xtream.e2e.ts`
|
||||
- Verify only: `apps/web-e2e/src/stalker.e2e.ts`
|
||||
- Verify only: `docs/architecture/iptvnator-ui-guidelines.md`
|
||||
|
||||
- [ ] **Step 1: Run the Xtream portal E2E target**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts
|
||||
```
|
||||
|
||||
Expected: PASS for the Xtream category and content-list workflows.
|
||||
|
||||
- [ ] **Step 2: Run the Stalker portal E2E target**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx run web-e2e:e2e-ci--src/stalker.e2e.ts
|
||||
```
|
||||
|
||||
Expected: PASS for the Stalker category and content-list workflows.
|
||||
|
||||
- [ ] **Step 3: Perform the final diff and documentation-impact check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff HEAD^ --check
|
||||
git show --stat --oneline HEAD
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `git diff --check` exits 0;
|
||||
- the implementation commit contains only the three grid-list files;
|
||||
- the worktree contains only this implementation plan if it has not been
|
||||
committed separately;
|
||||
- no canonical documentation update is needed because
|
||||
`docs/architecture/iptvnator-ui-guidelines.md` already directs contributors
|
||||
not to add redundant badges or secondary selection systems.
|
||||
@@ -0,0 +1,552 @@
|
||||
# Stalker `is_series` Playback Metadata Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Correct Stalker/Ministra VOD-backed series quick-start translations and persist episode coordinates so the workspace dashboard can show its season/episode badge.
|
||||
|
||||
**Architecture:** Keep the provider-neutral dashboard contract unchanged. Preserve the shared quick-start translation parameters in the Stalker button view model, then enrich resolved Stalker series playback at the feature boundary by resolving the selected episode against the existing normalized `mappedSeasons()` data.
|
||||
|
||||
**Tech Stack:** Angular standalone components and signals, ngx-translate, TypeScript, Jest, Nx, Markdown repository documentation.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Establish the implementation branch and workspace
|
||||
|
||||
**Files:**
|
||||
- Verify: `package.json`
|
||||
- Verify: `pnpm-lock.yaml`
|
||||
|
||||
- [ ] **Step 1: Create the feature branch**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git switch -c agent/fix-stalker-is-series-metadata
|
||||
```
|
||||
|
||||
Expected: Git reports a new branch named
|
||||
`agent/fix-stalker-is-series-metadata`.
|
||||
|
||||
- [ ] **Step 2: Install the frozen workspace dependencies**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Expected: installation completes without changing `pnpm-lock.yaml`.
|
||||
|
||||
- [ ] **Step 3: Verify Nx workspace discovery**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx show projects
|
||||
```
|
||||
|
||||
Expected: output includes `portal-stalker-feature`,
|
||||
`workspace-dashboard-data-access`, and `web`.
|
||||
|
||||
### Task 2: Add failing Stalker quick-start and playback regressions
|
||||
|
||||
**Files:**
|
||||
- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Make the test translate pipe expose missing parameters**
|
||||
|
||||
Change the `MockPipe(TranslatePipe, ...)` transform to render the problematic
|
||||
translation with its parameter:
|
||||
|
||||
```ts
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(
|
||||
value: string | null | undefined,
|
||||
params?: Record<string, number>
|
||||
) => {
|
||||
if (value === 'XTREAM.PLAY_EPISODE') {
|
||||
return `Play episode ${params?.['episode'] ?? '{{episode}}'}`;
|
||||
}
|
||||
return value ?? '';
|
||||
}
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a quick-start interpolation regression**
|
||||
|
||||
Add a component test that uses a loaded `is_series` episode with a non-resume,
|
||||
non-watched position:
|
||||
|
||||
```ts
|
||||
it('interpolates the episode number for a recently started VOD is_series episode', async () => {
|
||||
selectedContentType.set('vod');
|
||||
selectedItem.set({
|
||||
id: '50001',
|
||||
is_series: true,
|
||||
info: {
|
||||
name: 'VOD Flagged Series',
|
||||
description: 'Lazy seasons',
|
||||
movie_image: 'vod-series.jpg',
|
||||
},
|
||||
});
|
||||
serialSeasonsResource.set([]);
|
||||
vodSeriesSeasonsResource.set([]);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.componentInstance.vodSeriesSeasons.set([
|
||||
{
|
||||
id: 'season-1',
|
||||
video_id: '50001',
|
||||
season_number: '1',
|
||||
name: 'Season 1',
|
||||
episodes: [
|
||||
{
|
||||
id: 'episode-1',
|
||||
series_number: 1,
|
||||
name: 'Pilot',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isExpanded: false,
|
||||
},
|
||||
]);
|
||||
const episode = fixture.componentInstance.mappedSeasons()['1'][0];
|
||||
fixture.componentInstance.episodePlaybackPositions.set(
|
||||
new Map([
|
||||
[
|
||||
Number(episode.id),
|
||||
{
|
||||
contentXtreamId: Number(episode.id),
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 50001,
|
||||
positionSeconds: 5,
|
||||
durationSeconds: 100,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button: HTMLButtonElement | null =
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="series-quick-start"]'
|
||||
);
|
||||
|
||||
expect(button?.textContent).toContain('Play episode 1');
|
||||
expect(button?.textContent).not.toContain('{{episode}}');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Extend the existing external `is_series` quick-start test**
|
||||
|
||||
After clicking the quick-start button, assert that the external playback object
|
||||
contains episode coordinates:
|
||||
|
||||
```ts
|
||||
expect(openResolvedPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contentInfo: expect.objectContaining({
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
}),
|
||||
}),
|
||||
true
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Extend the existing inline `is_series` playback test**
|
||||
|
||||
After `onEpisodeClicked(firstEpisode)`, assert:
|
||||
|
||||
```ts
|
||||
expect(inlinePlayer.playback()).toEqual(
|
||||
expect.objectContaining({
|
||||
contentInfo: expect.objectContaining({
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
}),
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the Stalker feature tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test portal-stalker-feature
|
||||
```
|
||||
|
||||
Expected: the new interpolation and playback-coordinate assertions fail for
|
||||
the missing `labelParams`, `seasonNumber`, and `episodeNumber`.
|
||||
|
||||
### Task 3: Preserve translation parameters and enrich Stalker playback
|
||||
|
||||
**Files:**
|
||||
- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts`
|
||||
- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html`
|
||||
- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
|
||||
- Test: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Preserve label parameters in the Stalker button model**
|
||||
|
||||
Add the optional field:
|
||||
|
||||
```ts
|
||||
export interface StalkerQuickStartButton {
|
||||
labelKey: string;
|
||||
labelParams?: Record<string, number>;
|
||||
episodeLabel: string | null;
|
||||
icon: string;
|
||||
disabled: boolean;
|
||||
action: SeriesQuickStartAction | null;
|
||||
lazySeason: VodSeriesSeasonVm | null;
|
||||
}
|
||||
```
|
||||
|
||||
Copy it from a loaded action:
|
||||
|
||||
```ts
|
||||
return {
|
||||
labelKey: action.labelKey,
|
||||
labelParams: action.labelParams,
|
||||
episodeLabel: action.episodeLabel,
|
||||
icon: action.icon,
|
||||
disabled: action.disabled,
|
||||
action,
|
||||
lazySeason: null,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Pass parameters to the Stalker translate pipe**
|
||||
|
||||
Replace the quick-start label expression with:
|
||||
|
||||
```html
|
||||
{{
|
||||
action.labelKey
|
||||
| translate: action.labelParams
|
||||
}}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Resolve episode coordinates before playback handoff**
|
||||
|
||||
In `startPlayback(...)`, derive normalized episode state from the existing
|
||||
mapped seasons and enrich only episode playback:
|
||||
|
||||
```ts
|
||||
const episodeState =
|
||||
episodeId === undefined
|
||||
? null
|
||||
: resolveSeriesPlaybackEpisodeState({
|
||||
episodesBySeason: this.mappedSeasons(),
|
||||
currentEpisodeId: episodeId,
|
||||
fallbackEpisodeNumber: episodeNum,
|
||||
});
|
||||
const resolvedPlayback =
|
||||
episodeState && playback.contentInfo?.contentType === 'episode'
|
||||
? {
|
||||
...playback,
|
||||
contentInfo: {
|
||||
...playback.contentInfo,
|
||||
seasonNumber: episodeState.seasonNumber,
|
||||
episodeNumber: episodeState.episodeNumber,
|
||||
},
|
||||
}
|
||||
: playback;
|
||||
```
|
||||
|
||||
Use `resolvedPlayback` for both branches:
|
||||
|
||||
```ts
|
||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
||||
this.inlinePlayback.set(resolvedPlayback);
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeInlinePlayer();
|
||||
void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the Stalker feature tests and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test portal-stalker-feature
|
||||
```
|
||||
|
||||
Expected: all Stalker feature tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit the focused Stalker fix**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts
|
||||
git commit -m "fix(stalker): preserve series episode metadata"
|
||||
```
|
||||
|
||||
Expected: one commit containing the Stalker tests and minimal production fix.
|
||||
|
||||
### Task 4: Lock the dashboard’s existing `is_series` contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Add Stalker dashboard contract coverage**
|
||||
|
||||
Add a test that supplies a Stalker playlist-backed recent item and its saved
|
||||
episode position:
|
||||
|
||||
```ts
|
||||
it('resolves episode metadata for a Stalker VOD is_series recent item', async () => {
|
||||
playlistsSignal.set([
|
||||
...createDefaultPlaylists(),
|
||||
{
|
||||
_id: 'stalker-series',
|
||||
title: 'Ministra Portal',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
recentlyViewed: [
|
||||
{
|
||||
id: '50001',
|
||||
title: 'VOD Flagged Series',
|
||||
category_id: 'vod',
|
||||
is_series: '1',
|
||||
added_at: '2026-07-20T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
||||
async (playlistId: string) =>
|
||||
playlistId === 'stalker-series'
|
||||
? [
|
||||
{
|
||||
playlistId,
|
||||
contentXtreamId: 5000101,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 50001,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 120,
|
||||
durationSeconds: 1800,
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
await service.reloadPlaybackPositions();
|
||||
|
||||
const item = service
|
||||
.globalRecentItems()
|
||||
.find((recent) => recent.playlist_id === 'stalker-series');
|
||||
expect(item?.type).toBe('series');
|
||||
expect(service.getPlaybackPositionForItem(item!)).toEqual(
|
||||
expect.objectContaining({
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the dashboard data-access tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test workspace-dashboard-data-access
|
||||
```
|
||||
|
||||
Expected: all tests pass, proving that the dashboard already consumes the
|
||||
metadata without a provider-specific branch.
|
||||
|
||||
- [ ] **Step 3: Commit the dashboard contract test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts
|
||||
git commit -m "test(dashboard): cover Stalker is_series positions"
|
||||
```
|
||||
|
||||
Expected: one test-only commit.
|
||||
|
||||
### Task 5: Document the cross-surface `is_series` contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/stalker-portal.md`
|
||||
- Modify: `docs/architecture/workspace-dashboard.md`
|
||||
- Modify: `.codex/skills/stalker-portal/SKILL.md`
|
||||
|
||||
- [ ] **Step 1: Update Stalker architecture documentation**
|
||||
|
||||
Add these rules to the `VOD/Series Modes` and regression sections:
|
||||
|
||||
```markdown
|
||||
- All three series modes must preserve shared quick-start translation
|
||||
parameters.
|
||||
- Episode playback must carry `seriesXtreamId`, `seasonNumber`, and
|
||||
`episodeNumber` through both inline and external player handoffs.
|
||||
- Playlist-backed activity keeps `is_series` so dashboard normalization
|
||||
classifies the VOD-origin record as `series`.
|
||||
- Existing playback rows without episode coordinates remain badge-less until
|
||||
the next episode playback; the dashboard must not query the portal to infer
|
||||
them.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update workspace dashboard documentation**
|
||||
|
||||
Generalize the playback-position contract from Xtream-only wording and record:
|
||||
|
||||
```markdown
|
||||
Stalker VOD-backed `is_series` activity is normalized as series activity.
|
||||
New Stalker episode positions include season/episode coordinates, so the hero
|
||||
and Continue Watching cards use the same badge path as Xtream. Legacy rows
|
||||
without those fields remain valid but do not show a badge.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add an agent-facing `is_series` checklist**
|
||||
|
||||
In `.codex/skills/stalker-portal/SKILL.md`, require every Stalker series change
|
||||
to verify:
|
||||
|
||||
```markdown
|
||||
1. Detail mode selection for regular, embedded `series[]`, and `is_series`.
|
||||
2. Parameterized quick-start labels.
|
||||
3. Recent/favorite normalization retaining VOD origin and series identity.
|
||||
4. Playback `seriesXtreamId` plus season/episode coordinates.
|
||||
5. Dashboard hero and Continue Watching consumption.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify Markdown changes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: no whitespace errors.
|
||||
|
||||
- [ ] **Step 5: Commit the documentation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add docs/architecture/stalker-portal.md docs/architecture/workspace-dashboard.md .codex/skills/stalker-portal/SKILL.md
|
||||
git commit -m "docs(stalker): record is_series cross-surface contract"
|
||||
```
|
||||
|
||||
Expected: one documentation commit.
|
||||
|
||||
### Task 6: Validate the complete change
|
||||
|
||||
**Files:**
|
||||
- Verify: `libs/portal/stalker/feature/project.json`
|
||||
- Verify: `libs/workspace/dashboard/data-access/project.json`
|
||||
- Verify: `apps/web/project.json`
|
||||
|
||||
- [ ] **Step 1: Run targeted unit tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test portal-stalker-feature
|
||||
pnpm nx test workspace-dashboard-data-access
|
||||
```
|
||||
|
||||
Expected: both targets pass.
|
||||
|
||||
- [ ] **Step 2: Run affected lint targets**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx lint portal-stalker-feature
|
||||
pnpm nx lint workspace-dashboard-data-access
|
||||
```
|
||||
|
||||
Expected: both targets pass.
|
||||
|
||||
- [ ] **Step 3: Compile the Angular application**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx build web
|
||||
```
|
||||
|
||||
Expected: the web build completes successfully, proving the updated template
|
||||
and TypeScript compile together.
|
||||
|
||||
- [ ] **Step 4: Perform the test-impact audit**
|
||||
|
||||
Confirm that no Stalker/Ministra fixture-backed E2E target covers lazy
|
||||
`is_series` playback. Record that targeted unit coverage plus the Angular build
|
||||
is the strongest automated validation if no such target exists.
|
||||
|
||||
- [ ] **Step 5: Inspect the final diff and history**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff master...HEAD --check
|
||||
git status --short
|
||||
git log --oneline master..HEAD
|
||||
```
|
||||
|
||||
Expected: no diff errors, a clean worktree, and intentional commits only.
|
||||
|
||||
### Task 7: Independent review, PR creation, and review loop
|
||||
|
||||
**Files:**
|
||||
- Review: all files in `git diff master...HEAD`
|
||||
|
||||
- [ ] **Step 1: Dispatch an independent subagent review**
|
||||
|
||||
Ask a separate subagent to inspect the complete diff for correctness,
|
||||
regressions, type safety, test sufficiency, and adherence to the approved
|
||||
design. Require file/line evidence for every actionable finding.
|
||||
|
||||
- [ ] **Step 2: Address validated subagent findings**
|
||||
|
||||
For each finding, reproduce or confirm it, add or adjust tests first when
|
||||
behavior changes, implement the smallest correction, and rerun the affected
|
||||
validation targets. Commit any corrections separately.
|
||||
|
||||
- [ ] **Step 3: Create the pull request**
|
||||
|
||||
Push `agent/fix-stalker-is-series-metadata` and create a ready PR with:
|
||||
|
||||
```text
|
||||
Summary:
|
||||
- interpolate Stalker series quick-start episode labels
|
||||
- persist season/episode coordinates for all Stalker series modes
|
||||
- document and test the Ministra is_series dashboard contract
|
||||
|
||||
Validation:
|
||||
- pnpm nx test portal-stalker-feature
|
||||
- pnpm nx test workspace-dashboard-data-access
|
||||
- pnpm nx lint portal-stalker-feature
|
||||
- pnpm nx lint workspace-dashboard-data-access
|
||||
- pnpm nx build web
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Inspect GitHub reviews, comments, and checks**
|
||||
|
||||
Wait for initial PR checks and review-bot feedback. Read every unresolved review
|
||||
thread and failed check, classify each item as actionable or non-actionable
|
||||
with evidence, and address all actionable findings.
|
||||
|
||||
- [ ] **Step 5: Repeat until clean**
|
||||
|
||||
After each correction, rerun affected validation, push the new commit, and
|
||||
re-check PR reviews/comments/checks. Stop only when checks are green and no
|
||||
actionable unresolved feedback remains.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Linux Embedded MPV Frame-Copy Packaging Design
|
||||
|
||||
**Date:** 2026-07-17
|
||||
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Ship a genuinely usable Embedded MPV frame-copy runtime in every official
|
||||
Linux x64 package format produced by IPTVnator: AppImage, DEB, RPM, Pacman,
|
||||
Snap, and Flatpak. Keep libmpv outside the Electron process, retain the
|
||||
existing native-view engine as a safe fallback, and never advertise
|
||||
frame-copy from artifact presence alone.
|
||||
|
||||
Linux arm64 and armv7l remain out of scope for native Embedded MPV artifacts.
|
||||
The current CI cross-packages those architectures from an x64 host and cannot
|
||||
produce or exercise matching native addons. Those packages must continue to
|
||||
carry an explicit unavailable marker and must not contain x64 native binaries.
|
||||
|
||||
## Evidence From The Existing Implementation
|
||||
|
||||
- `apps/electron-backend/native/binding.gyp` builds three distinct artifacts:
|
||||
the native-view addon, the N-API shared-memory frame reader, and the
|
||||
`iptvnator_mpv_helper` process. On Linux only the helper links `-lmpv`; the
|
||||
addon uses X11/Xext/dlopen and must remain free of libmpv linkage.
|
||||
- `tools/packaging/embedded-mpv-frame-copy-files.cjs` deliberately deletes the
|
||||
helper from every Linux package.
|
||||
- `tools/packaging/embedded-mpv-packaging.cjs` rejects Linux packages that
|
||||
contain either the helper or libmpv, and accepts only the
|
||||
`external-mpv-process` manifest origin.
|
||||
- `resolveFrameCopyHelperPath()` currently treats an executable helper plus a
|
||||
readable reader addon as a usable runtime. It does not prove that the ELF
|
||||
loader can resolve libmpv or that libmpv/EGL initialization works.
|
||||
- `.github/workflows/build-and-make.yaml` builds and verifies the Linux helper
|
||||
against Ubuntu's system libmpv, then relies on the after-pack hook to remove
|
||||
it. The same x64 build output is used for foreign-architecture Linux
|
||||
packages, which receive the unavailable marker.
|
||||
- Electron Builder creates one unpacked application layout before producing
|
||||
multiple distributable targets. A system-runtime layout and a bundled
|
||||
portable-runtime layout therefore cannot safely share one packaging pass.
|
||||
|
||||
## Selected Distribution Strategy
|
||||
|
||||
Linux packaging is split into explicit profiles:
|
||||
|
||||
| Profile | Formats | libmpv strategy |
|
||||
| ---------- | ---------------- | -------------------------------------------------------------------------- |
|
||||
| `system` | DEB, RPM, Pacman | Depend on the distribution package and resolve `libmpv.so.2` from the host |
|
||||
| `portable` | AppImage, Snap | Bundle the pinned LGPL-compatible runtime closure under `native/lib` |
|
||||
| `flatpak` | Flatpak | Bundle the same pinned LGPL-compatible runtime closure under `native/lib` |
|
||||
|
||||
The official CI matrix must run these profiles independently. The packaging
|
||||
hook receives the profile through a required environment value and validates
|
||||
that the selected target set matches the runtime mode. It must fail closed if
|
||||
an official x64 package is requested with an absent, incomplete, or ambiguous
|
||||
profile.
|
||||
|
||||
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.
|
||||
|
||||
System package dependencies are:
|
||||
|
||||
- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1`
|
||||
- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm`
|
||||
- Pacman: `mpv`, `libglvnd`, `mesa`
|
||||
|
||||
These names match the current Debian, Fedora, and Arch package databases and
|
||||
cover every direct helper interface: libmpv, EGL, GL, and GBM. The helper
|
||||
links `libGL.so.1` rather than `libOpenGL.so.0`, matching both the distro
|
||||
contracts and Snap's graphics provider. System
|
||||
packages do not copy libmpv into IPTVnator. The helper keeps an `$ORIGIN/lib`
|
||||
RUNPATH first for a consistent binary, but naturally resolves the system SONAME
|
||||
when the private directory is absent.
|
||||
|
||||
Portable and sandboxed packages use a source-built runtime rather than copying
|
||||
the Ubuntu runner's mpv package. The runtime build is checksum/version pinned,
|
||||
uses FFmpeg without GPL/nonfree switches and mpv with `-Dgpl=false`, records
|
||||
sources and exact flags in the manifest, and keeps shared libraries replaceable
|
||||
under the LGPL. The minimal codec baseline is FFmpeg's built-in LGPL decoders,
|
||||
demuxers, protocols, and software scaling/resampling plus libass text
|
||||
subtitles. Hardware decoding remains opportunistic through host Mesa/driver
|
||||
interfaces and must fall back to software decoding.
|
||||
|
||||
The source build also pins the `hwdata` v0.409 archive and its SHA-256 because
|
||||
libdisplay-info 0.1.1 compiles `pnp.ids` into its generated vendor lookup
|
||||
table. The builder stages that file with private `hwdata.pc` metadata and
|
||||
restricts libdisplay-info's native pkg-config search to the staged prefix, so
|
||||
Meson's `/usr/share/hwdata/pnp.ids` fallback cannot make the runtime depend on
|
||||
unrecorded host data. The runtime manifest records this build-input
|
||||
relationship. Release source bundles must include the exact hwdata archive and
|
||||
its dual-license notice (`GPL-2.0-or-later OR XFree86-1.0`) alongside the
|
||||
MIT-licensed libdisplay-info source.
|
||||
|
||||
The strict Snap uses `base: core22`, a private `shared-memory` plug, and an
|
||||
exact `graphics-core22` content plug targeting a real empty mode-0755
|
||||
`$SNAP/graphics` with external `mesa-core22` as default provider. The graphics provider supplies
|
||||
EGL/GL/GLX/GBM/DRM/VA; Electron Builder's GNOME content runtime supplies
|
||||
ALSA/PulseAudio. Those shared providers are not copied into IPTVnator's Snap or
|
||||
source/notices archive. Because core22 does not synthesize `$SNAP` content
|
||||
targets, the package hook creates the empty directory and extracted-artifact
|
||||
validation checks its type and emptiness. The metadata also declares exactly
|
||||
the canonical graphics layouts: `/usr/share/libdrm` binds from
|
||||
`$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to
|
||||
`$SNAP/graphics/drirc.d`. Locally installed `--dangerous` artifacts explicitly
|
||||
install and connect both providers in CI, disconnect `graphics-core22` to
|
||||
require an unavailable application diagnostic with exit code `1`, then
|
||||
reconnect it and require success.
|
||||
|
||||
## Runtime Layout And Linkage
|
||||
|
||||
The x64 packaged native directory is:
|
||||
|
||||
```text
|
||||
resources/app.asar.unpacked/electron-backend/native/
|
||||
embedded_mpv.node
|
||||
embedded_mpv_frame_reader.node
|
||||
iptvnator_mpv_helper
|
||||
embedded-mpv-runtime.json
|
||||
lib/
|
||||
libmpv.so.2
|
||||
libavcodec.so.*
|
||||
libavformat.so.*
|
||||
libavutil.so.*
|
||||
libavfilter.so.*
|
||||
libswresample.so.*
|
||||
libswscale.so.*
|
||||
libass.so.*
|
||||
...other non-system runtime dependencies
|
||||
```
|
||||
|
||||
For `system`, `lib/` is absent and the manifest declares the required SONAME
|
||||
and package-family dependency. For `portable` and `flatpak`, `lib/` contains
|
||||
the complete non-system dependency closure. ELF dependencies inside that
|
||||
closure and the helper use only SONAMEs plus `$ORIGIN`-relative RPATH/RUNPATH;
|
||||
they may not retain build-prefix paths.
|
||||
|
||||
`embedded_mpv.node`, the Electron executable (`iptvnator` for Flatpak;
|
||||
`iptvnator.bin` for other Linux targets), and Electron's shipped libraries must
|
||||
not have a direct `DT_NEEDED` entry for libmpv.
|
||||
`iptvnator_mpv_helper` must have one. Process isolation is an invariant, not a
|
||||
profile-specific choice. The source `electron-backend/native{,/**/*}` tree is
|
||||
excluded from `app.asar`; `afterPack` is the sole owner of the normalized
|
||||
unpacked native directory, and package checks reject every archived native
|
||||
entry. The pristine Electron tree is scanned recursively
|
||||
before target packaging. Because Snap later overlays package-manager
|
||||
`lib/**`/`usr/lib/**` trees into the payload root, its extracted-target scan
|
||||
excludes exactly those two target-provided trees while remaining recursive
|
||||
everywhere else. Electron-library symlinks outside those roots fail closed.
|
||||
|
||||
The manifest records:
|
||||
|
||||
- schema version, platform, architecture, profile, and runtime origin;
|
||||
- required helper/reader names and executable/readable expectations;
|
||||
- libmpv SONAME and either system package requirements or bundled files;
|
||||
- source package versions, URLs/checksums, license identifiers, and exact
|
||||
FFmpeg/mpv build flags for bundled profiles;
|
||||
- the pinned hwdata `pnp.ids` build input consumed by libdisplay-info;
|
||||
- runtime closure and total byte size;
|
||||
- the native-view backend contract and the fact that only the helper links
|
||||
libmpv.
|
||||
|
||||
## Honest Capability Detection
|
||||
|
||||
The helper gains a side-effect-free `--runtime-probe` mode. It must:
|
||||
|
||||
1. load through the normal ELF loader and therefore prove that all `DT_NEEDED`
|
||||
dependencies resolve;
|
||||
2. create and initialize an idle libmpv handle with `vo=libmpv`;
|
||||
3. create the platform render pipeline far enough to prove EGL/OpenGL/GBM
|
||||
availability without opening media;
|
||||
4. create, map, validate, and destroy the minimal shared-memory ring required
|
||||
by playback;
|
||||
5. emit one versioned JSON result and exit promptly with status zero only on
|
||||
success.
|
||||
|
||||
The main process invokes this probe synchronously with a bounded timeout before
|
||||
BrowserWindow creation. Probe success is cached for the process lifetime.
|
||||
The probe environment prepends the packaged `native/lib` directory only when
|
||||
the manifest declares a bundled runtime. The system profile does not inject a
|
||||
private loader path. Snap additionally rebuilds its loader and graphics-driver
|
||||
variables from validated host GL, `$SNAP/graphics`, exact GNOME-platform, and
|
||||
generic core22 roots; ambient preload/audit/library/driver overrides and
|
||||
caller-provided architecture triplets are not inherited. The direct provider
|
||||
wrapper launch also removes shell startup/options, tracing hooks, and exported
|
||||
functions and fixes `PATH` to immutable core22 system directories.
|
||||
|
||||
Packaging CI invokes the full main-process gate with the exact
|
||||
`--embedded-mpv-runtime-probe` application switch. It executes before
|
||||
BrowserWindow startup, writes one availability JSON line, and exits zero only
|
||||
for a usable runtime. CI does not treat a direct helper invocation or an
|
||||
environment opt-in as proof of packaged capability.
|
||||
|
||||
Frame-copy is usable only when all of the following are true:
|
||||
|
||||
- platform and architecture are supported;
|
||||
- helper and reader are regular files with correct access modes;
|
||||
- the runtime manifest is present, parses, matches Linux/x64, names the actual
|
||||
artifacts, and uses an allowed profile/origin;
|
||||
- every manifest-declared bundled file exists as a readable regular file;
|
||||
- `--runtime-probe` succeeds and returns the expected protocol version.
|
||||
|
||||
Any failure returns `false`, keeps the renderer sandbox enabled, and makes the
|
||||
native service choose native-view. The capability result includes a stable
|
||||
reason code for tracing and diagnostics but does not crash startup.
|
||||
|
||||
If dependencies disappear after startup, helper spawn/early-exit remains a
|
||||
session error and follows the existing renderer fallback path. The helper is
|
||||
never loaded into Electron as a library.
|
||||
|
||||
## Packaging And CI Validation
|
||||
|
||||
Unit and packaging tests cover:
|
||||
|
||||
- profile-to-target mapping and rejection of mixed system/bundled passes;
|
||||
- Linux runtime staging, manifest normalization, closure collection, RPATH,
|
||||
file modes, and stale-artifact cleanup;
|
||||
- package validation for system, portable, Flatpak, foreign architecture, and
|
||||
malformed/incomplete manifests;
|
||||
- capability probe timeout, nonzero exit, invalid JSON, manifest mismatch,
|
||||
missing dependency, and successful result caching;
|
||||
- exclusion of all native payloads from `app.asar`, including marker-only ARM
|
||||
and system-package stale x64 artifacts;
|
||||
- helper probe protocol and failure behavior;
|
||||
- package metadata dependencies for DEB/RPM/Pacman.
|
||||
|
||||
Linux CI must:
|
||||
|
||||
1. build or restore the pinned x64 LGPL runtime;
|
||||
2. build the addon, reader, and helper once against that staged runtime;
|
||||
3. prove with `readelf`/`ldd` that Electron and `embedded_mpv.node` do not link
|
||||
libmpv and the helper does;
|
||||
4. package the three profiles independently;
|
||||
5. unpack or mount each produced format and validate its real payload, modes,
|
||||
manifest, RPATH, dependency closure, and profile;
|
||||
6. install/run the application-level packaged gate inside the actual Snap and
|
||||
Flatpak (with the exact Freedesktop 24.08 EGL external-platform path
|
||||
reconstructed inside `/app`), and probe the AppImage payload;
|
||||
7. install system packages in matching disposable distro containers and run
|
||||
the helper probe after the declared libmpv dependency is installed;
|
||||
8. run a packaged Electron smoke test that confirms frame-copy capability,
|
||||
creates a helper session against a deterministic local media fixture, sees
|
||||
at least one frame/snapshot, and then repeats with libmpv hidden or removed
|
||||
to prove non-crashing native-view fallback.
|
||||
|
||||
Checks that require Linux kernel/package tooling or GPU/EGL are CI-only.
|
||||
macOS development can run all pure Node/Jest tests and static source checks,
|
||||
but cannot establish Linux ELF, package-manager, sandbox, or rendering
|
||||
behavior.
|
||||
|
||||
## Documentation And Release Compliance
|
||||
|
||||
Update `docs/architecture/embedded-mpv-native.md`,
|
||||
`tools/embedded-mpv/README.md`, `vendor/embedded-mpv/README.md`,
|
||||
`AGENTS.md`, and `CLAUDE.md`. The docs must describe the x64 format matrix,
|
||||
profile selection, manifest/probe contract, process-isolation invariant,
|
||||
fallback behavior, source-distribution obligations, codec baseline, and ARM
|
||||
status.
|
||||
|
||||
Release artifacts must publish the generated runtime manifest and exact source
|
||||
archives/metadata required by the recorded LGPL source-distribution statement.
|
||||
The libplacebo payload is a VCS-metadata-free working-tree snapshot with exact
|
||||
commit/submodule records, so clone-local `.git` state cannot perturb the
|
||||
compliance tar. Automated Snap publication must wait for a public `v*` release
|
||||
that already contains both the Snap assets and the exact source archive. Snap
|
||||
Store publication remains outside this implementation and requires its
|
||||
separate release workflow; repository integration follows explicit maintainer
|
||||
authorization.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Flatpak Zypak Launcher Design
|
||||
|
||||
## Goal
|
||||
|
||||
Package Flatpak so Electron Builder's generated `electron-wrapper` passes the
|
||||
real IPTVnator ELF executable directly to Zypak. Preserve the existing
|
||||
conditional Linux sandbox wrapper for AppImage, DEB, RPM, Pacman, and Snap.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The common Linux `afterPack` hook currently renames the Electron executable
|
||||
from `iptvnator` to `iptvnator.bin` and writes a shell script at `iptvnator`.
|
||||
Electron Builder's Flatpak launcher calls `zypak-wrapper iptvnator`, so Zypak
|
||||
receives the shell script instead of an ELF executable. Zypak rejects that
|
||||
layout; the shell script can then mask the failure by adding `--no-sandbox` on
|
||||
hosts with restricted user namespaces.
|
||||
|
||||
## Launcher Contract
|
||||
|
||||
Introduce one packaging-owned launcher-layout helper:
|
||||
|
||||
- an isolated Flatpak target uses `iptvnator` as the Electron ELF and does not
|
||||
apply the custom Linux sandbox wrapper;
|
||||
- every other supported Linux target keeps the existing `iptvnator` shell
|
||||
wrapper and `iptvnator.bin` Electron ELF;
|
||||
- a target set containing Flatpak and any other target fails before filesystem
|
||||
mutation because Electron Builder shares one unpacked application tree
|
||||
across those targets;
|
||||
- target matching is case-insensitive and uses Electron Builder's documented
|
||||
`AfterPackContext.targets[].name` values, not output paths or environment
|
||||
heuristics.
|
||||
|
||||
The launcher hook, pristine-layout validation, and extracted-artifact
|
||||
validation must all resolve the Electron ELF path through this contract.
|
||||
|
||||
## Validation
|
||||
|
||||
Regression coverage will prove the following:
|
||||
|
||||
1. The Linux launcher hook preserves the original executable bytes and creates
|
||||
no `.bin` file for an isolated Flatpak target.
|
||||
2. The hook retains the existing wrapper layout for a non-Flatpak Linux target.
|
||||
3. A mixed Flatpak/non-Flatpak target set fails before renaming the executable.
|
||||
4. Pristine Flatpak validation inspects `iptvnator`, while other profiles
|
||||
inspect `iptvnator.bin`.
|
||||
5. Extracted Flatpak verification reads architecture and checks process
|
||||
isolation from `iptvnator`.
|
||||
6. CI asserts that the installed Flatpak target is an ELF, rejects a sibling
|
||||
`.bin` layout, and fails on the known Zypak wrapper warnings.
|
||||
|
||||
The existing application-level `--embedded-mpv-runtime-probe` remains the
|
||||
sandboxed launch check. Once the custom wrapper is absent, it can no longer
|
||||
silently add `--no-sandbox`; reaching the Electron main-process probe therefore
|
||||
also verifies the corrected Zypak entry path.
|
||||
|
||||
## Documentation
|
||||
|
||||
Update the canonical Linux Embedded MPV packaging documentation and the living
|
||||
`AGENTS.md`/`CLAUDE.md` summaries to state the launcher split explicitly.
|
||||
Correct the earlier Linux frame-copy design document's claim that every Linux
|
||||
Electron executable is named `iptvnator.bin`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Changing the Chromium GPU/Video.js behavior reported in issue #1203.
|
||||
- Removing the conditional sandbox wrapper from non-Flatpak Linux packages.
|
||||
- Adding `--no-sandbox`, changing `chrome-sandbox` permissions, or bypassing
|
||||
Zypak.
|
||||
- Changing the bundled Embedded MPV runtime or Flatpak permissions.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Hide Type Badges in Homogeneous Portal Catalogs
|
||||
|
||||
## Context
|
||||
|
||||
The shared `GridListComponent` renders catalog items after the user has already
|
||||
selected Live TV, VOD, or Series in the portal rail. Every item in these grids
|
||||
has the same content type, so the `LIVE`, `VOD`, and `SERIES` badges repeat
|
||||
information that is already established by the surrounding navigation.
|
||||
|
||||
Mixed-content surfaces such as portal search use `ContentCardComponent`, where
|
||||
the type badge remains useful for distinguishing results.
|
||||
|
||||
## Design
|
||||
|
||||
Remove the type-badge markup and its dedicated styles from
|
||||
`GridListComponent`.
|
||||
|
||||
Keep the component's `type` input. It still controls provider-neutral behavior
|
||||
that is unrelated to the badge:
|
||||
|
||||
- choosing artwork placeholder icons;
|
||||
- limiting country-prefix stripping to live content;
|
||||
- selecting poster/logo presentation behavior.
|
||||
|
||||
Do not change `ContentCardComponent` or any search, favorites, recently added,
|
||||
or dashboard surface. Their badges remain unchanged.
|
||||
|
||||
## Affected Portals and Views
|
||||
|
||||
Because Xtream and Stalker both route their category pages through the shared
|
||||
portal catalog view, removing the badge from `GridListComponent` covers:
|
||||
|
||||
- Xtream Live TV, VOD, and Series homogeneous grids;
|
||||
- Stalker Live TV, VOD, and Series homogeneous grids;
|
||||
- the Xtream Live TV all-items grid.
|
||||
|
||||
## Testing
|
||||
|
||||
Update the focused `GridListComponent` tests to prove that no `.type-badge` is
|
||||
rendered for `live`, `vod`, or `series`, while the `type` input continues to
|
||||
drive existing title and placeholder behavior.
|
||||
|
||||
Run the affected shared portal UI test target and the closest catalog feature
|
||||
test target. Since this is a visible portal workflow change, run the closest
|
||||
Xtream and Stalker Playwright coverage when available; otherwise perform the
|
||||
strongest available targeted validation and document any skipped UI automation.
|
||||
|
||||
## Documentation Impact
|
||||
|
||||
The canonical UI guideline already says not to add redundant badges or a
|
||||
second selection system. No canonical documentation change is required beyond
|
||||
this design record because no route, architecture boundary, setup workflow, or
|
||||
subsystem contract changes.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Stalker `is_series` Playback Metadata Design
|
||||
|
||||
## Context
|
||||
|
||||
Some Stalker/Ministra portals expose series inside the VOD catalog by setting
|
||||
`is_series=1`. IPTVnator already normalizes this provider-specific shape and
|
||||
loads its seasons and episodes lazily, but two cross-surface contracts are
|
||||
incomplete:
|
||||
|
||||
1. The Stalker quick-start button renders `XTREAM.PLAY_EPISODE` without the
|
||||
translation parameters supplied by the shared series quick-start action.
|
||||
The result is a visible `{{episode}}` placeholder.
|
||||
2. Stalker episode playback does not add `seasonNumber` and `episodeNumber` to
|
||||
`ResolvedPortalPlayback.contentInfo`. Playback positions therefore lack the
|
||||
metadata used by the workspace dashboard hero and Continue Watching cards
|
||||
to render their season/episode badge.
|
||||
|
||||
The dashboard already classifies raw Stalker records carrying `is_series` as
|
||||
series activity. No new dashboard-specific content-type branch is required.
|
||||
|
||||
## Goals
|
||||
|
||||
- Render parameterized Stalker quick-start translations correctly.
|
||||
- Persist season and episode numbers for future Stalker episode playback,
|
||||
including VOD-backed `is_series` series.
|
||||
- Let the existing dashboard position lookup and badge rendering consume that
|
||||
metadata without provider-specific duplication.
|
||||
- Document `is_series` as a cross-surface compatibility contract so future
|
||||
changes cover detail rendering, activity persistence, playback metadata, and
|
||||
dashboard presentation together.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Migrating or reconstructing existing playback-position rows that do not
|
||||
contain season/episode metadata.
|
||||
- Loading Stalker catalog data from the dashboard to infer missing metadata.
|
||||
- Changing Stalker season-loading behavior, episode ordering, tracking IDs, or
|
||||
playback URLs.
|
||||
- Redesigning the detail-page or dashboard UI.
|
||||
|
||||
## Design
|
||||
|
||||
### Quick-start translation
|
||||
|
||||
`StalkerQuickStartButton` will expose the optional `labelParams` already
|
||||
provided by `SeriesQuickStartAction`. For loaded episode actions, the Stalker
|
||||
adapter will copy those parameters into its button view model. Lazy-season
|
||||
actions will leave the field undefined because their label keys do not require
|
||||
an episode parameter.
|
||||
|
||||
The Stalker series template will call the translate pipe with
|
||||
`action.labelParams`, matching the established Xtream series template. This
|
||||
keeps translation-key selection in the shared quick-start utility and keeps
|
||||
template behavior consistent across providers.
|
||||
|
||||
### Playback metadata
|
||||
|
||||
`StalkerSeriesViewComponent` already maps all three supported series shapes to
|
||||
`Record<string, XtreamSerieEpisode[]>`:
|
||||
|
||||
- regular Stalker series;
|
||||
- VOD with an embedded `series[]`;
|
||||
- VOD with `is_series=1`.
|
||||
|
||||
When an episode is selected, the component will use the mapped episode identity
|
||||
to resolve its normalized season and episode numbers. After
|
||||
`StalkerStore.resolveVodPlayback(...)` returns, the component will enrich the
|
||||
episode `contentInfo` with those two fields before handing the playback object
|
||||
to either the inline player or an external player.
|
||||
|
||||
If no mapped episode state can be resolved, the component will preserve the
|
||||
existing playback object unchanged. Missing metadata must never block
|
||||
playback.
|
||||
|
||||
This placement avoids expanding the positional `resolveVodPlayback(...)`
|
||||
contract and ensures both inline and external playback receive the same
|
||||
metadata. Subsequent playback-position writes will therefore carry:
|
||||
|
||||
```text
|
||||
playlistId
|
||||
contentXtreamId
|
||||
contentType = episode
|
||||
seriesXtreamId
|
||||
seasonNumber
|
||||
episodeNumber
|
||||
```
|
||||
|
||||
### Dashboard behavior
|
||||
|
||||
No new dashboard branching will be introduced. Existing behavior remains:
|
||||
|
||||
1. `extractStalkerItemType(...)` normalizes `is_series` activity to `series`.
|
||||
2. `DashboardDataService` finds the newest episode position by
|
||||
`seriesXtreamId`.
|
||||
3. The dashboard hero and Continue Watching cards render the season/episode
|
||||
badge when both metadata fields are present.
|
||||
|
||||
Existing positions without these fields will remain badge-less until the user
|
||||
plays an episode again and a new position is saved.
|
||||
|
||||
## Tests
|
||||
|
||||
Regression coverage will be added before production changes:
|
||||
|
||||
1. Stalker series quick-start view-model/component coverage will demonstrate
|
||||
that a parameterized `PLAY_EPISODE` action carries and renders the episode
|
||||
number instead of `{{episode}}`.
|
||||
2. Stalker `is_series` component coverage will demonstrate that resolved
|
||||
playback contains the mapped season and episode numbers.
|
||||
3. Dashboard coverage will use a Stalker `is_series` recent item plus an
|
||||
episode playback position and assert that the hero exposes the expected
|
||||
season/episode badge.
|
||||
|
||||
Targeted Nx tests will run for the affected Stalker feature and workspace
|
||||
dashboard projects. Broader validation will be selected after checking the
|
||||
available project targets.
|
||||
|
||||
## Documentation
|
||||
|
||||
The implementation will update:
|
||||
|
||||
- `docs/architecture/stalker-portal.md` with the activity/playback/dashboard
|
||||
contract for all three series modes;
|
||||
- `docs/architecture/workspace-dashboard.md` with Stalker episode-position
|
||||
badge behavior and the forward-only limitation;
|
||||
- `.codex/skills/stalker-portal/SKILL.md` with a cross-surface `is_series`
|
||||
checklist for future agents.
|
||||
|
||||
## Compatibility and failure handling
|
||||
|
||||
- Raw `true`, `1`, and `"1"` `is_series` forms continue to normalize through
|
||||
existing Stalker helpers.
|
||||
- Existing series tracking IDs and saved positions remain valid.
|
||||
- Playback remains functional when season/episode metadata cannot be derived.
|
||||
- Old position rows are read unchanged and are not rewritten speculatively.
|
||||
Reference in new issue
Block a user