mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
Merge remote-tracking branch 'origin/master' into agent/download-season-queue
# Conflicts: # apps/electron-backend/src/app/events/database/download-requests.ts # libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts # libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts # libs/ui/components/src/lib/season-container/episode-download.util.ts # libs/ui/components/src/lib/season-container/episode-utils.spec.ts # libs/ui/components/src/lib/season-container/season-container.component.spec.ts # libs/ui/components/src/lib/season-container/season-container.component.ts
This commit is contained in:
commit
31ac306bb0
243 files changed
+12935
-1106
No files matched your search
@@ -12,7 +12,7 @@ variants, contextual buttons, and theme-aware styling.
|
||||
- **Queue control (`apps/electron-backend/src/app/events/database/download-runtime.ts`)**
|
||||
`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.
|
||||
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. Fresh Xtream movie and series-episode downloads propagate the playlist's configured headers, using its User-Agent when present and otherwise sharing the provider-compatible `XTREAM_CLIENT_USER_AGENT` used by Xtream API requests and stream probes. Retry, resume, and missing-file recovery resolve the owning playlist type and add that fallback to legacy Xtream rows without a stored User-Agent; known Stalker rows are left unchanged. Download rows deliberately outlive individually deleted playlists, so a headerless legacy row whose source no longer exists receives the same IPTV-player fallback because its original provider type cannot be recovered. 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). The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator`; only a partial carrying that validator may send `Range: bytes=<offset>-` plus `If-Range` and append bytes. A retained partial without a validator restarts from byte zero and overwrites its `.part`, so a changed remote representation can never be joined to an unverified prefix. 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, inspected, or deleted.
|
||||
Before starting a new transfer, the backend atomically reserves a free
|
||||
@@ -23,11 +23,14 @@ variants, contextual buttons, and theme-aware styling.
|
||||
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
|
||||
overwriting an existing file; cancel and non-recoverable transfer failures
|
||||
remove the `.part`, while finalization failures, completed-partial failures,
|
||||
and allowlisted network interruptions after bytes reached disk with a stored
|
||||
representation validator deliberately retain it (the row keeps `filePath`
|
||||
so a later retry can finish without re-downloading); pause and restart
|
||||
recovery keep partials, but a later retry starts over when no validator was
|
||||
available.
|
||||
Re-downloading such a failed row from a detail page
|
||||
(`DOWNLOADS_START`) deletes the retained `.part` before the row is reset.
|
||||
- **Derived file readiness and recovery**
|
||||
`DOWNLOADS_GET_LIST` and `DOWNLOADS_GET` inspect completed destinations on
|
||||
@@ -210,6 +213,7 @@ variants, contextual buttons, and theme-aware styling.
|
||||
- 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.
|
||||
- An allowlisted mid-response network failure such as `ECONNRESET` is recoverable only when the response advertised a larger total and the `.part` contains valid incomplete bytes. This includes a validated `206` resume that drops before adding another byte. The failed row retains that partial and exposes a stable `DOWNLOAD_NETWORK_INTERRUPTED (<code>)` message without a URL; Retry continues through the same Range/If-Range validation. Pre-response failures, unknown stream errors, filesystem errors, empty fresh failures, and responses without a trustworthy total keep the generic failure path.
|
||||
- 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.
|
||||
|
||||
@@ -122,8 +122,9 @@ policy.
|
||||
## Scoped Request Header Overrides
|
||||
|
||||
Inline playback can request temporary `User-Agent`, `Referer`, and `Origin`
|
||||
header overrides through `window.electron.setUserAgent(userAgent, referer,
|
||||
scopeUrl)`.
|
||||
header overrides — and, for auth-gated portal streams, `Cookie` and
|
||||
`Authorization` credentials — through `window.electron.setUserAgent(userAgent,
|
||||
referer, scopeUrl, credentials?)`.
|
||||
|
||||
The Electron backend handles that IPC in `apps/electron-backend/src/app/events/shared.events.ts`
|
||||
and delegates to `apps/electron-backend/src/app/services/request-header-overrides.service.ts`.
|
||||
@@ -131,11 +132,24 @@ The service registers one `session.defaultSession.webRequest.onBeforeSendHeaders
|
||||
listener and updates layered in-memory overrides instead of stacking a new
|
||||
listener for every channel change.
|
||||
|
||||
`ElectronStreamHeadersService` (`libs/ui/playback`) is the single renderer
|
||||
owner of the scoped override slot: it extracts the full header set from the
|
||||
resolved playback (including the Stalker mac cookie and Bearer token), and
|
||||
its `clear()` releases the slot only while the caller's stream still owns it,
|
||||
so a consumer being destroyed cannot wipe an override a newer consumer just
|
||||
configured. Three surfaces apply it: `WebPlayerViewComponent` for every
|
||||
built-in video player (configuring the override **before** handing the
|
||||
source over, clearing on destroy), and — for the dedicated radio audio
|
||||
player, which never mounts a `WebPlayerViewComponent` — the Stalker live
|
||||
layout and the unified collection tab (global/portal Favorites and Recently
|
||||
Viewed). Individual player components must not call the bridge themselves —
|
||||
a narrower call would overwrite the credentialed override.
|
||||
|
||||
Rules:
|
||||
|
||||
- empty playlist-level `userAgent` and `referer` clear all active overrides
|
||||
- empty channel-level `userAgent` and `referer` with a `scopeUrl` clear only
|
||||
the scoped channel override, preserving playlist-level defaults
|
||||
- empty channel-level values with a `scopeUrl` clear only the scoped channel
|
||||
override, preserving playlist-level defaults
|
||||
- channel playback should pass the stream URL as `scopeUrl`
|
||||
- scoped overrides apply only to the active stream origin and referer origin
|
||||
- playlist-level user agents and referrers may call the bridge without a
|
||||
@@ -143,10 +157,43 @@ Rules:
|
||||
the whole M3U playlist
|
||||
- header names are replaced case-insensitively before canonical `User-Agent`,
|
||||
`Referer`, and `Origin` names are written
|
||||
- header values containing control characters are rejected outright (header
|
||||
smuggling)
|
||||
|
||||
Credential rules (`credentials.cookie` / `credentials.authorization`) are
|
||||
deliberately stricter than the general scope:
|
||||
|
||||
- credentials are accepted **only** with a `scopeUrl` that parses to a
|
||||
concrete origin; an unscoped (playlist-level) call silently drops them —
|
||||
fail closed, never fail broad
|
||||
- they are attached **only** to requests whose origin equals the stream URL's
|
||||
exact origin — never to the referer-origin sibling that `User-Agent`/`Referer`
|
||||
also cover, and never to third-party hosts an HLS manifest may point at
|
||||
- they live only in the in-memory override: never in the session cookie jar,
|
||||
never on disk, so they cannot outlive the app process
|
||||
- they are dropped whenever the scoped override is replaced (channel or
|
||||
source change) or released — player close/destroy, a radio host's close, or
|
||||
a new selection that mounts no player surface. The media `ended` event
|
||||
deliberately does **not** clear the override: the mounted player still owns
|
||||
the session (replay, or a seek into an unbuffered range, must keep working
|
||||
against a gated stream), and the credentials only ever travel to the exact
|
||||
origin that issued them; every dismount path above releases them
|
||||
|
||||
The header-injection design was chosen over `session.cookies.set()`
|
||||
deliberately: jar cookies only attach to credentialed requests, which would
|
||||
force `withCredentials` into every web engine and break against the
|
||||
`Access-Control-Allow-Origin: *` that IPTV panels typically send, and jar
|
||||
scoping is domain-based (port-blind) — weaker than the exact-origin match
|
||||
above. Injecting at `onBeforeSendHeaders` sits below the CORS/credentials
|
||||
layer, so the request stays "uncredentialed" for the fetch spec while the
|
||||
wire request carries the portal session.
|
||||
|
||||
When changing this flow, keep stale header cleanup covered. Switching from a
|
||||
channel or playlist with custom headers to one without custom headers must clear
|
||||
the previous override.
|
||||
the previous override. The Electron e2e
|
||||
`apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts` pins the
|
||||
end-to-end contract against a mock stream that answers 403 without the portal
|
||||
credentials.
|
||||
|
||||
## Main-Process Remote Requests
|
||||
|
||||
|
||||
@@ -253,9 +253,11 @@ remain local when the meaning is explicit.
|
||||
width is preserved so uncollapsing restores the user's previous resized
|
||||
width. Both rails share the same 180 ms width transition so motion stays in
|
||||
lockstep.
|
||||
- Below 600 px viewport, the M3U layout's mobile bottom-drawer rule overrides
|
||||
the desktop collapse to `height: 0` instead of `width: 0`, and the floating
|
||||
restore handle is hidden.
|
||||
- At the phone breakpoint the M3U layout's bottom-drawer rule overrides the
|
||||
desktop collapse to `height: 0` instead of `width: 0`. The floating restore
|
||||
handle stays visible there: the collapse toggle is reachable by touch, so
|
||||
hiding the handle left a phone with no way to bring the list back short of
|
||||
`Cmd/Ctrl+B`.
|
||||
|
||||
### EPG Card
|
||||
|
||||
@@ -331,6 +333,98 @@ Settings use the same system but are flatter than content-heavy views.
|
||||
- Neutral rows can use low-opacity dark overlays
|
||||
- Keep strong blue tint reserved for active sections and selected items
|
||||
|
||||
## Phone Layout
|
||||
|
||||
`640px` is the phone breakpoint. Use `@media (max-width: 640px)` rather than
|
||||
inventing a nearby value: several surfaces cooperate at this width, and a
|
||||
component that picks `599px` leaves a band where the shell has already stacked
|
||||
but the component has not.
|
||||
|
||||
### Rails become rows, stacks, or drawers
|
||||
|
||||
- The workspace shell rail turns into a horizontal top bar. Everything inside
|
||||
it has to opt into the row direction — a nested list that keeps
|
||||
`flex-direction: column` stacks its links out of the bar and over the header.
|
||||
The bar scrolls sideways once a portal contributes its sections, and the
|
||||
settings link is `position: sticky` so it never scrolls out of reach.
|
||||
- The shell context panel (categories, filters, settings sections) is an
|
||||
off-canvas drawer: hidden by default so the route content owns the full
|
||||
pane, opened from a toggle in the workspace header, closed by selection,
|
||||
backdrop tap, Escape, or any navigation. State lives in
|
||||
`WorkspaceShellContextDrawerService` (root-provided from
|
||||
`@iptvnator/workspace/shell/util` — see below for why); the
|
||||
panels call `close()` after selections that do not navigate — a
|
||||
NavigationEnd listener alone misses Stalker ITV/radio categories, settings
|
||||
sections, sources filters, and collection filters. The drawer positioning
|
||||
is `position: fixed` on the sidebar host, which also removes it from the
|
||||
shell grid, so the phone `workspace-body` stays single-pane. The drawer is
|
||||
modal for keyboard and screen-reader users: `CdkTrapFocus` captures and
|
||||
contains Tab focus while open, the shell marks the rail, header, content,
|
||||
and playback footer `inert` (a focus trap alone does not stop a screen
|
||||
reader's virtual cursor from activating obscured controls), the panel
|
||||
itself is the initial focus target (`tabindex="-1"` + `cdkFocusInitial`,
|
||||
so capture still works when a category list is loading or empty and
|
||||
renders no focusable rows), and the shell restores focus to the header
|
||||
toggle on close — deferred one tick, because the toggle is inside the
|
||||
inert header and `focus()` on a still-inert element is silently ignored.
|
||||
The service closes the drawer when the viewport leaves the phone
|
||||
breakpoint so the trap and inert state can never hold the in-flow desktop
|
||||
layout. While open, the shell consumes Escape (downstream consumers —
|
||||
the inline player's close handler, the shared controls shortcuts — check
|
||||
`defaultPrevented`, so one keypress cannot close both the drawer and the
|
||||
obscured player) and suppresses workspace-level shortcuts (Ctrl/Cmd+F
|
||||
global search, Ctrl/Cmd+K command palette, Ctrl/Cmd+R global recent, the
|
||||
`?` shortcuts dialog — dialogs must not stack a second focus trap on the
|
||||
modal drawer, and navigation must not act behind it), and document-level
|
||||
shortcuts owned by routed content (shared controls, Embedded MPV legacy
|
||||
dock, radio audio player, the live layouts' Ctrl/Cmd+B sidebar toggle,
|
||||
the M3U player's digit-key channel switching and sidebar toggle) opt out
|
||||
on their own by checking for an `inert` ancestor, since `inert` does not
|
||||
silence document-level listeners. Any NEW document-level key listener on
|
||||
routed content must apply the same `closest('[inert]')` guard. The service is root-provided
|
||||
from `@iptvnator/workspace/shell/util` so consumers outside the shell's
|
||||
element injector (AppComponent's Ctrl/Cmd+R handler) can observe it
|
||||
without pulling the lazy shell chunk into the eager bundle. The shell
|
||||
also registers the open drawer with
|
||||
`EmbeddedMpvOverlayVisibilityService.acquireExternalModalSurface()`:
|
||||
the native-view video surface is composited outside DOM stacking and
|
||||
would paint straight over the drawer regardless of z-index. The drawer carries its own phone-only close
|
||||
button: touch screen-reader users have no hardware Escape and cannot
|
||||
reach the inert header toggle or the aria-hidden backdrop, so the
|
||||
trapped surface itself must offer dismissal even when its list is
|
||||
loading or empty.
|
||||
The toggle's label is variant-aware — categories, filters, or settings
|
||||
sections — because a fixed label would misdescribe two of the three.
|
||||
- Other side rails stack above the content instead of beside it: the
|
||||
live-layout channel sidebar and the M3U channel drawer.
|
||||
|
||||
### Resizable rails need `!important`
|
||||
|
||||
`ResizableDirective` writes the persisted desktop width as an inline style, so
|
||||
a phone rule must be `width: 100% !important` to win. Hide `.resize-handle` in
|
||||
the same rule — dragging is meaningless at full width. Since there is no global
|
||||
`border-box` reset, a full-width rail with its own padding also needs
|
||||
`box-sizing: border-box` or it overflows the viewport.
|
||||
|
||||
### State the content's floor, not the list's ceiling
|
||||
|
||||
On routes that stack two lists above the player (live TV shows the categories
|
||||
panel and the channel list), capping both lists still leaves the video a
|
||||
sliver. Give the player container a `min-height` instead and let the lists
|
||||
shrink into what is left.
|
||||
|
||||
### What to drop
|
||||
|
||||
Prefer removing a control over shrinking everything around it:
|
||||
|
||||
- Keyboard-only affordances — the `⌘K` badge, the shortcuts button.
|
||||
- The `mat-paginator` page-size select, which is the widest part of the
|
||||
control and the least useful one on a phone. The range and arrows stay.
|
||||
- Counts and subtitles that a neighbouring control already states.
|
||||
|
||||
Never drop the only way back to a hidden surface. A collapse toggle that is
|
||||
reachable by touch needs its restore affordance to be reachable too.
|
||||
|
||||
## Theme Guidance
|
||||
|
||||
### Light Theme
|
||||
|
||||
@@ -27,6 +27,29 @@ Do not invent a `test`, `build`, or `e2e` target because a similarly named
|
||||
project has one. Run affected lint/test/build targets that exist and the closest
|
||||
available E2E target for the changed behavior.
|
||||
|
||||
## Nx Dependency Updates
|
||||
|
||||
Keep `nx` and every official `@nx/*` package on the same exact version. Run
|
||||
`pnpm run deps:nx:validate` after any manifest or lockfile update; CI runs the
|
||||
same policy check and rejects both direct specifier drift and multiple resolved
|
||||
Nx versions.
|
||||
|
||||
Dependabot groups routine minor and patch Nx updates when possible. A security
|
||||
update may still contain only the vulnerable package, so replace an incomplete
|
||||
Dependabot PR with a coordinated maintainer update instead of editing the bot
|
||||
branch:
|
||||
|
||||
```bash
|
||||
pnpm nx migrate nx@<target> --skipInstall
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm nx migrate --run-migrations
|
||||
pnpm run deps:nx:validate
|
||||
```
|
||||
|
||||
Omit `pnpm nx migrate --run-migrations` when the first command reports that no
|
||||
migrations exist. Major Nx updates always use this manual workflow and the
|
||||
resulting PR runs the full CI pipeline.
|
||||
|
||||
## Placement Decision
|
||||
|
||||
- `apps/` owns runtime applications, development servers, E2E applications,
|
||||
|
||||
@@ -257,6 +257,15 @@ popovers even when a modifier is held or playback shortcuts are unavailable.
|
||||
Buttons, form controls, links, ARIA menu controls, and content-editable targets
|
||||
are also ignored anywhere in the event's composed path.
|
||||
|
||||
A player whose host sits inside an `inert` region ignores every shortcut,
|
||||
including Escape: `inert` strips pointer and Tab access but document-level
|
||||
listeners still fire, so the optional `hostElement` handler on
|
||||
`ControlsShortcutHandlers` lets the shortcuts opt out while a modal surface
|
||||
above the player (e.g. the workspace's phone context drawer) owns the
|
||||
keyboard. `EmbeddedMpvShortcutHandlers` (the native-view legacy dock) and
|
||||
the radio audio player's document-level volume/mute keys apply the same
|
||||
rule.
|
||||
|
||||
Action-specific keys are prevented only when the active controller can handle
|
||||
them: seek requires both capability and current seekability, volume/mute
|
||||
requires volume capability, and fullscreen requires an available DOM
|
||||
|
||||
@@ -37,7 +37,67 @@ Stalker portals use MAC address as the primary credential. The mock server follo
|
||||
|
||||
### In-Memory Only
|
||||
|
||||
No files or databases are written. All state (generated content + favorites) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs.
|
||||
No files or databases are written. All state (generated content + favorites + portal sessions) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs.
|
||||
|
||||
### Two Endpoints With Different Strictness
|
||||
|
||||
The app decides how to talk to a portal from the shape of its URL: a URL
|
||||
containing `/stalker_portal` is imported as a **full portal** (handshake,
|
||||
`Authorization: Bearer`, watchdog), anything else as a **simple portal** with no
|
||||
authentication at all. The mock therefore serves the same action set at two
|
||||
paths:
|
||||
|
||||
| Path | Router | Behaviour |
|
||||
|---|---|---|
|
||||
| `/portal.php` | `createPortalRouter(false)` | Tolerant: ignores the token and the MAC format, like most reseller panels |
|
||||
| `/stalker_portal/server/load.php` | `createPortalRouter(true)` | Strict: enforces both, like the real middleware |
|
||||
| `/server/load.php` | `createPortalRouter(true)` | Strict: the second URL shape `isFullStalkerPortal` recognizes |
|
||||
|
||||
The `/stalker` proxy route applies the same rule through
|
||||
`isFullPortalUrlShape()` — every URL the client would authenticate against is
|
||||
enforced, so tests cannot silently fall into the tolerant branch.
|
||||
|
||||
Keeping the tolerant path is what lets the pre-existing e2e suite (which imports
|
||||
`portal.php`) stay meaningful — it covers the simple-portal branch — while the
|
||||
strict path finally covers the authenticated branch that had no coverage at all.
|
||||
|
||||
The strict behaviours mirror the plaintext Stalker 4.9.35 middleware
|
||||
(`server/lib/stb.class.php`), the last openly readable ancestor of the encoded
|
||||
5.x core:
|
||||
|
||||
- **Plain-text auth failures.** `Authorization failed.` / `Unauthorized request.`
|
||||
are returned with **HTTP 200** and a `text/html` body, because the real server
|
||||
`exit`s before the JSON envelope is built. A client checking only status codes
|
||||
sees "success" and renders nothing. The `/stalker` proxy route still wraps the
|
||||
body in the `{ payload }` envelope, matching what `apps/web-backend` does.
|
||||
- **A handshake is not a session.** The token only authorizes requests once
|
||||
`get_profile` has adopted it for that MAC. Adoption is deliberately
|
||||
*stricter* than the stock server: 4.9.35 issues handshake tokens statelessly
|
||||
and pins whatever Bearer `get_profile` presents, so a forged token would
|
||||
become a session on a real portal — the mock only adopts tokens it actually
|
||||
issued, so a client with a broken token pipeline fails loudly in tests.
|
||||
- **Idempotent handshake.** Presenting the MAC's current token returns that same
|
||||
token, which is what allows real clients to persist tokens across restarts.
|
||||
- **Device-id pinning.** `device_id`/`device_id2` are stored on first non-empty
|
||||
value; any later change — including reverting to empty — is a permanent
|
||||
`device conflict` carrying the "Your STB is damaged." block message. This is
|
||||
the only identity check the stock server actually enforces.
|
||||
- **`signature`, `metrics`, `prehash` are ignored**, exactly as upstream ignores
|
||||
them; they exist for portals with a custom `access_filter.php`.
|
||||
- **MAC format validation.** Non-Infomir MACs (`00:1A:79:XX:XX:XX`) get a bare
|
||||
`{ status: 1 }` from `get_profile`.
|
||||
|
||||
- **`do_auth` is a boolean login step.** Non-empty credentials answer
|
||||
`{js:true}` and are recorded; the `login-required` scenario's `get_profile`
|
||||
keeps answering `status: 2` until that record exists, because the app sends
|
||||
`auth_second_step=1` on its very first profile request and a parameter check
|
||||
alone would be trivially bypassed.
|
||||
|
||||
Session state lives in `src/app/auth-store.ts` and is cleared by `/reset`.
|
||||
`POST /invalidate-session?macAddress=<mac>` drops a single MAC's tokens so
|
||||
tests can assert the client re-handshakes and retries instead of surfacing an
|
||||
error; pinned device identity survives invalidation, as it does on a real
|
||||
portal.
|
||||
|
||||
## Data Generation Pipeline
|
||||
|
||||
@@ -148,13 +208,21 @@ marker and an `ffrt4://radio/...` command.
|
||||
"cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
|
||||
"streamer_id": "1",
|
||||
"load": "",
|
||||
"error": ""
|
||||
"error": "",
|
||||
"cmd_received": "ffrt4://ch/live/1001/index.m3u8",
|
||||
"query_keys_received": ["JsHttpRequest", "action", "cmd", "type"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The stream URL is selected from a pool of 4 real public HLS test streams. The choice is deterministic based on the `cmd` field's character sum, so the same item always returns the same stream.
|
||||
|
||||
`cmd_received` and `query_keys_received` are mock-only diagnostics (a real
|
||||
portal does not send them): they echo the request's `cmd` after Express' single
|
||||
query decode — the same view a PHP portal gets from `$_GET` — plus the sorted
|
||||
set of query keys. E2E uses them to pin the client's `cmd` wire contract: no
|
||||
double-encoding, and no query-parameter injection through `cmd`.
|
||||
|
||||
### `get_short_epg`
|
||||
|
||||
```json
|
||||
@@ -308,6 +376,6 @@ test('browse VOD categories', async ({ page }) => {
|
||||
|
||||
- **New content types**: Add a new generator function in `data-generator.ts` and a new handler in `handlers/`.
|
||||
- **New scenarios**: Add to `SCENARIOS` in `scenarios.ts`.
|
||||
- **Stateful session tokens**: `handshake.handler.ts` generates a token from the MAC — extend this to track token expiry for testing re-auth flows.
|
||||
- **Error simulation**: Add a special MAC or query param to trigger error responses (e.g. 401, 500) for testing error handling in the Stalker store.
|
||||
- **Session behaviour**: `auth-store.ts` owns tokens and device pinning. Add TTLs or a "token replaced by another device" mode there rather than in the handlers.
|
||||
- **Error simulation**: Add a special MAC or query param to trigger error responses for testing error handling in the Stalker store. Note that portal-level auth errors are *not* HTTP errors — see [Two Endpoints With Different Strictness](#two-endpoints-with-different-strictness).
|
||||
- **Slow responses**: Add a `MOCK_DELAY_MS` env var and apply it in middleware for testing loading states.
|
||||
@@ -138,6 +138,125 @@ blank fields are not generated or forwarded to `get_profile`.
|
||||
metadata is independent from M3U playlist EPG metadata and must not depend on
|
||||
M3U-specific EPG fields.
|
||||
|
||||
## Request Transport and `cmd` Encoding
|
||||
|
||||
A real MAG/STB sends `cmd` unencoded: the portal's client JS concatenates raw
|
||||
`key=value` pairs, the browser URL layer escapes only what a URL cannot carry,
|
||||
and PHP's `$_GET` applies exactly one form-urldecode. The portal therefore sees
|
||||
the stored `cmd` decoded **once** — a pre-encoded `%3A` arrives as `:` and a
|
||||
literal `+` arrives as a space. IPTVnator reproduces that reference wire format
|
||||
on both transports with the shared `encodeStalkerCmdValue()`
|
||||
(`libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts`):
|
||||
|
||||
- `%` passes through untouched, so a `cmd` that already contains percent
|
||||
sequences is never double-encoded (the pre-0.23 `encodeURIComponent`
|
||||
transport delivered `%253A` and strict panels no longer matched the string).
|
||||
- Characters the WHATWG URL serializer keeps raw in a query stay raw
|
||||
(`/ : ? = + , @ $ [ ]` …), so the emitted bytes survive the axios/`new URL`
|
||||
transport unchanged.
|
||||
- Everything else is percent-encoded. This keeps the injection protection from
|
||||
the 0.22 hardening: `&`, `#` (and `;` for PHP setups with a `;` argument
|
||||
separator) inside `cmd` cannot append or truncate query parameters — they
|
||||
decode back to the original byte server-side, so the portal-visible value is
|
||||
unaffected.
|
||||
|
||||
Both transports assemble the portal request from the same two shared builders
|
||||
in `@iptvnator/shared/interfaces`, so their wire format cannot drift apart:
|
||||
|
||||
- `buildStalkerRequestUrl()`
|
||||
(`libs/shared/interfaces/src/lib/stalker-request-url.util.ts`) builds the
|
||||
full portal URL: `cmd` uses the reference encoding, every other param stays
|
||||
fully `encodeURIComponent`-encoded, `JsHttpRequest=1-xml` is appended when
|
||||
missing, and any query carried by the portal URL itself is dropped.
|
||||
- `buildStalkerIdentityRequestContext()`
|
||||
(`libs/shared/interfaces/src/lib/stalker-request-identity.util.ts`) builds
|
||||
the STB identity: the `mac`/`stb_lang`/`timezone` cookie (plus a
|
||||
serial-derived `__cfduid`), the MAG `User-Agent`/`X-User-Agent` pair
|
||||
(`STALKER_MAG_USER_AGENT`), `Accept`/`Accept-Language`/`Connection`, the
|
||||
`SN` header and `Authorization: Bearer` when present, and the serial
|
||||
parameter rule: `sn` travels only on `get_profile` (injected there, stripped
|
||||
everywhere else, mirrored into the `metrics` JSON).
|
||||
|
||||
Consumers:
|
||||
|
||||
- Electron: the `STALKER_REQUEST` handler
|
||||
(`apps/electron-backend/src/app/events/stalker.events.ts`) feeds both
|
||||
builders directly.
|
||||
- PWA: the renderer (`PwaService.forwardStalkerRequest`) sends `macAddress`,
|
||||
`token`, and `serialNumber` as **control params** on the renderer→proxy leg
|
||||
(`URLSearchParams`, which Express decodes losslessly). The web-backend
|
||||
`/stalker` proxy consumes them into the identity headers via the same shared
|
||||
builders and **never forwards them in the portal's query string** — portal
|
||||
credentials must not land in portal or intermediary access logs. The one
|
||||
protocol exception is `handshake`, whose candidate token is genuine query
|
||||
content (the portal reads it for the idempotent-handshake path) and is
|
||||
re-injected there.
|
||||
- Mock: the stalker-mock-server's `/stalker` route
|
||||
(`apps/stalker-mock-server/src/main.ts`) mirrors the proxy with the same
|
||||
shared identity builder, so PWA E2E runs exercise the real contract
|
||||
(including `query_keys_received` diagnostics matching what a real portal
|
||||
would log).
|
||||
|
||||
The mock portal's `create_link` response carries mock-only `cmd_received` and
|
||||
`query_keys_received` diagnostics so E2E can pin this contract
|
||||
(`apps/electron-backend-e2e/src/providers.e2e.ts`).
|
||||
|
||||
Response-side `cmd` normalization is also shared: both the Stalker store and
|
||||
the cross-portal collection resolver (`StreamResolverService`) use
|
||||
`normalizeStalkerPlaybackCommand()` / `resolveStalkerPlaybackUrl()` from
|
||||
`libs/portal/stalker/data-access`, which strip the `<solution> ` prefix and
|
||||
resolve relative (`/media/...`) or query-only (`?token=...`) `create_link`
|
||||
replies against the portal base URL.
|
||||
|
||||
## Playback Header Contract
|
||||
|
||||
Every playback kind — ITV, VOD, series episodes, and radio — resolves its
|
||||
stream and attaches the same portal header set through
|
||||
`buildStalkerExternalPlaybackHeaders()`
|
||||
(`libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts`).
|
||||
The collection routes (Favorites/Recently Viewed) share the contract:
|
||||
`StreamResolverService.resolveStalker()` builds the identical profile for the
|
||||
streams it resolves, so a channel opened from a collection carries the same
|
||||
credentials as one opened from the portal.
|
||||
The resolved `ResolvedPortalPlayback.headers` feed both the external players
|
||||
(MPV/VLC/Embedded MPV via the launch IPC) and the built-in players via the
|
||||
scoped Electron request-header override (`ElectronStreamHeadersService`,
|
||||
applied by `WebPlayerViewComponent` for the video players and by the Stalker
|
||||
live layout for the radio audio player, which renders outside
|
||||
`WebPlayerViewComponent` — see `docs/architecture/electron-security.md`,
|
||||
"Scoped Request Header Overrides").
|
||||
|
||||
Two stream profiles exist, selected by one shared predicate:
|
||||
|
||||
- **Portal-owned** (`isStalkerStreamCredentialSafe()` in
|
||||
`@iptvnator/shared/interfaces`): the stream host equals the portal host —
|
||||
including a different port or an http→https upgrade, the routine IPTV panel
|
||||
shape (#1158 class). These streams get the full MAG profile: `Cookie`
|
||||
(`mac=…` plus protocol cookies), `Authorization: Bearer <token>` when a
|
||||
session token exists, `User-Agent` (playlist override or the MAG UA — the
|
||||
API path always sent both, the playback set historically sent only
|
||||
`X-User-Agent`), `X-User-Agent`, `SN` when a real serial exists, and
|
||||
`Origin`/`Referer` set to the portal origin.
|
||||
- **Foreign / direct** (different host, or an https→http downgrade): the
|
||||
credential-free `KSPlayer` direct-stream profile (`User-Agent: KSPlayer`,
|
||||
`Accept`, `Range`, `Icy-MetaData`, `Connection`). Portal credentials must
|
||||
never reach a third-party host; direct stream URLs carry their access token
|
||||
in the URL minted by `create_link`.
|
||||
|
||||
The Electron main process keeps a fallback header context per resolved
|
||||
`create_link` URL (`stalker-playback-context.service.ts`) for external-player
|
||||
launches that arrive without renderer headers. It classifies streams with the
|
||||
same shared predicate — if the two ever diverged,
|
||||
`isStalkerDirectStreamProfile` in the external-player path would discard the
|
||||
renderer's credentialed headers for streams the main process misread as
|
||||
direct.
|
||||
|
||||
The mock server's `gated-stream` scenario (MAC `00:1A:79:00:00:09`) makes
|
||||
`create_link` return a local `/stream/gated/video.mp4` that answers 403
|
||||
without the mac cookie and current Bearer token;
|
||||
`apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts` uses it to
|
||||
prove a built-in player's media requests really carry the credentials.
|
||||
|
||||
## Live TV and Radio
|
||||
|
||||
The Stalker live route and radio route intentionally share
|
||||
@@ -476,6 +595,41 @@ Import rule:
|
||||
- a fresh handshake must happen after import for full-portal sessions; imported
|
||||
backups never trust a serialized token
|
||||
|
||||
## Account Info Dialog
|
||||
|
||||
`StalkerAccountInfoComponent`
|
||||
(`libs/portal/stalker/feature/src/lib/stalker-account-info/`) mirrors the
|
||||
Xtream account-info dialog's visual language and shows subscription facts
|
||||
for a portal: status, login, tariff plan, expiry date with a days-left
|
||||
counter, MAC/phone, and portal details.
|
||||
|
||||
Data flow (two sources, cached-first):
|
||||
|
||||
- Cached: `Playlist.stalkerAccountInfo`, captured from `get_profile` at
|
||||
import time for full `/stalker_portal/` installations. The dialog loads it
|
||||
by playlist id (the meta row does not carry it) and renders instantly with
|
||||
a "Saved data" badge.
|
||||
- Fresh: `StalkerAccountInfoService`
|
||||
(`libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts`).
|
||||
Full portals re-run handshake + `get_profile`; `portal.php` panels are
|
||||
queried with `account_info/get_main_info`, whose field set varies between
|
||||
panels and is mapped best-effort (absent fields render nothing). A failed
|
||||
refresh keeps the cached snapshot and flags it. The two no-data outcomes
|
||||
differ: a portal that answers but publishes no account facts (and no
|
||||
cached snapshot exists) renders the ready-state "No account details"
|
||||
panel, while only an unreachable portal without a cached snapshot enters
|
||||
the error state with retry.
|
||||
|
||||
Entry points are shared with Xtream and gated on the shared predicates in
|
||||
`libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts`
|
||||
(`isXtreamAccountPlaylist` / `isStalkerAccountPlaylist`): the header playlist
|
||||
switcher (bottom section for the active playlist and the per-row ⋮ menu),
|
||||
the dashboard source card ⋮ menu, and the command palette. The
|
||||
`WorkspaceShellHeaderService.openAccountInfoFor()` branch picks the dialog
|
||||
by playlist type; `WORKSPACE_SHELL_ACTIONS.openStalkerAccountInfo()` lazy
|
||||
loads the component. The stalker-mock-server implements `get_main_info` for
|
||||
dev/E2E.
|
||||
|
||||
## Remote Control Integration
|
||||
|
||||
Stalker live remote control is implemented in:
|
||||
|
||||
@@ -116,10 +116,12 @@ The control plane is absent by default. It is mounted only when
|
||||
`IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN` and a literal loopback `HOST`
|
||||
(`127.0.0.1` or `::1`). Every `/__control/*` request must carry that exact value
|
||||
in `x-iptvnator-performance-token`, including `OPTIONS` preflight requests.
|
||||
Configuration is validated before the HTTP listener opens. Normal development
|
||||
mode preserves the legacy wildcard bind when `HOST` is unset; control mode
|
||||
instead defaults to `127.0.0.1` and rejects an explicitly configured
|
||||
non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell
|
||||
Configuration is validated before the HTTP listener opens. Both modes now
|
||||
default to `127.0.0.1` when `HOST` is unset — the fixture serves fabricated but
|
||||
unauthenticated content, so it should not be reachable from other hosts by
|
||||
accident. Set `HOST=0.0.0.0` explicitly to expose it, which is what you need
|
||||
when driving the mock from a phone, an STB, a container, or another machine.
|
||||
Control mode additionally *rejects* an explicitly configured non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell
|
||||
value reaches the parser; its no-value default remains `3211`.
|
||||
|
||||
Use a dedicated port rather than the normal `3211` E2E server:
|
||||
|
||||
@@ -98,9 +98,17 @@ and private-network checks.
|
||||
|
||||
## User-Agent
|
||||
|
||||
Electron's `XTREAM_REQUEST` and `XTREAM_PROBE_URL` handlers
|
||||
(`apps/electron-backend/src/app/events/xtream.events.ts`) send a shared
|
||||
`XTREAM_CLIENT_USER_AGENT` constant on every outgoing request. Some Xtream
|
||||
Electron's `XTREAM_REQUEST` and stream-probe handlers plus fresh Xtream movie
|
||||
and series-episode download requests share the exported
|
||||
`XTREAM_CLIENT_USER_AGENT` fallback. A playlist's explicit User-Agent,
|
||||
Referer, and Origin are propagated to either download kind; the explicit
|
||||
User-Agent still wins over the fallback. Legacy
|
||||
download rows without a stored User-Agent receive the fallback when retrying,
|
||||
resuming, or recovering a missing completed file. Download rows intentionally
|
||||
survive individual source deletion; when the playlist row is already gone and
|
||||
its type can no longer be recovered, a headerless legacy download receives the
|
||||
same IPTV-player fallback, while a still-identifiable Stalker row remains
|
||||
unchanged. Some Xtream
|
||||
panels sit behind a WAF (e.g. Cloudflare) configured to challenge
|
||||
generic/incomplete browser-looking User-Agents while allowlisting known IPTV
|
||||
player clients; a player-style User-Agent (currently a VLC signature) avoids
|
||||
|
||||
Reference in new issue
Block a user