mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02: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
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Unofficial Websites Cover 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:** Generate, validate, and publish a branded 16:9 cover image for the unofficial IPTVnator websites safety post.
|
||||
|
||||
**Architecture:** Create one project-bound raster asset with the built-in image-generation tool, then reference it through the blog collection’s existing `heroImage` frontmatter field. Keep the artwork text-free and independent of real services or user data so the same file is safe for the article hero, blog cards, and Open Graph metadata.
|
||||
|
||||
**Tech Stack:** Built-in OpenAI image generation, Astro 5 content collections, static assets under `apps/website/public/`, Nx website build.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Create `apps/website/public/blog/beware-unofficial-iptvnator-websites.png`: final 16:9 raster cover.
|
||||
- Modify `apps/website/src/content/blog/beware-unofficial-iptvnator-websites.mdx`: add the public asset URL to `heroImage` frontmatter.
|
||||
- No production component or schema changes are needed because the existing blog layout, cards, structured data, and Open Graph head already consume `heroImage`.
|
||||
|
||||
### Task 1: Generate and select the cover
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/website/public/blog/beware-unofficial-iptvnator-websites.png`
|
||||
|
||||
- [ ] **Step 1: Generate one horizontal image with the built-in image tool**
|
||||
|
||||
Use this prompt exactly as the base generation brief:
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: IPTVnator blog header and social-sharing cover
|
||||
Primary request: Create a premium editorial illustration that distinguishes the authentic IPTVnator project from unofficial lookalike websites. A crisp, original screen-and-broadcast-signal symbol derived from IPTVnator's established app-icon language is the central protected artifact. Two faint, fragmented browser-like panels recede beyond the left and right canvas edges as unofficial copies. Add one small pale-red warning marker as the only caution accent.
|
||||
Scene/backdrop: matte near-black graphite field with a restrained technical grid and subtle tactile grain
|
||||
Subject: central teal television-screen outline with broadcast arcs, concentric signal rings behind it, a small verification check attached to the central artifact, low-contrast broken browser silhouettes at both edges
|
||||
Style/medium: high-end flat editorial technology illustration, minimalist, precise, lightly tactile, strong silhouette, not a UI screenshot
|
||||
Composition/framing: horizontal 16:9 hero cover; central subject inside the middle 60 percent safe area; generous negative space; edge browser silhouettes may bleed out of frame; readable as a small blog-card thumbnail
|
||||
Lighting/mood: calm, authoritative, protective, restrained contrast
|
||||
Color palette: #0a0a08 graphite, #121210 charcoal, IPTVnator teal #20a8a8 / #38c4c4 / #5ee0e0, pale red #fdebec with muted red #9f2f2d, tiny warm-bone highlights #f0f0eb
|
||||
Materials/textures: matte surfaces, fine grid, very subtle paper-like grain
|
||||
Constraints: no text; no named or recognizable unofficial website; no real playlist, channel, stream, account, subscription, or copyrighted media content; no third-party logos; no watermark; keep the core mark original rather than pasting a logo
|
||||
Avoid: people, hooded figures, padlocks, shields, phishing hooks, generic cybersecurity stock imagery, neon, purple-blue AI gradients, glossy 3D, glassmorphism, heavy shadows, dense dashboards, excessive warning symbols
|
||||
```
|
||||
|
||||
Expected: one coherent 16:9 cover with the official signal dominant and the unofficial browser forms visibly secondary.
|
||||
|
||||
- [ ] **Step 2: Inspect the generated image at full size**
|
||||
|
||||
Open the generated bitmap with the local image viewer and verify all of the following:
|
||||
|
||||
- the canvas is horizontal and close to 16:9;
|
||||
- the central screen/signal mark is crisp and remains inside the middle safe area;
|
||||
- no text, watermark, third-party branding, real content, or malformed pseudo-UI appears;
|
||||
- the teal and pale-red accents match the approved palette;
|
||||
- the result is flat and editorial rather than glossy, neon, or generic cybersecurity art.
|
||||
|
||||
Expected: every check passes. If exactly one visual defect remains, perform one targeted edit that names only that defect and repeats all invariants above, then inspect again.
|
||||
|
||||
- [ ] **Step 3: Save the selected asset into the project**
|
||||
|
||||
Copy the selected built-in output from its generated-images location to:
|
||||
|
||||
```text
|
||||
apps/website/public/blog/beware-unofficial-iptvnator-websites.png
|
||||
```
|
||||
|
||||
Expected: the project-bound final exists at the exact path and the selected output is not referenced from the generated-images directory.
|
||||
|
||||
- [ ] **Step 4: Verify the file and thumbnail legibility**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
sips -g format -g pixelWidth -g pixelHeight apps/website/public/blog/beware-unofficial-iptvnator-websites.png
|
||||
sips -Z 480 apps/website/public/blog/beware-unofficial-iptvnator-websites.png --out /tmp/iptvnator-unofficial-sites-cover-thumb.png
|
||||
```
|
||||
|
||||
Expected: PNG format, landscape dimensions close to 16:9, and a 480-pixel thumbnail whose central signal remains clearly readable when inspected.
|
||||
|
||||
### Task 2: Connect the cover to the blog post
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/website/src/content/blog/beware-unofficial-iptvnator-websites.mdx`
|
||||
|
||||
- [ ] **Step 1: Add the existing `heroImage` frontmatter field**
|
||||
|
||||
Insert this line immediately after `author: 4gray`:
|
||||
|
||||
```yaml
|
||||
heroImage: /iptvnator/blog/beware-unofficial-iptvnator-websites.png
|
||||
```
|
||||
|
||||
Expected: the post uses the same public-URL convention as existing release and guide posts.
|
||||
|
||||
- [ ] **Step 2: Verify the frontmatter reference and asset pairing**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "^heroImage: /iptvnator/blog/beware-unofficial-iptvnator-websites\.png$" apps/website/src/content/blog/beware-unofficial-iptvnator-websites.mdx
|
||||
test -f apps/website/public/blog/beware-unofficial-iptvnator-websites.png
|
||||
```
|
||||
|
||||
Expected: `rg` prints exactly one frontmatter match and `test` exits successfully.
|
||||
|
||||
### Task 3: Validate the website integration
|
||||
|
||||
**Files:**
|
||||
- Verify: `apps/website/src/content/blog/beware-unofficial-iptvnator-websites.mdx`
|
||||
- Verify: `apps/website/public/blog/beware-unofficial-iptvnator-websites.png`
|
||||
|
||||
- [ ] **Step 1: Bootstrap Nx discovery if dependencies are absent**
|
||||
|
||||
If `node_modules` is missing, run:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
pnpm nx show projects
|
||||
```
|
||||
|
||||
Expected: Nx lists workspace projects and includes `website`.
|
||||
|
||||
- [ ] **Step 2: Build the website**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx build website
|
||||
```
|
||||
|
||||
Expected: the Astro build succeeds and emits the blog post under `dist/apps/website/blog/beware-unofficial-iptvnator-websites/`.
|
||||
|
||||
- [ ] **Step 3: Verify the built metadata and static asset**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "beware-unofficial-iptvnator-websites\.png" dist/apps/website/blog/beware-unofficial-iptvnator-websites/index.html
|
||||
test -f dist/apps/website/blog/beware-unofficial-iptvnator-websites.png
|
||||
```
|
||||
|
||||
Expected: the built HTML references the cover in article/Open Graph metadata and the static file exists in the built blog directory.
|
||||
|
||||
- [ ] **Step 4: Review scope and release-note policy**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no whitespace errors; the final implementation changes only the generated cover and the blog post frontmatter beyond the already-approved design and plan documents. Skip `.changes/` because website content is auto-exempt from the runtime release-note gate. Canonical architecture documentation does not need an update because no runtime behavior, contract, route, or workflow changed.
|
||||
|
||||
- [ ] **Step 5: Commit the implementation**
|
||||
|
||||
```bash
|
||||
git add apps/website/public/blog/beware-unofficial-iptvnator-websites.png apps/website/src/content/blog/beware-unofficial-iptvnator-websites.mdx docs/superpowers/plans/2026-08-02-unofficial-websites-cover.md
|
||||
git commit -m "docs(website): add unofficial sites post cover"
|
||||
```
|
||||
|
||||
Expected: the generated cover, frontmatter integration, and implementation plan are committed together.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Unofficial IPTVnator Websites Blog Cover Design
|
||||
|
||||
## Goal
|
||||
|
||||
Create one horizontal 16:9 cover image for the blog post “Beware of Unofficial IPTVnator Websites and IPTV Services.” The image should make the distinction between the authentic IPTVnator project and unofficial lookalike websites immediately understandable without repeating the article title inside the artwork.
|
||||
|
||||
## Approved Direction
|
||||
|
||||
The approved concept is **Official Signal**. A crisp IPTVnator screen-and-broadcast mark is the central artifact. Concentric signal rings establish authenticity and continuity, while two faint, fragmented browser-like panels recede beyond the left and right edges as unofficial copies. A small pale-red warning marker supplies the only caution color. The composition remains legible when cropped into an aspect-ratio blog card.
|
||||
|
||||
## Visual System
|
||||
|
||||
- Canvas: 16:9 landscape, suitable for a 2200 × 1238 source image.
|
||||
- Background: matte IPTVnator graphite (`#0a0a08`) with a restrained technical grid and light grain.
|
||||
- Primary accent: IPTVnator teal (`#20a8a8`, `#38c4c4`, and `#5ee0e0`).
|
||||
- Warning accent: pale red (`#fdebec`) with muted red detail (`#9f2f2d`).
|
||||
- Main subject: a screen and broadcast-signal symbol derived from the established IPTVnator app-icon language, rendered as an original illustration rather than a pasted logo.
|
||||
- Supporting forms: two low-contrast browser silhouettes with broken or dashed edges, clearly secondary to the official signal.
|
||||
- Material character: flat, editorial, and lightly tactile; no gradients, glass effects, or heavy shadows.
|
||||
- Text: none inside the final image.
|
||||
|
||||
## Composition
|
||||
|
||||
The official signal occupies the central safe area, with enough surrounding negative space to remain readable in both the article hero and smaller listing cards. Signal rings form a controlled circular rhythm behind it. The unofficial panels are partly cropped by the canvas edges and carry lower contrast, preventing them from competing with the main mark. The pale-red warning marker sits off-axis as the single second-read detail.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not reproduce or name any unofficial website.
|
||||
- Do not depict real playlist, channel, stream, account, or subscription content.
|
||||
- Do not add people, hooded figures, padlocks, shields, phishing hooks, or generic cybersecurity stock imagery.
|
||||
- Do not use neon, purple-blue AI gradients, glassmorphism, glossy 3D rendering, or dense dashboard UI.
|
||||
- Do not add text, watermarks, unrelated logos, or trademarked third-party graphics.
|
||||
- Keep all key elements inside the center-safe crop while allowing the secondary browser silhouettes to bleed beyond the frame.
|
||||
|
||||
## Deliverable and Integration
|
||||
|
||||
Generate a single project-bound raster cover with the built-in image-generation tool. Save the selected asset under `apps/website/public/blog/` using a descriptive, non-versioned filename. Add its `/iptvnator/blog/...` URL as `heroImage` in `apps/website/src/content/blog/beware-unofficial-iptvnator-websites.mdx`.
|
||||
|
||||
Validate the saved dimensions and file type, inspect the final image at full size and as a small thumbnail, run the website build, and verify that the post’s social metadata resolves to the new cover. No release note is required because this is website content and is auto-exempt from the runtime release-note gate.
|
||||
Reference in new issue
Block a user