mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
5e4f2ca3dd8b6a52afbf2ddb8e0c0daf51ec3db1
2689
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5e4f2ca3dd |
docs(stalker): reconcile the Stalker docs after the API-compatibility series (#1375)
Nine PRs landed between 2026-08-01 and 2026-08-04 in parallel worktrees, each
editing its own section of docs/architecture/stalker-portal.md and CLAUDE.md.
Sections that were correct when written disagreed with each other, or with
master, afterwards. Every claim here was verified against the code.
Corrected in stalker-portal.md: routes listed without the /workspace prefix;
"simple portals carry only the mac= cookie" (every request goes through the
shared identity builder — but the direct branch forwards no serial, so no
SN/__cfduid either, while playback headers are NOT mode-gated); a facade
introduced as "three modules" above a list of five; the pre-#1370 "blank
fields are not generated" opening; an ambiguous stalker-identity.utils.ts
citation (two files share the name); two of the three surfaces that apply the
scoped header override; a bare {status: 1} now being a refusal; and the
session-state fields #1354 added to the backup exclusion list (mirrored in
playlist-backup-restore.md).
CLAUDE.md had no entry at all for portal mode / endpoint discovery / lazy
repair — the largest change of the series; added one. Its session-facade list
was missing two modules and status 1 still read as plain "blocked".
Mock server: documented the /stalker, /stream/gated and marketing-poster
routes and the HOST variable; replaced the global POST /reset guidance with
the real per-MAC isolation contract (OWNED_MACS, the sibling 00:1A:79:5F:*
range, mode: 'serial'); added get_main_info; refreshed the project tree; fixed
a broken anchor; and corrected MOCK_PORT, which moves the client side only —
nothing maps it to the server's PORT.
The repo skill's "keep Stalker request rules in Stalker data access" no longer
holds: the wire-format, identity, portal-mode and auth-failure contracts live
in shared/interfaces because the Electron main process cannot import renderer
libs.
Also fixes four stale code comments carrying the same claims, including
"Single choke point for Stalker API calls" — four callers deliberately go
direct, and only fetchViaProfile() wires repair itself.
Docs and comments only; no executable change. No release note (no user-visible
behavior); no-release-note label applied for the libs/** paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
53c318bac7 |
chore(deps): bump axios from 1.18.1 to 1.19.0 (#1367)
Bumps [axios](https://github.com/axios/axios) from 1.18.1 to 1.19.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.18.1...v1.19.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.19.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
33e345c83f |
chore(deps): bump github/codeql-action in the actions-minor-patch group (#1371)
Bumps the actions-minor-patch group with 1 update: [github/codeql-action](https://github.com/github/codeql-action). Updates `github/codeql-action` from 4 to 4.37.4 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.4 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
d8e3eb9219 |
chore(deps-dev): bump angular-eslint from 21.3.1 to 21.4.0 (#1350)
Bumps [angular-eslint](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/angular-eslint) from 21.3.1 to 21.4.0. - [Release notes](https://github.com/angular-eslint/angular-eslint/releases) - [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/angular-eslint/CHANGELOG.md) - [Commits](https://github.com/angular-eslint/angular-eslint/commits/v21.4.0/packages/angular-eslint) --- updated-dependencies: - dependency-name: angular-eslint dependency-version: 21.4.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
9ff1c6ae01 |
feat(stalker): identity hardening (#1370)
MAC addresses are canonicalized to the uppercase colon form a real STB
sends and validated at the input boundary, with a hint when they fall
outside Infomir's OUI — which the stock server's default filter refuses
with a bare {status: 1} no user could diagnose. Normalization applies
only to a value the user actually edits: rewriting stored bytes would
move the session fingerprint for every existing playlist with no user
action, and the MAC is the account key.
Device IDs can optionally be derived from the MAC the way StbEmu and
stalker-to-m3u do — SHA256(MAC) and SHA256(MAC + "stalker"), which a
real box never reports as equal. The portal pins the first non-empty
device_id/device_id2 it sees to the MAC permanently, refuses a different
one, and treats a later empty value as an unrecoverable lockout, so
derived values are written into the visible fields and persisted as
literal strings, never recomputed at request time. The option is offered
at import only; the edit dialog warns instead once an ID has actually
reached the portal.
get_profile now reports one coherent MAG250 (ver, stb_type — previously
empty —, hw_version, image_version, client_type), and a device conflict
gets its own StalkerPortalError kind so the UI can explain it instead of
relaying the portal's "Your STB is damaged".
Closes the identity-fields cluster: #927, #860.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7111942509 |
chore(deps): update Nx to 22.7.2 (#1365)
* chore(deps): update Nx to 22.7.2 * fix(deps): keep Nx major updates manual |
||
|
|
d2a83164ec | feat(stalker): protocol-correct auth lifecycle (#1354) | ||
|
|
e197409b10 |
fix(stalker): only mint a temporary link when the row asks for one (#1364)
* fix(stalker): only mint a temporary link when the row asks for one `create_link` ran on every Stalker playback. The reference client — the portal's own `player.js`, mirrored by Kodi's pvr.stalker — mints a link only when the catalog row sets `use_http_tmp_link` or `use_load_balancing`; otherwise it plays the static `cmd` that `get_all_channels` / `get_ordered_list` already returned. Neither flag was read anywhere in the codebase, so every channel paid a round trip and gained a failure point the reference client does not have. One helper now owns the decision (`resolveStalkerStaticPlaybackUrl`), used by `fetchStalkerPlaybackLink()` for ITV/VOD/radio, by the download path, and by `StreamResolverService` for Favorites/Recently Viewed. Its guards are deliberately wider than the flags alone and can only route a row back onto the `create_link` path: no row to read flags from, a relative or query-only command (the VOD `has_files` rewrite), a non-HTTP scheme, or a loopback host. An episode always mints, since `series` selects it server-side. Radio joins the same decision, so a station the portal proxies now gets its link instead of playing a URL the portal never meant to serve. Temporary links live ~5 s, so the audit that came with this: favorites and recently-viewed persist the `cmd`, playback positions store ids, and the main-process context map stores headers keyed by origin+path — none replay a resolved URL. Downloads are the documented exception, and honouring the flags shrinks even that, since an unflagged movie now yields a permanent URL that survives retry. `forced_storage` and `play_token` stay unwired, with the reasoning recorded in the docs rather than left ambiguous. The mock's ITV/radio rows now carry both flags, and the new `static-channel-cmd` scenario (MAC 00:1A:79:00:00:0A) serves unflagged rows with a playable command so the e2e can assert that NO `create_link` request reaches the portal — verified to fail when the change is reverted, with a companion test proving the recorder sees a link when one is due. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): keep temporary-link flags across VOD normalization Codex P1 on #1364, and it is real. `buildStalkerSelectedVodItem()` narrows a raw portal row to an explicit whitelist, and the two flags were not on it. It feeds both `selectedItem()` — which the VOD playback path reads as `linkFlags` — and, through `createStalkerVodItem`, the download payload. So a flagged VOD row with an absolute HTTP `cmd` arrived looking unflagged and took the static path, playing the portal's non-final URL instead of minting a link. The direction of the failure is what makes it a P1: a dropped flag reads as "no temporary link needed", so the whitelist fails OPEN. Both flags now sit on `StalkerVodSource` / `StalkerSelectedVodItem` and on the whitelist, with the consequence spelled out at the normalizer so the next edit does not quietly undo it, and specs pinning all three normalizers plus a store-level test that a flagged VOD still mints. Also two things from re-reading my own diff: - The radio path called `resolveStalkerStaticPlaybackUrl` and then handed the same row to `fetchStalkerPlaybackLink`, which runs that exact check again. Two copies of one decision is the divergence this PR exists to remove, so the outer call and its now-unreachable guard are gone. - `portal-catalog-facade.ts` spells the flag shape out instead of importing `StalkerLinkFlagSource`; it now says why (`type:util`/`domain:portal-shared` may not depend on `type:data-access`/`domain:stalker`), so the obvious "reuse the type" cleanup does not get made and break the boundary lint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): authenticate before serving a static collection stream Second Codex P1 on #1364, and a regression this PR introduced. `create_link` was also the request that warmed the portal session. Tokens live in memory only (`StalkerSessionService.tokenCache` is a plain Map), and the collection header builder reads the raw `getCachedToken()`. So a cold start from global Favorites or Recently Viewed — the portal never opened this session — took the static path, found no token, and handed a same-host gated stream headers with no `Authorization`: a 403 on exactly the streams the header contract exists for. The same raw accessor cannot tell a token negotiated for a pre-edit identity from a current one. `StreamResolverService` now calls `ensureToken()` before building a static playback. It is the right primitive: handshake + `get_profile` with no link minted, identity fingerprint validated, concurrent callers deduped, and an immediate null for simple portals — and calling it keeps this change out of `stalker-session.service.ts`, which PR 6 (#1354) is splitting. Best-effort by design: a static URL may point at a CDN that needs no credentials, so a failed handshake degrades to the token-less header set instead of costing the user their playback. Both halves are pinned by tests, and removing the call makes the cold-start test fail. The portal routes need no equivalent and do not get one: an item cannot be selected before its catalog has loaded, and every catalog load authenticates. That reasoning is now written down rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): warm the session at the choke point; keep downloads authenticated Two more Codex findings on #1364, and the first one shows my previous commit message reasoned too broadly. P1 — I claimed the portal routes are "structurally warm" because an item cannot be selected before its catalog loads. That is true of the routed portal views, but not of the global collection detail, which calls `setCurrentPlaylist()` and `setSelectedItem()` straight from a persisted row with no catalog load in between and then goes through the STORE playback path. A VOD opened from Favorites on a cold start therefore still played a same-host gated stream with no Bearer token. Rather than extend the per-route argument, the warm-up moved to the one place every static return passes through: `fetchStalkerPlaybackLink()` now calls the session before short-circuiting, covering ITV, VOD, radio and downloads at once. `StreamResolverService` keeps its own call — its static branch does not go through that function — but both now share a single primitive, `ensureStalkerSession()` in `stalker-request.utils.ts`, so the two routes cannot drift on when a session is required. Still best-effort, still outside `stalker-session.service.ts` (PR 6 territory). P2 — downloads cannot use that escape hatch at all: the main-process stored header allowlist is User-Agent/Origin/Referer only, no Cookie or Authorization, so a static same-host URL 401s where a minted one worked. `startStalkerVodDownload` now classifies the candidate with the shared `isStalkerStreamCredentialSafe()` and withholds the row — forcing `create_link` — for anything portal-owned. A CDN-hosted movie keeps the permanent URL that survives retry; a portal-hosted one keeps the minted URL that carries its own token. Both fixes mutation-checked: each reverted change fails exactly one test. Docs corrected, including the overreaching "structurally warm" claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(stalker): record the cached-token revalidation trade-off Codex flagged that the static path no longer self-heals a retired token, since `ensureToken` returns a same-identity cache entry without a network call — whereas `create_link` used to refresh it through `makeAuthenticatedRequest`'s auth-failure retry. The mechanism it posits does not exist on stock Stalker: per the 4.9.35 reference, handshake tokens have no TTL, and not sending the watchdog does not invalidate auth (it only clears the admin panel's "online" flag). The real residual vector is another device calling `get_profile` on the same MAC, which is common enough on shared subscriptions to be worth naming. Revalidating on every static playback would cost exactly the round trip this change removes, so it is deliberately not done. Recorded as a known trade-off with its mitigation (a running watchdog still self-heals within a ping cycle) and handed to PR 6, where a refresh on an OBSERVED playback authorization failure belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(stalker): tighten the token-revalidation trade-off wording Greptile review feedback: the watchdog mitigation was the most important part of that paragraph and sat behind the caveat. It now follows the MAC-sharing vector directly, and the paragraph ends by naming what is actually left uncovered — a same-host static stream played while no watchdog is up — so a future reader can size the residual without re-deriving it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): prefer the live playlist row over a stale favorite snapshot Codex P1 on #1364, and mine. `resolveStalker` reads its portal coordinates as `item.stalkerPortalUrl ?? playlist?.portalUrl` — item first. The create_link branch quietly corrected for that afterwards by re-reading `applyOverride(playlist).portalUrl`, so the row won wherever it existed, which is what the comment right above it already promised: "when the row exists it wins over the item's snapshot of the portal URL (a repaired endpoint must beat a stale favorite)". The static branch I added returns before that correction, so it shipped the stale snapshot. Consequences after a playlist edit: a same-host static URL matching the OLD host gets the newly negotiated token and identity headers sent to the previous portal, and a MAC-only edit pairs the new token with the old MAC cookie — precisely the pairing `stalkerIdentityFingerprint` exists to prevent. Both branches now derive the coordinates once, row-first with the repair override applied, and fall back to the item's snapshot only for a playlist that no longer exists — which is the role `buildStalkerPlayback` already documents for it. Mutation-checked: restoring item-first precedence fails the new test alone. Also documents a local-only e2e hazard found while re-running the suite: `mode: 'serial'` orders tests within one project, but chromium/firefox/webkit run the file concurrently against the same mock server, so one project's beforeEach reset can drop a session another is mid-test on — which is what a lone auth-spec failure that passes on rerun actually is. CI never sees it; the Web E2E job runs --project=chromium alone, and that command is clean (22/22). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): warm the session against the repaired portal configuration Found while auditing my own static branch against the create_link path rather than waiting for the next review round. `executeStalkerRequest` applies the lazy-repair override on its first line, so the create_link path always talks to the configuration a completed repair proved good. The session warm-up I added did not: it handed `ensureToken` the caller's pre-repair row, so a portal whose endpoint or mode had been repaired would handshake against the configuration the repair had already rejected — stranding the session precisely on the portals repair exists to rescue. The override now happens inside `ensureStalkerSession`, mirroring `executeStalkerRequest`'s first line, so every caller inherits the rule instead of each having to remember it. Mutation-checked: dropping the override fails the new test alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): fall back to create_link when a portal-owned static url has no session Codex P1 on #1364. `create_link` was also the request that could FAIL, and a failure is what triggers the lazy portal repair. A playlist still misclassified as token-free, or pointing at an unrepaired endpoint, used to self-heal on that failure and then play; the static path issues no request, so nothing fires and the stream just 401s. Its suggested remedy — routing a skipped warm-up through `repairPortal()` — cannot be taken literally: a skipped warm-up is the NORMAL case for the many legitimately token-free reseller panels, and probing each of them on every playback would cost far more than the round trip this PR removes. What is decidable without a request is whether we are about to serve a stream we already know will fail. `ensureStalkerSession` now reports whether the session can serve credentialed playback — true for a portal needing no token and for one holding a usable token, false for a full portal left without one — and both static call sites act on it: - foreign-host URL: served regardless, it never needed the session; - portal-owned URL with a usable session: served, as before; - portal-owned URL with no usable session: falls back to `create_link`, which mints a URL carrying its own token AND re-enters the only path that can observe a failure and repair. That covers the unrepaired-endpoint half exactly. The misclassified-as-simple half stays open by construction — no request means no evidence, and "simple portal" is indistinguishable from "misclassified" without one. It belongs with the other reactive-repair work already handed to PR 6: refresh and repair on an OBSERVED playback authorization failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): require flag evidence before trusting a row as unflagged Two Codex findings on #1364. P1 — legacy persisted snapshots. Favorites and Recently Viewed rows saved before this change went through `buildStalkerSelectedVodItem`'s whitelist, which dropped both flags, and `buildStalkerFavoritePayload` spreads that whitelisted object. So a legacy row is flagless because WE stripped it, not because the portal said no — and the helper was reading it as "explicitly unflagged". With an absolute HTTP `cmd` from a load-balanced portal that meant playing a non-final URL. There is no migration or provenance marker for those rows. A stock portal returns both flags on every row, so their PRESENCE is itself the provenance signal, and it is the only one available without a refetch. `resolveStalkerStaticPlaybackUrl` now requires at least one flag key to be present; absence reads as "no evidence" and routes back to `create_link`, which is the pre-PR behaviour. This costs the optimization on panels that omit the flags entirely — the honest price for not being able to tell them apart from our own stripped rows. Radio is the one documented exception. It has always played a directly usable command without `create_link`, so a flagless radio row keeps that rather than newly minting — a portal whose radio `create_link` never worked would otherwise lose playback it has today. ITV and VOD have no such history and stay conservative. P2 — loopback range. IPv4 reserves all of `127.0.0.0/8`, so `127.0.0.2` was being handed to the player as a real address. Classified by range now, with a test that `127.0.0.1.cdn.example` is still treated as the ordinary hostname it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): classify every portal-local IPv6 placeholder Codex P2 on #1364, same class as the 127.0.0.0/8 one. `http://[::]/ch/1234_` and the IPv4-mapped loopback forms slipped past the exact-name set and would have been handed to the player as real addresses. Checked how `URL` actually normalizes these rather than guessing at the spelling a portal might use: brackets are kept, `[0:0:0:0:0:0:0:1]` collapses to `[::1]`, and an IPv4-mapped address is rewritten to hex — `[::ffff:127.0.0.1]` arrives as `[::ffff:7f00:1]`. The guard now strips the brackets, matches `::1` and `::`, and decodes the mapped form by its high byte, so the whole of the mapped 127.0.0.0/8 range is covered along with the mapped unspecified address. The dotted tail is still accepted for any engine that leaves it alone. Routable hosts are unaffected, pinned by tests for `[2001:db8::1]` and `[::ffff:203.0.113.7]`. Mutation-checked: dropping `::` and the mapped-IPv4 decode fails five tests and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): normalize hostname and scheme spelling before the static verdict Two Codex P2s on #1364, both about trusting how a portal spells things. `http://localhost./ch/1234_` — a trailing dot is the DNS root and resolves identically, but `URL` keeps it for names while dropping it for IP literals (`127.0.0.1.` arrives bare, `localhost.` does not). The exact-name check read that as a remote host and would have pointed the player at its own loopback. Stripped before classifying. `HTTP://cdn.example/a.ts` — RFC 3986 makes the scheme case-insensitive. The case-sensitive tests failed SAFE, minting a link instead, but that defeats the contract for a portal that spells it this way, and one whose `create_link` cannot resolve an already-playable row would break. There were five such tests, and only one was on the new static path: the other three live in `resolveStalkerPlaybackUrl`, the create_link RESPONSE resolver, where `ffrt3 HTTP://…` failed to split its solution prefix and a query-only reply was appended to the portal base instead of to the command. That is pre-existing, but it is the same bug in the same shared normalizer, and fixing only the half this PR introduced would leave exactly the divergence this PR keeps removing. All five now go through one `hasHttpScheme()`. The response resolver had only indirect coverage, so it gains a direct spec alongside the static-path tests. Mutation-checked: reverting the dot strip and the case-insensitive scheme fails ten tests and nothing else. Also carries a docblock fix noticed on a read-through: the guard list still pointed at `PORTAL_LOCAL_HOSTNAMES` after the logic moved into `isPortalLocalHostname`, which now covers considerably more than that set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): normalize DNS root dots in the shared credential classifier Codex P2 on #1364, extending the `localhost.` fix into `isStalkerStreamCredentialSafe()`. It compared hostnames literally, so a portal on `portal.example` serving `https://portal.example./movie.mkv` classified its own stream as third-party. Wider than the download guard it was reported against: this predicate is the single rule BOTH the renderer playback-header builder and the Electron main-process fallback use to decide whether a stream may carry the mac cookie and Bearer token. A portal-owned stream spelled with the root dot was getting the credential-free profile and would 401 — pre-existing, and exactly the "only VLC works" class this contract exists to prevent. My PR added two new dependencies on the same predicate (the download static guard and the portal-owned fallback), which is how it surfaced. Both sides are normalized, so it stays symmetric, and it can only widen toward "same host" — never toward handing credentials to a different one. A test pins that `evil.portal.example.` is still rejected. Also carries the authority guard found by probing the same class myself rather than waiting for it to be reported: `http:///ch/1` has no authority and `URL` quietly reinterprets the first path segment as the host, so a malformed command reached the player as a nonsense address instead of going to the portal. `isPlayableHttpUrl()` now requires a non-empty authority. The other exotic spellings I probed were already covered — `URL` canonicalizes `127.1`, `2130706433` and `0x7f000001` to `127.0.0.1`, uppercases and expanded IPv6 normalize too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(stalker): classify the static url before authenticating Codex P2 on #1364. Both static call sites awaited the session warm-up and only then asked whether the stream needed portal credentials at all — so a movie or channel on a foreign CDN paid for a handshake whose result was immediately discarded. That is not free: non-`create_link` requests carry a 15 s timeout (`stalker.events.ts`), so a portal that is slow or offline stalled playback of a stream the CDN would have served instantly. Cold Favorites/Recently Viewed starts are exactly where this bites, since that is where the session is not warm already. Classification now runs first. Foreign host returns immediately, portal-owned still warms and still falls back to `create_link` without a usable session. Behaviour is otherwise unchanged; only the order and the wasted wait are gone. Two tests moved with it: the foreign-host case now asserts the portal is not contacted at all rather than merely not asked for a link, and the repaired-endpoint case had been written against a foreign-host command, which under the new ordering correctly never reaches the handshake it was meant to be testing — it uses a portal-owned command now. Mutation-checked: restoring warm-before-classify fails the foreign-host test alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(stalker): repoint two handshake tests at the path they claim to cover Self-audit, prompted by the previous round: the reorder exposed one test that was asserting through a path it no longer reached, so I checked the rest of that class rather than assume it was the only one. Two more had the same defect, both mine. `still returns the static url when the handshake fails` (both specs) mocked `ensureToken` to reject, but used a FOREIGN-host command. Now that classification runs before authentication, that command returns before the handshake is ever attempted — the rejection was never exercised and the test passed on the early return instead of the mechanism in its name. Worse, the foreign case is already covered by the test added alongside the reorder, so these were asserting nothing new. Both now use a portal-owned command, which is what actually reaches the handshake, and assert what a throw really produces: `ensureStalkerSession` swallows it, the verdict is false, and the row falls back to `create_link` rather than being served as a known 401. Each asserts `ensureToken` was in fact called, so neither can silently drift back into testing an early return. Docs corrected with them: the "best-effort degrades to the token-less header set" wording described behaviour the reorder removed. A foreign-host URL is now returned before any handshake, and a failed one routes to `create_link`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(stalker): make the simple-portal skip test prove portal mode Fourth test found passing through the wrong exit, from auditing all ten in the block rather than waiting to trip over another one. `skips the handshake for a simple portal` used a foreign-host command, so the classification step returned before the warm-up was reached. `ensureToken` was indeed not called — but because the host was foreign, not because the portal was simple, and the assertion could not tell those apart. The command is now portal-owned, so the skip can only come from the mode, and the test also pins the returned URL and that no request was made. Mutation-checked properly this time: removing the simple-portal early return from `ensureStalkerSession` now fails this test. Under the old command it would not have. Also records the pattern where the next person will meet it. The decision chain has several exits — no flag evidence, unresolvable command, `series` set, foreign host, unusable session — and more than one can satisfy the same assertion, so a foreign-host command silently stands in for "simple portal" or "handshake failed". Mutation testing does not catch that class: it proves a test is coupled to its target, not that it reached the mechanism it names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): key the radio fallback on flag evidence, not snapshot presence Codex P2 on #1364, and a divergence I introduced myself. `withStalkerPlayer`'s radio branch checks `hasStalkerLinkFlagEvidence(item)` before synthesizing the zero flags. `StreamResolverService` used `??`, which only falls back when the snapshot is absent entirely. A radio Favorite or Recent row persisted before the flags were carried HAS a snapshot — the old whitelist just stripped the flags out of it — so the `??` selected that flagless object, the helper found no evidence, and the collection route began minting for exactly the rows that used to play directly. That breaks portals whose radio `create_link` is unsupported, which is the case the radio exception exists for. The two paths now apply the identical rule. The divergence came from fixing them in different rounds and is precisely the class this PR keeps closing, so the comment on each side now points at the other. The existing radio test carries no `stalkerItem` at all, so it exercises the missing-snapshot arm and stayed green throughout — the same "passes through a different exit" pattern documented in the section above. The new test supplies a present-but-flagless snapshot. Mutation-checked: restoring the presence check fails it alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): treat every reserved localhost name as portal-local Codex P2 on #1364, the fourth in this class. RFC 6761 §6.3 reserves `localhost` AND every name ending in `.localhost` for the loopback interface, and resolvers honour it — so `http://stream.localhost/ch/1234_` reached the player's own machine instead of being sent to the portal to resolve. Closed the class rather than adding one more name: the suffix is matched, and `localhost.localdomain` goes in with it as the conventional `/etc/hosts` alias for 127.0.0.1 on most Linux systems. Together with the earlier rounds the predicate now covers `localhost` and `*.localhost`, `localhost.localdomain`, `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, the IPv4-mapped forms `URL` rewrites to hex, and a terminal DNS root dot on any of them. Only the suffix is reserved, so the guard must not over-match: tests pin that `localhost.cdn.example` and `notlocalhost` remain ordinary routable names and keep playing statically. Mutation-checked: dropping the suffix rule and the localdomain alias fails four tests and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d44948f2fa |
chore(deps): bump angularx-qrcode from 21.0.4 to 21.0.5 (#1351)
Bumps [angularx-qrcode](https://github.com/Cordobo/angularx-qrcode) from 21.0.4 to 21.0.5. - [Release notes](https://github.com/Cordobo/angularx-qrcode/releases) - [Commits](https://github.com/Cordobo/angularx-qrcode/compare/v21.0.4...v21.0.5) --- updated-dependencies: - dependency-name: angularx-qrcode dependency-version: 21.0.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
96facd6f49 |
feat(downloads): queue season episode downloads (#1357)
* docs(downloads): specify season queueing * docs(downloads): plan season queue implementation * feat(downloads): define episode queue identity * fix(downloads): align episode identity contract * feat(downloads): coordinate season queue submissions * fix(downloads): keep queue coordination provider neutral * fix(downloads): reconcile legacy episode identities * fix(downloads): fail closed on invalid stored coordinates * refactor(downloads): adapt Xtream episode requests * fix(downloads): use canonical Stalker episode ids * test(downloads): cover Stalker adapter reactivity * feat(downloads): add selected season queue action * refactor(downloads): extract season download presenter * feat(downloads): localize season queue feedback * test(downloads): cover series batch queue flow * test(downloads): harden series queue fixtures * docs(downloads): describe season queueing * docs(downloads): clarify season queue IPC contract * fix(downloads): isolate season header build warnings * fix(downloads): label season view toggles * fix(downloads): preserve Xtream episode headers * fix(downloads): fail closed on stale episode state * fix(downloads): align renderer queue safeguards * fix(downloads): block ambiguous episode actions * fix(downloads): accept nullable legacy coordinates * fix(downloads): preserve scoped episode ownership * fix(downloads): probe restored files asynchronously * fix(downloads): bound restored file probes * fix(downloads): release timed out file probes * fix(downloads): bound file probe callers * fix(downloads): refresh stable season skips * fix(downloads): fail closed before provider prep * fix(downloads): preserve retained partial ownership * fix(downloads): reconcile partial cleanup completion * fix(downloads): await authoritative list refresh * fix(downloads): coalesce list refreshes * fix(downloads): preserve specials season identity * fix(stalker): preserve specials season mapping * fix(downloads): distinguish missing Xtream seasons |
||
|
|
d3cc18dc72 |
fix(stalker): anchor auth-failure body detection and share it across transports (#1358)
* fix(stalker): anchor auth-failure body detection and share it across transports The middleware's auth failures are bare plain-text bodies, but they were matched by substring under a 200-character cap. A short page from something in FRONT of the portal — a proxy or WAF answering `<html><body>Access denied</body></html>` (38 characters) — therefore read as the portal refusing authorization, which drives probe classification and the lazy repair trigger: a portal that never answered at all could be re-probed and reclassified. The body match is now anchored to the whole reply, with the stock server's optional trailing counter still accepted. The structured `js.error`/`js.msg` fields keep the wider phrase set, since a panel fills those in deliberately. The detection also moves to `@iptvnator/shared/interfaces`. It had to live somewhere both transports can reach: the Electron main process is where these bodies actually arrive and cannot import a renderer library, which is the same reason the identity and URL builders were centralised there. `stalker-portal-discovery.utils.ts` re-exports it, so no call site changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): drop the unanchored auth-failure sweep in the session service Anchoring the body detector closed one door and left another open. The session service still stringified the whole response and matched an UNANCHORED `authorization failed`, so a short page from something in FRONT of the portal retired the token, retried, and threw `Authorization failed after retry` — whose own message then matched the repair trigger's wide phrase set and re-probed a portal that had refused nothing. The shared detector already covers every real shape, including the `js.error`/`js.msg` envelopes the sweep was also catching, so removing it costs no coverage. Regression goes through `makeAuthenticatedRequest` rather than the primitive, since that is where the chain actually ran. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c741815b97 |
fix(build): include shared UI stylesheets in Nx cache inputs (#1360)
* fix(build): include shared UI stylesheets in Nx cache inputs `libs/ui/styles` held shared SCSS partials but had no `project.json`, so its files belonged to no Nx project and were absent from every task hash. Editing a partial and running `pnpm nx build web` reported 4 of 4 tasks cached and shipped the previous CSS — a silent wrong build rather than a failure. Nx derives its project graph from TypeScript imports only, so a relative Sass `@use` that crosses a project root creates no edge. Verified directly: after adding the project but before declaring anything, `ui-styles` still had zero dependents in the graph. Make it the `ui-styles` project (no targets — it exists to be hashed) and declare `implicitDependencies` on the 8 consumers. Chosen over adding the path to `sharedGlobals`, which would put shared styles into every project's hash and make a one-line SCSS tweak mark the whole workspace affected. A styles edit now marks 15 projects affected and leaves electron-backend, website, the mock servers and the shared libs alone. `libs/ui/styles` was the only projectless directory holding files under `libs/` or `apps/`. Add `pnpm run styles:inputs:validate` to keep it closed: it resolves every relative stylesheet import against Nx's real project graph and fails when one escapes the input closure of a build that compiles it, naming the project to declare. It exits 1 with 21 diagnostics on the pre-fix tree. Imports of `apps/web/src/nav-list.scss` are deliberately accepted — `web` already hashes that file, and a lib -> app edge would make the graph cyclic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(build): spawn git without a shell in the stylesheet check `execSync("git ls-files '*.scss'")` runs through `cmd.exe` on Windows, where single quotes are literal characters rather than quoting. Git received the pathspec with the quotes intact, matched nothing and exited 0, so `styles:inputs:validate` reported success after checking zero stylesheets — silently disabling the check for Windows developers while staying green. Spawn with `execFileSync` so no shell is involved and git expands its own pathspec; verified to return the identical 133 files. Both this and the eslint glob trap next to it in the docs report success while covering nothing, so also make an empty scan fail rather than pass: the workspace always contains SCSS, and a listing that returns none means the scan broke. Reported by Codex review on #1360. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(styles): move nav-list partial into ui-styles (#1361) * fix(build): count every target of a comma-separated Sass @import `@import` is the only rule that takes a list, and the scan read just its first target. A later entry crossing an Nx project boundary escaped the cache key while the check still reported success — the same silent-pass failure the tool exists to prevent. Parse every target of an `@import` list. The obvious "read all quoted strings" fix trades one silent gap for a phantom one, so the rule decides: `@use`/`@forward` load exactly one module and a quoted string after it is `with (...)` configuration, and `url(...)` stays a plain CSS import the browser resolves at runtime. Neither is a module Sass compiles. The workspace has no relative `@import` at all today, so the scan still finds the same 42 imports across 133 files; this closes the gap before someone writes one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0010dd7351 |
feat(portals): rework the VOD sources popup and action row (#1359)
* feat(portals): rework the VOD sources popup and action row The Sources popup now anchors above its button and always fits on screen: the overlay caps it to the space beside the chip, only the source list scrolls so the header, search, filter chips and footer stay visible, and it flips below the button when the space above is too small. Filter chips (All / Available / HD+ / language) compose with the playlist search, and "Available" runs check-all itself when nothing has been checked yet. Expanded copy rows no longer repeat the playlist domain: each copy shows its parsed language chip, the provider's raw stream title, and only the tags that differ from the parent copy. Availability checks run at most four at a time and settled verdicts are remembered per movie and source for ten minutes, so reopening a movie no longer re-contacts every foreign portal. Favorites and Download become icon-only buttons with real state: a filled heart when favorited, and a download icon that turns into a progress ring and then a checkmark that reveals the finished file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(portals): match the movie detail's icon-only favorite button The Xtream movie detail's favorite control is now an icon-only button, so the shared "add this detail to favorites" helpers no longer found it by class. They are used against series and Stalker details too, which still render the labeled variant, so they now select by accessible name — the icon button carries the same label in aria-label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): keep provider playback for a downloaded movie Once a movie is downloaded the primary button plays the local file, and the sources popover only exists when another playlist carries the same film. The icon-only rework left those as the only two paths, so a downloaded movie in a single-playlist library had no way at all to stream the provider's copy — the labeled action that used to do it was gone. Restores it as an icon button beside the downloaded checkmark, under the same condition the old one used. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
83f6a270a5 |
fix(dashboard): give the hero the identity the detail view matched with (#1362)
The dashboard hero showed no backdrop for items whose detail page had one. Two independent causes, both about identity rather than the matching gate. Stalker items never reach the `content` table, so the xtream back-fill of `content.backdrop_url` has no equivalent for them — but the enriched backdrop is already sitting in the stored playlist entry (`info.tmdb_backdrop`). The activity mappers now surface it as `backdrop_url`, where the hero already looks first. The hero's TMDB lookup ran on the display title alone, while the detail view searched with the original title and the release year. Without a year `pickConfidentMatch` requires a single exact title match, which common titles never satisfy, and the miss lands in the negative cache under a lookup key the detail view's hit can never be found at. The query is now built from the same fields (`extractStalkerItemTmdbHints`), and the resolved `tmdb_id` short-circuits the search entirely. A 'movie' verdict retries as 'tv' without the id — 'movie' is the answer every row falls back to, and an id is valid only for its own media type. A 'tv' verdict, reached only on positive series evidence, gets no retry back to 'movie'. Also removes `buildStalkerRecentItems`, a dead duplicate of the mapper the dashboard actually uses. |
||
|
|
b92503feae |
feat(stalker): endpoint probing + behavior-based portal mode with lazy repair (#1344)
* feat(stalker): endpoint probing + behavior-based portal mode with lazy repair Replace the URL-shape guess behind isFullStalkerPortal with real endpoint discovery: at import, probe portal.php -> server/load.php -> stalker_portal/server/load.php (the pasted .php endpoint first) and classify the portal by observed behavior — a token-less itv/get_genres answering data proves a token-free panel, the middleware's plain-text auth failure proves the endpoint enforces the token, confirmed by the real handshake + get_profile. The proven endpoint and mode are persisted. The three diverging portal-mode predicates (import, session service, legacy migration) collapse into one shared helper in @iptvnator/shared/interfaces; executeStalkerRequest becomes the single request choke point (search and the collection stream resolver fold in), and the production-dead makeStalkerRequest copy is removed. Existing misclassified playlists repair themselves lazily: only after a request actually fails with the plain-text auth bodies, HTTP 404, or a terminal handshake error, at most once per playlist per session, and only a configuration discovery proved to answer is persisted — via a minimal portalUrl/isFullStalkerPortal patch, so favorites, recents and playback positions survive. Working reseller panels are never probed or rewritten; there is deliberately no eager one-shot migration, because tolerant portal.php panels cannot be told apart from misclassified canonical portals without probing. The Electron handler now embeds the HTTP status code in the error message (ipcRenderer.invoke strips custom properties from rejections), and probe requests carry silent:true so expected 404s do not toast error snackbars. The stalker mock gains a portal.php-less /ministra host so e2e can prove the 404 fallthrough end to end. Fixes #850, #686, #755. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): sync watchdog, PWA proxy errors and cmd resolution with lazy repair Review round 1 (Greptile P1, Codex P1/P2): - A successful repair now re-syncs the ACTIVE watchdog playlist via the new StalkerSessionService.refreshActiveWatchdogPlaylist(): a simple-to-full repair starts the required keepalive mid-session, full-to-simple stops it, and an endpoint change repoints the pings instead of leaving them on the activation-time snapshot. - PwaService.forwardStalkerRequest surfaces the web-backend proxy's normalized { message, status } no-payload envelope as an HTTP error carrying the status, so endpoint discovery and the lazy repair can classify upstream 404s in the PWA too (previously payload unwrapping returned undefined and dead endpoints were unrepairable there). Probe requests pass silent:true and skip the error snackbar. - fetchStalkerPlaybackLink and the collection StreamResolverService re-apply the repair override AFTER the request, so a relative create_link reply resolves against the endpoint that actually answered (the resolver keeps the /stalker_portal path segment as base, so this matters beyond origin). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): parse candidate URLs and tie repair overrides to their source config Review round 2 (Codex P2 x2): - Endpoint candidates are now derived from the parsed origin + pathname: a pasted URL carrying a query or fragment (host/c?key=value) no longer gets /portal.php bolted onto the query, which made every probe hit /c and persisted the non-API URL. - A repair override is tied to the failing configuration it replaced. Playlists carrying anything else (the user edited the portal URL or mode through the playlist dialog) drop the override and re-arm the once-per-session probe latch, so edited metadata is used verbatim and may repair again if it fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): auth-gated probes, normalized offline fallback, mock docs sync Review round 3 (Codex P1 x2, P2): - A probe answered with HTTP 401/403 now classifies the endpoint as auth-required and attempts the real handshake instead of skipping the candidate: non-standard middlewares answer 401 where the stock server sends HTTP 200 + plain text, and such portals authenticated fine before discovery existed. - The unreachable-host import fallback normalizes the pasted URL (origin + pathname) before the legacy /c -> portal.php rewrite, so a query or fragment can no longer make it persist the browser page URL - a 200 HTML answer from /c is not a repair trigger, which would have left the playlist empty for good. - The stalker mock-server README and architecture doc now describe behavior-based discovery and the /ministra host instead of the retired URL-shape rule and its "known inconsistency" note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): recognize JSON auth failures and guard repairs against mid-probe edits Review round 4 (Codex P1 + P2): - isStalkerAuthFailureResponse() recognizes the JSON envelope some panels answer instead of the plain-text body ({js:{error:"Authorization failed"}} / {js:{msg:...}}). Probe classification treats it as auth-required instead of token-free data, and the lazy-repair trigger fires on it at runtime — previously such a portal was persisted simple with no repair path at all. - A repair is committed only after re-reading the persisted row and verifying it still carries the configuration that failed: a user who edits the portal URL (or deletes the playlist) during the multi-second probe now wins over the in-flight repair result for the old URL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): probe past endpoint 5xx, sibling fallbacks, identity-aware repair guard Review round 5 (Codex P2 x3): - A probe that fails with a RESOLVABLE HTTP status keeps discovery going: a broken /portal.php handler answering 500 must not hide a healthy sibling endpoint. Only status-less failures (true network level) stop the loop. The Electron handler now gives real HTTP 5xx responses the same parseable "HTTP Error <code>" message shape as 4xx, so the renderer can tell them apart from ECONNREFUSED/timeouts after ipcRenderer strips the object shape. - Standard fallback candidates for a nonstandard pasted endpoint (.../cp/api.php) derive from its DIRECTORY, so recovery probes hit /cp/portal.php instead of /cp/api.php/portal.php. - The repair's row re-verification also compares the MAC and all Stalker identity fields: a probe authenticated as the old identity must not install its token/watchdog or persist onto a row whose credentials were edited mid-probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): reactivation-safe watchdog, wider JSON auth phrases, per-config probe latch Review round 6 (Greptile 4/5 concern + Codex P1/P2): - setCurrentPlaylist applies the repair override before feeding the watchdog and store state: re-activating the portal route with the stale NgRx meta no longer stops or repoints the repaired keepalive back to the broken configuration. - The structured js.error/js.msg fields accept the full phrase set the session service recognizes (Invalid token, Auth failed, bare unauthorized/authorization) — panels answering those envelopes were still classified token-free. Plain-text body matching stays narrow on purpose (HTML false positives). - The once-per-session probe latch is keyed by the SOURCE configuration fingerprint (endpoint, mode, MAC, identity) instead of the playlist id: a repair discarded because of a mid-probe edit no longer blocks the edited configuration from repairing, while stale snapshots of an already-probed configuration still cannot loop the probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): identity-aware override invalidation and timeout-tolerant probing Review round 7 (Greptile P1 + Codex P2): - The repair override records the identity fingerprint the probe authenticated as. Editing the MAC or any Stalker identity field afterwards drops the override, the per-config probe latch AND the cached token, so requests and watchdog pings never pair the edited identity with a session negotiated for the previous one. - A status-less probe failure that is a TIMEOUT (renderer budget, axios request timeout, ETIMEDOUT) continues to the next candidate — one hanging handler must not hide healthy siblings; connection-level failures (refused, unresolvable host) still stop discovery, so dead hosts keep failing fast. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): watchdog pings authenticate as the persisted row Review round 8 (Greptile 4/5 concern): The watchdog held its activation-time playlist snapshot for the whole session, so portal metadata edited (or repaired) mid-session kept the keepalive authenticating as the previous identity/endpoint — its pings could keep the old session alive and repopulate the playlist-scoped token cache with a token for the pre-edit identity. Each ping now resolves the playlist from the persisted row first (the single source of truth), falling back to the snapshot only when the store cannot be read, and refreshes the snapshot on every successful read. Any edit — identity, endpoint or mode — reaches the keepalive within one ping cycle; a row now marked simple (or deleted) stops the watchdog. The in-flight guard is claimed before the row read so overlapping pings cannot double-fire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): identity-tagged tokens, watchdog override overlay, retire-on-failure Review round 9 (Greptile 4/5 concern + Codex P2): - The session token cache is tagged with the identity fingerprint (MAC + all Stalker identity fields) the session was negotiated for; ensureToken re-authenticates instead of handing an edited identity the previous token. The fingerprint helper is shared (stalker-identity.utils) with the repair layer's override/latch checks. - Watchdog pings overlay the repair layer's in-session override on the resolved row (registered decorator, no import cycle): a simple-to-full repair whose persistence is pending or failed no longer reads the stale row and stops the freshly started keepalive. - makeAuthenticatedRequest retires a failed token even on the no-retry path (watchdog pings), so a dead session is never handed to the next caller. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): pending authentications are identity-scoped Review round 10 (Greptile 4/5 concern): pendingAuth entries carry the identity fingerprint they authenticate as. A request for an edited identity no longer adopts an in-flight result negotiated for the previous identity: it waits the old authentication out (a competing handshake would strand it with a dead token on strict portals) and then negotiates its own session. This was the last id-only-keyed session structure — override, probe latch, token cache, watchdog snapshot and pending auth are now all identity-aware. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): atomic repair persistence, full probe history, normalized offline classify Review round 11 (Codex P2 x3 + P1 docs): - The repair's row verification and patch now run ATOMICALLY inside the per-playlist write queue via the new PlaylistsService.transformPlaylistMeta(): a user edit that is queued but not yet committed wins over the repair — the transform sees the edited row and aborts instead of overwriting it. Write failures after a successful verification keep the session-only override, read failures discard the repair. - The per-playlist probe latch keeps EVERY attempted source fingerprint, so alternating edits (A -> B -> A) cannot evict a fingerprint and let stale snapshots re-run discovery. - The unreachable-host import fallback classifies the normalized origin+pathname, so a query merely mentioning /server/load.php cannot make a panel URL look canonical and abort the offline import. - docs/architecture/stalker-portal.md documents the actual probe sequencing: any resolvable HTTP status (incl. 5xx) and timeouts continue, 401/403 classify as auth-required, only connection-level failures abort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): collision-proof session fingerprints Review round 12 (Greptile P1): identity values are unrestricted strings, so the delimiter-joined fingerprint could alias distinct identity tuples (serial "a|b" + empty device vs serial "a" + device "b") and bypass the identity invalidation. Both the identity fingerprint and the repair source fingerprint are JSON-encoded now; regression test pins the exact aliasing pair. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): preserve URL authority in normalization; document per-config latch Review round 13 (Codex P1 docs + P2): - normalizeStalkerPortalInputUrl mutates the parsed URL (clear query/ fragment, trim pathname) instead of rebuilding from origin, and the candidate builder swaps only the path — file: URLs (origin "null") no longer make the builder throw, and basic-auth credentials are not silently dropped before probing. - The canonical docs and the repair service JSDoc now describe the actual loop guard: at most one probe per SOURCE CONFIGURATION (endpoint, mode, MAC, identity) per playlist per session, not once per playlist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): HTTP 401/403 failures trigger the lazy repair Review round 14 (Codex P1): discovery classifies 401/403 endpoints as auth-required, but the repair trigger accepted only 404 — a legacy playlist misclassified token-free against an HTTP-auth-gated middleware could never reach discovery and stayed unusable. 401/403 now qualify; endpoint-specific 5xx still do not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): re-enter repair for edited configurations after a pending probe Review round 15 (Codex P2): a request carrying an edited configuration that raced an in-flight probe only awaited it and inherited its outcome — the edited fingerprint stayed unattempted and the first request failed without triggering its own discovery. repairPortal now re-enters after awaiting the pending probe, so the per-config latch decides: already attempted -> reapply, never attempted -> own probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): probe history remembers outcomes so restored configs repair again Review round 16 (Greptile P1): the per-config latch kept A's fingerprint after an edit to B dropped A's override, so restoring A left it latched with nothing to reapply — broken until restart. The history now stores each probe's OUTCOME (override or null): a restored configuration reinstalls its remembered repair without a second discovery, and the anti-ping-pong property (A<->B alternation never re-runs discovery from stale snapshots) is preserved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlist): serialize deletion behind the per-playlist write queue Review round 17 (Codex P2): deletePlaylist bypassed serializePlaylistWrite, so a queued mutation (e.g. the Stalker portal repair's conditional transform) finishing after an unserialized delete could upsert the row back and resurrect the playlist. Deletion now runs through the same queue: queued writes commit first, the delete lands last, and a transform enqueued after the delete reads a missing row and aborts. Regression test pins the write-then-delete ordering. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): reinstalled repairs re-sync the watchdog like fresh ones Review round 18 (Greptile P1): the restored-configuration branch reinstalled the remembered override without the watchdog refresh the fresh-repair path performs — if the intermediate edit stopped the keepalive, the restored full-portal session recovered requests but never its pings. The reinstall now calls refreshActiveWatchdogPlaylist with the override applied, symmetric with a fresh repair. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): discarded probes retry once their configuration is restored Review round 19 (Greptile P1): the pre-probe history reservation survived the row-mismatch discard, so restoring the original configuration hit the latch with nothing to reinstall — lazy repair stayed disabled for the session. Probe records are now explicit (override / no-change / discarded): a discarded configuration probes again once one cheap row read confirms the row was RESTORED to it, while stale snapshots of it stay declined without a discovery run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): IPC-safe transport errors, repairable profile path, nested base paths Review round 20 (Codex P2 x4): - The Electron handler throws a real Error for axios failures without a response: Electron serializes rejections via toString(), so a plain object arrived as "[object Object]" and discovery could not tell a timeout (keep probing) from a dead host (stop). - isAuthorizationError parses HTTP 401/403 out of the IPC-wrapped message, so an expired-token 403 retires the token and re-authenticates instead of surfacing as a plain failure. - The account-info full-profile path (which bypasses executeStalkerRequest) routes repair-trigger failures through StalkerPortalRepairService and retries with the repaired playlist, so opening the dialog can fix a stale endpoint. - resolveStalkerPlaybackUrl derives the installation base from the endpoint's API suffix instead of a fixed stalker_portal|c|portal allowlist: relative create_link replies now resolve correctly under arbitrary discovered installations such as /cp/server/load.php. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): strict probe data shape, mode-aware profile retry, docs API name Review round 21 (Codex P1 docs + P2 x2): - Probe classification requires the real get_genres shape (array, or a {data: []} envelope without an error) instead of a bare `js` key: a 200 error envelope ({js:{error:"Unknown action"}}, {js:false}) no longer ends discovery on a broken candidate and persists an empty catalog. - After a repair that flips the portal to simple mode, the account-info retry re-enters the mode routing and uses get_main_info instead of handshaking against a token-free panel again. - docs/architecture/stalker-portal.md names transformPlaylistMeta and its atomic source-check invariant (plus the serialized deletion) rather than the race-prone updatePlaylistMeta. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): account dialog re-routes after a simple-to-full repair Review round 22 (Codex P2): fetchViaMainInfo runs through executeStalkerRequest, whose lazy repair retries the SAME action, so a repair proving the portal is actually full left the dialog calling get_main_info — canonical installations publish subscription details only through handshake + get_profile, leaving the dialog empty. The routing is now symmetric with the full-to-simple case: an empty main-info result whose repair flipped the mode re-enters the profile flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): row-gate override reinstall; document mode-based account routing Review round 23 (Codex P2 + P1 docs): - Reinstalling a remembered override now requires the persisted row to actually carry that configuration again. A stale request for A while the row holds an unrelated C no longer resurrects A's override, which would retry against B and repoint the active watchdog away from C. (The edit-back-to-A case stays as documented: there the row IS A.) - docs/architecture/stalker-portal.md and CLAUDE.md describe account-info routing by the observed portal MODE instead of the endpoint shape — a token-enforcing portal.php is a full portal now — and note the mode-change re-routing in both directions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): share the auth-failure predicate; prefer profile over partial main-info Review round 24 (Codex P1 + P2): - isAuthorizationError now reuses isStalkerAuthFailureResponse, so the phrases discovery and the lazy repair already classify as auth failures (Access denied., Unauthorized request., and their JSON envelopes) also retire the session token. Previously a full portal expiring with either phrase kept its dead token: the repair rediscovered the same endpoint/mode, recorded no-change, and every later request stayed broken. - After a simple-to-full repair, even a PARTIAL get_main_info answer no longer wins over the profile flow — expiry and tariff live only behind handshake + get_profile. The partial facts are kept only if the profile path itself publishes nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): keep a literal c installation directory in candidate derivation Review round 25 (Codex P2): the /c landing-page rewrite ran after the endpoint file was stripped, so `/tenant/c/portal.php` collapsed to `/tenant` and the sibling probes went one level too high, rejecting a valid portal whose installation directory is literally named `c`. The rewrite now applies only when the pathname itself ends in `/c` (no endpoint file); pasted endpoints strip only the file part. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): route rejected post-repair main-info retries to the profile flow Review round 26 (Codex P2): a simple-to-full repair during fetchViaMainInfo makes executeStalkerRequest retry the same action against the repaired full portal, and installations that do not implement get_main_info answer 404 — the rejection escaped before the repaired-mode check, so the dialog failed instead of switching to get_profile. The rejection is captured and reaches the same check; without a mode change it is rethrown unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): full predicate for wrapped denials; record the removed store prop Review round 27 (Codex P2 + P1 docs): - The repair trigger applies the shared auth-failure predicate to the error MESSAGE too, so authentication's wrapped structured denials (Error('Profile error: Access denied.')) reach the repair instead of bypassing it and leaving a healthy sibling endpoint unprobed. - docs/architecture/stalker-store-api-baseline.md records makeStalkerRequest as removed, with the reason it gets no facade alias: it was production-dead and held a fourth private copy of the portal-mode branch that the shared predicate exists to prevent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): complete auth predicate for wrapped error messages Review round 28 (Codex P2): the plain-text BODY matcher deliberately knows only the three middleware phrases, so passing an error message through it let authenticate()'s wrapped denials — Error('Profile error: Invalid token') / 'Auth failed' — bypass both the repair trigger and the session auth predicate. A dedicated isStalkerAuthFailureMessage() applies the wide phrase set to controlled error strings, while arbitrary portal bodies keep the narrow matcher that cannot false-positive on HTML pages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): reject denied profiles during confirmation; document all repair triggers Review round 29 (Codex P2 + P1 docs): - Full-portal confirmation validates the get_profile envelope with the shared structured predicate: a handshake can hand out a token whose profile still answers {js:{error:"Invalid token"}}, and authenticate() inspects only msg/block_msg — discovery would have persisted an unusable endpoint and stopped before the healthy sibling. authenticate() now returns the raw profile response for that check. - The canonical lazy-repair contract lists the complete trigger set: the plain-text bodies AND their JSON envelopes, HTTP 404, HTTP 401/403, and terminal handshake/profile errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
65f81b7110 |
fix(pwa): bring the Stalker transport to parity with Electron (#1348)
* fix(pwa): bring the Stalker transport to parity with Electron The self-hosted PWA's /stalker proxy now derives its portal requests from the same shared identity and URL builders as the Electron main process: MAG User-Agent/X-User-Agent, full STB cookie (mac + stb_lang + timezone + serial-derived __cfduid), SN header, JsHttpRequest=1-xml defaulting, and the sn-only-on-get_profile rule. macAddress/token/serialNumber are control params consumed into headers and never echoed into the portal's query string (handshake keeps its candidate token — protocol content). The stalker-mock-server /stalker route mirrors the new contract through the same shared builder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(stalker): forward the full identity header set in the mock /stalker mirror Greptile review: the synthetic portal request kept only the cookie and Authorization from the generated identity, so mock handlers could never validate the SN/MAG-UA/Accept/Language/Connection headers the real proxy sends. Forward the complete set, lowercased the way Express normalizes incoming headers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a6186a46c8 |
feat(dashboard): subscription-expiry warning badge on source cards (#1342)
* feat(dashboard): warn on source cards when a portal subscription expires soon Dashboard source cards now carry a passive expiry chip: amber "Expires in N d" within 7 days of the subscription lapsing, error-toned "Expired" once it has. Account details stay behind the card's ⋮ → Account info. Xtream expirations ride on the playlist switcher's cached PortalStatusService check — checkPortalStatusDetails() now surfaces the parsed exp_date from the same round-trip, so the dashboard adds no extra portal calls. Stalker expirations come from the stalkerAccountInfo snapshot persisted at import; it lives in the playlist payload (meta rows carry payload: null), so each Stalker source costs one full-playlist read memoized on the playlist's update timestamp. New i18n keys added to all 19 locales via the i18n-fill merger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dashboard): address review feedback on expiry badges - Recompute expiry badges on a minute tick so a dashboard left open crosses day-countdown and expiration boundaries (Greptile P1 / Codex P2) - Gate the expiry refresh on the recent-sources rail setting so hidden rails cost no portal checks or playlist reads (Codex P2) - Move chip colors to theme-aware tokens in m3-theme.scss; both themes now hold >= 4.5:1 small-text contrast (light warn 5.3:1, light expired 5.4:1, dark warn 7.4:1, dark expired 6.0:1) (Codex P2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dashboard): make expiry-badge labels depend on the language signal sourceCards previously relied on getPlaylistProvider's indirect language read; the translate.instant() labels now read languageTick explicitly (mirroring trendingCards). Also shift the minute tick by one so the interval's first 0 differs from initialValue — the signal equality check was swallowing the first heartbeat, delaying it to two minutes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8f9e78ff90 |
fix(workspace): report a local phase while reading the cached Xtream catalog (#1345)
* fix(workspace): report a local phase while reading the cached Xtream catalog Since #1311 the sync overlay is shown for the whole import session, but the DB-first read path never emitted an import phase, so switching to an already-imported Xtream playlist showed a bare "Syncing playlist" card with no badge or description. The Electron data source now reports a 'loading-cached' phase (local-library badge, its own label and detail text) before reading categories/content from SQLite, and the PWA data source reports the remote loading phases on API fetches it previously swallowed. Adds the two new i18n keys to en.json and all 18 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): keep the loading-cached phase from marking a real import The store's onPhaseChange callbacks set isImporting unconditionally, and the initialization error path gates import-cache cleanup on that flag — so a cancelled or failed warm SQLite read would have wiped the healthy cached catalog and forced a full provider redownload. The shared publishImportPhase helper now publishes 'loading-cached' as a presentation-only phase; any remote/save phase still marks the import as running. Adds regression specs (verified to fail against the previous behavior) in a dedicated spec file to stay under the test max-lines limit. Addresses Codex P1 review feedback on #1345. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): scope cancelled-import cleanup to types with remote work A session-wide isImporting flag meant that once any content type contacted the provider, cancelling during a later cache-only read cleared the healthy cached catalogs of every not-yet-completed type. Cleanup now consults a per-session set of types that actually performed remote or save work (populated from typed phase callbacks and save-content events), so cache-only types keep their catalogs on cancellation while genuinely partial types are still cleared. Mixed-scenario regression spec added (mutation-verified against the unguarded behavior). Addresses the second Codex P1 on #1345. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
011f322807 |
ci(nx): enforce synchronized dependency updates (#1343)
* ci(nx): enforce lockstep dependency versions * ci(deps): group Nx updates explicitly * docs(nx): document coordinated dependency updates * fix(nx): validate peer dependency versions * fix(nx): validate duplicate root declarations |
||
|
|
dac0dfdb6c | docs(website): add unofficial sites post cover | ||
|
|
347e3cd2f8 | docs(website): define unofficial sites cover design | ||
|
|
d8b07ecd5b | fix(website): refresh landing screenshots | ||
|
|
fc7f23b229 |
feat(playback): forward portal Cookie/Authorization to built-in players (#1335)
* feat(playback): forward portal Cookie/Authorization to built-in players The web players (HTML5/hls.js, Video.js, ArtPlayer, Shaka) could only ever receive User-Agent/Referer/Origin, so any Stalker stream gated on the portal session cookie or Bearer token played exclusively in external MPV/VLC — the long-running "only VLC works" cluster (#849, #910, #732). - request-header-overrides.service: the scoped override now carries Cookie and Authorization, attached only to requests on the exact stream origin, in-memory only, dropped on replace/clear. Unscoped (playlist-level) calls drop credentials fail-closed; control characters in header values are rejected. Chosen over session.cookies.set(): jar cookies attach only to credentialed requests, which would force withCredentials into every engine and break against the Access-Control-Allow-Origin:* IPTV panels send, and jar scoping is port-blind. - WebPlayerViewComponent is now the single owner of the scoped override for every built-in player: it extracts the full header set from the resolved playback, configures the override BEFORE handing the source over (players render only once the source exists), and clears the scoped layer on destroy. HtmlVideoPlayerComponent's own three-header call is removed — it would overwrite the credentialed override. - Stalker VOD, series episodes and radio now build the same portal header set ITV already had (they previously carried no portal headers at all); same-origin playback sends the real User-Agent alongside X-User-Agent. - Stream classification is host-based via one shared predicate (isStalkerStreamCredentialSafe): same-host port changes and scheme upgrades keep the portal profile (the #1158 class), a foreign host or https->http downgrade keeps the credential-free KSPlayer profile. The main-process fallback context uses the same predicate so isStalkerDirectStreamProfile can no longer discard renderer headers. - setUserAgent bridge gains an optional credentials parameter; preload, ipcMain handler and ElectronBridgeApi updated together. - stalker-mock-server: gated-stream scenario (MAC 00:1A:79:00:00:09) whose create_link returns a local /stream/gated/video.mp4 that 403s without the mac cookie + current Bearer token; new Electron e2e proves a built-in player actually plays it (and that the gate refuses bare requests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): apply header override to Stalker radio, redact mock cookie log Address Codex review feedback on #1335: - The radio branch of the Stalker live layout renders the dedicated audio player, never WebPlayerViewComponent, so the resolved portal headers were built but never applied — an auth-gated radio stream still 403'd. The override sync is extracted into ElectronStreamHeadersService (single owner of the scoped override slot, with clear-only-while-owning semantics so a destroyed consumer cannot wipe a newer consumer's override), applied by WebPlayerViewComponent for video players and by the radio branch before the audio element gets its URL. The service feature-detects the bridge method so partial bridges behave like the PWA instead of throwing. - The gated-stream mock no longer logs the raw Cookie header on 403 — presence only, matching the Authorization logging. - The gated scenario now serves an audio fixture for radio create_link and the Electron e2e covers the radio path end-to-end (bare request 403s, built-in audio player advances past the gate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): claim radio header ownership before awaiting the IPC Codex round-2 P2: leaving the radio route while the header IPC was still in flight left the portal cookie/token installed — ngOnDestroy saw a null scope URL (it was recorded only after the await) and could not clear the override. Ownership is now claimed synchronously before awaiting, destroy invalidates the pending playback continuation, and the apply's stillCurrent verdict is honored. Regression test covers destroy-during-pending-IPC. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): carry portal headers into collection playback Codex round-3 P1: Stalker channels opened from Favorites/Recently Viewed resolved through StreamResolverService.resolveStalker(), which returned no portal headers — the video path handed the header owner an empty set and collection radio bypassed it entirely, so auth-gated streams still 403'd from collections. - resolveStalker() now builds the same profile as the live layout via the shared classifier: portal-owned streams get mac cookie/Bearer token/MAG UA/portal Origin+Referer, foreign hosts keep the credential-free KSPlayer profile (both create_link results and direct radio URLs). - UnifiedLiveTabComponent applies the scoped override for radio before the audio element gets its URL (ownership claimed before awaiting the IPC, round-2 lesson), and clears it on close and destroy. - Regression tests: resolver header profiles for portal-host and foreign streams; unified tab radio apply-then-clear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): release the radio override when a new selection mounts no player Codex round-4 P2: after radio installed its credentials, selecting an item that never mounts a player surface (external video playback, failed resolution) left the old Cookie/Authorization installed — no WebPlayerViewComponent, close, or destroy cleanup runs on that path. Both radio hosts (unified collection tab and the Stalker live layout, which has the identical hole) now release the previously owned radio scope at the start of every new selection; the slot-ownership semantics keep this a no-op when another playback already owns the override. Regression test in the live-layout spec pins the failed-selection path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(playback): state the exact override release points Codex round-5 P2 flagged that the media 'ended' event does not clear the scoped override while the player stays mounted. That is deliberate, not a gap: a mounted player still owns the session — replay or a seek into an unbuffered range must keep working against a gated stream, and clearing on 'ended' would 403 exactly the streams this PR fixes. The credentials only ever travel to the exact origin that issued them, and every dismount path (channel/source change, player close/destroy, radio close, playerless selection) releases them. The security doc and the release note now say precisely that instead of the ambiguous "cleared when playback ends". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(playback): fit the release note back under the 400-character cap Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6c065124ed |
feat(stalker): add account info dialog for Stalker portals (#1330)
* feat(stalker): add account info dialog for Stalker portals Xtream playlists have had an account-info dialog for a while; Stalker portals stored the same facts (login, expiry, tariff, status captured at import) as dead weight in the database and showed them nowhere. Add StalkerAccountInfoComponent mirroring the Xtream dialog's visual language: status pill, days-left/tariff/MAC hero stats, account and portal panels. Data is cached-first — the import-time snapshot renders instantly with a "Saved data" badge, then StalkerAccountInfoService refreshes it: full /stalker_portal/ installations re-run handshake+get_profile, portal.php panels are queried best-effort via account_info/get_main_info. A failed refresh keeps the cached snapshot; no data at all shows a retry-able error state. Entry points are unified behind shared portal-account predicates (isXtreamAccountPlaylist / isStalkerAccountPlaylist in shared/interfaces) so both portal types get the same set: header playlist switcher (bottom section + new per-row ⋮ Account info item), dashboard source card ⋮ menu, and the command palette (now visible on stalker routes with its own description). The header service picks the dialog by playlist type; the per-row path works for non-active playlists and skips the session-scoped stream counts. Also adds the missing top-level LOADING/RETRY i18n keys the Xtream dialog already referenced (they rendered as raw keys), a get_main_info handler in the stalker mock server, and STALKER.ACCOUNT_INFO translations for all 19 locales. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): unwrap nested js.account_info envelope in get_main_info Ministra-style portals nest the account block — fetchStalkerExpireDate() in stalker-player-request.utils already consumes exactly that shape, so the flat-only mapper silently discarded valid responses and legacy imports (which have no cached snapshot) got an empty account panel. Merge nested fields over flat aliases, send the JsHttpRequest parameter the existing get_main_info caller sends, switch the mock server to the nested envelope so the E2E covers the realistic shape, and document the account-info feature in CLAUDE.md (review feedback from Greptile and Codex on #1330). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(stalker): pin account-info expiry fixture below the day boundary Math.round on the epoch could round up half a second, putting the fixture's expiry just past the 30-day mark so daysLeft ceil'd to 31 on CI. Floor keeps the interval strictly inside 30 days regardless of when within the second the spec runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(stalker): address account-info review round two Three P2s from Codex on #1330: - Normalize the cached stalkerAccountInfo snapshot before rendering: the import path persists portal values verbatim, so expireDate can be a date string or milliseconds at runtime despite the declared number type. normalizeStoredStalkerAccountInfo() runs the same parsers as the fresh path. - Publish the re-auth token into StalkerSessionService's cache: strict portals invalidate the previous token per handshake, so the dialog's authenticate() would otherwise strand an active portal session on a dead token. - Extract the duplicated ~460-line account-dialog stylesheet into libs/ui/styles/_account-dialog.scss, shared by both dialogs with the provider accent injected via --account-dialog-accent; each consumer keeps only its accent and layout overrides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): serialize account-profile refresh with session auth The dialog's direct authenticate() call bypassed the pendingAuth map ensureToken() uses, so a refresh could run a second handshake while a catalog or watchdog request was still authenticating. On strict portals each handshake invalidates the other's token, and the later setCachedToken() could publish an already-dead one. Move the refresh into StalkerSessionService.refreshAccountProfile(): it waits for any in-flight authentication, registers its own so later callers wait for it, and republishes the resulting token. A failed pending auth no longer aborts the refresh, and the pendingAuth entry is only cleared when it is still this call's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): move pendingAuth cleanup out of the promise initializer TS2454 under the Angular compiler: the finally block referenced authPromise inside its own initializer, so every Electron/web production build failed even though jest and lint accepted it. Await the promise at the call site and retire the map entry there instead — same only-clear-our-own-entry semantics. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): harden account-info portal detection and expiry math Review round four (Codex P2s on #1330): - Fall back to the URL rule when isFullStalkerPortal is undefined: a playlist restored from an older backup carries no flag once the one-shot metadata migration has run, and it would then be sent down the unauthenticated legacy path and labelled a legacy panel. - Parse a bare YYYY-MM-DD expiry as a local calendar date. Date.parse reads it as UTC midnight, which renders as the previous day west of UTC and shifts the days-left boundary; timestamps carrying a time or offset keep standard parsing. - Decide expiry from the raw timestamp, not the rounded counter: an expiry that passed less than a day ago ceil's to 0/-0, so the hero stat claimed "0 days left" on a dead subscription. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): make account-profile refresh own the auth slot Review round five (Codex P2s on #1330): - Claim the pendingAuth slot in a loop and publish it before the first await. One settled promise releases every waiter at once, so a single pre-check let two queued refreshes both start handshakes that invalidate each other on strict portals. - Retire the cached token before the handshake: ensureToken() reads tokenCache before pendingAuth, so catalog and watchdog requests starting mid-handshake were handed a token this refresh was about to kill instead of queueing on the slot. - Render the portal type from the same resolver the fetch path uses, so a restored backup without an explicit flag is no longer labelled a legacy panel while authenticating as a full portal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): retire only the token that actually failed auth A request dispatched with the previous token can see its authorization failure arrive after a profile refresh has already cached a fresh one. The retry path deleted the cache blindly, killing the fresh token and kicking off another handshake that in turn invalidated tokens of newer requests — cascading retries on strict portals. makeAuthenticatedRequest() now retires the cached token only while it still equals the token that failed; a late failure of a stale token leaves the refreshed token in place and the retry reuses it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(stalker): distinguish the two no-data outcomes of the account dialog A portal that answers but publishes no account facts renders the ready-state "No account details" panel; only an unreachable portal without a cached snapshot enters the error state with retry. The doc conflated both as "error with retry" (review feedback on #1330). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): reject negative expiry sentinels before date parsing Portals encode unlimited/missing expiry as "-1" or "0"; the unsigned-digit check let "-1" fall through to Date.parse, which V8 reads as January 1, 2001 — an unlimited account rendered as expired. Signed numeric strings now take the numeric branch, whose non-positive guard already discards them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): reject out-of-range calendar components in expiry dates The multi-argument Date constructor normalizes invalid components ('2026-00-00' becomes Nov 30, 2025), fabricating an expiry and countdown from a placeholder. Round-trip the parsed year/month/day and reject any date that does not survive unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c559a386df |
docs(release): backfill 0.23 notes for embedded MPV scaling and dock-menu fixes (#1339)
The fixes from #1206 (native view misplaced on displays scaled above 100%) and #1207 (video jump / black bar when opening control menus) were merged before the .changes pipeline existed and the 0.23.0 backfill missed them. Both are user-visible and referenced from issue #1139, so they get notes. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e86e988e72 |
feat(ui): turn the phone context panel into an off-canvas drawer (#1332)
* feat(ui): turn the phone context panel into an off-canvas drawer On ≤640px viewports the workspace context panel (categories, filters, settings sections, collection filters) no longer stacks above the route content capped at 30vh — it is a hidden-by-default drawer that slides in from the left over a backdrop, opened via a new header toggle (phone-only, CSS-gated) and closed by selection, backdrop tap, Escape, or any navigation. State lives in the new WorkspaceShellContextDrawerService provided by the shell component; panels close it explicitly after selections that do not navigate (Stalker ITV/radio categories, settings sections, sources filters, collection filters), since NavigationEnd alone cannot cover those. Desktop behavior is untouched, including the ResizableDirective inline width. Closes the drawer follow-up deferred from #1100 / PR #1326. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): make the phone context drawer modal for keyboard users Addresses Greptile P1 and Codex P2 review feedback on #1332: - CdkTrapFocus on the sidebar captures focus into the drawer on open and contains it while the drawer is modal; the shell restores focus to the header toggle on close, since the closed drawer is visibility: hidden and focus left inside it would silently drop to <body>. - The drawer service closes the drawer when the viewport leaves the phone breakpoint (matchMedia), so the trap can never hold the in-flow desktop sidebar after a resize. - The toggle's tooltip and aria-label are now variant-aware — categories on portal routes, filters on sources/collection routes, settings sections on the settings route — instead of a fixed 'Categories & filters' that misdescribed two of the three; the two generic i18n keys are replaced by six variant keys across all 19 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): remove background content from the a11y tree while the drawer is open Round-2 review feedback on #1332 (Greptile P1, Codex P2): - The rail, header, route content and playback footer are marked inert while the phone drawer is open — CdkTrapFocus constrains Tab focus, but a screen reader's virtual cursor could still reach and activate the visually obscured controls behind the backdrop. - The drawer panel itself is the trap's initial focus target (tabindex=-1 + cdkFocusInitial), so focus capture still works when a category list is loading, empty, or failed and renders no focusable rows. - Focus restore on close is deferred one tick: the toggle lives in the inert header, and focus() on a still-inert element is silently ignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): gate global shortcuts and Escape behind the open phone drawer Round-3 review feedback on #1332 (Codex P2s): - The shell consumes Escape while the drawer is open: downstream Escape consumers (the portal detail shell's inline player close, the shared controls shortcuts) check defaultPrevented, so one keypress no longer closes both the drawer and the obscured playback surface. - inert does not silence document-level keydown listeners, so players opt out themselves while inside an inert region: ControlsShortcuts gains an optional hostElement handler and ignores every shortcut (including Escape) when that host has an inert ancestor, and the radio audio player applies the same check to its volume/mute keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): close control, Cmd+F gate, and Embedded MPV inert guard for the drawer Round-4 review feedback on #1332 (Greptile P1, Codex P2s): - 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 must offer dismissal itself — even when a category list is loading or empty and renders no actionable entries. - Ctrl/Cmd+F no longer opens global search while the drawer is modal; the shortcut would have navigated and focused an input inside the inert header. - EmbeddedMpvShortcuts (native-view legacy dock) gains the same hostElement/inert-ancestor guard as the shared controls shortcuts, so the obscured player cannot react to Space/arrows/M/Escape behind the drawer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): round-5 drawer feedback + update phone-layout e2e for the drawer Merges master (#1333 landed mobile-layout.e2e.ts pinning the #1326 stacked-panel behavior this PR replaces) and updates that spec to pin the drawer contract instead: panel hidden by default with full-width content, header toggle opens it over a backdrop, category selection and backdrop tap close it. Verified locally on Chromium, Firefox and WebKit (12/12). The spec's getByTestId calls needed plain [data-test-id=...] locators — the web-e2e Playwright config never mapped testIdAttribute. Also addresses Codex round-5 P2s: - Focus restore now reports whether the toggle received focus; when a drawer selection navigated to a route without a context panel (toggle gone), focus falls back to the route content instead of dropping to <body>. - The Xtream and Stalker live layouts' Ctrl/Cmd+B sidebar shortcut opts out while their host sits inside an inert region, matching the other document-level listeners. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): suppress command palette and shortcuts dialog behind the open drawer Greptile round-6 finding on #1332: the document-level Ctrl/Cmd+K handler in WorkspaceShellFacade and the '?' help-key handler in WorkspaceKeyboardShortcutsService still opened their dialogs while the phone context drawer was modal, stacking a second focus-trapped surface on top of it. Both now check the drawer service (injected optionally, same shell-component providers) and stay quiet while it is open, like the Ctrl/Cmd+F global-search gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): round-7 drawer feedback — Cmd+R gate and native MPV surface hiding Addresses the two Codex round-7 P2s on #1332: - WorkspaceShellContextDrawerService moves to @iptvnator/workspace/shell/util and becomes root-provided, so AppComponent's document-level Ctrl/Cmd+R global-recent shortcut can observe the modal drawer without pulling the lazy shell chunk into the eager bundle. Cmd+R is now suppressed while the drawer is open, like Cmd+F/Cmd+K/'?'. - The shell registers the open drawer with a new EmbeddedMpvOverlayVisibilityService.acquireExternalModalSurface() API: the native-view video surface is composited outside DOM stacking and would paint straight over the drawer regardless of z-index. The service treats registered external modal surfaces exactly like open Material dialogs. - The service's recompute no longer reads overlayActive back before setting it: signals already skip notification on equal values, and that hidden read registered overlayActive as a dependency of any reactive context calling into the service — the shell's acquire/release effect looped forever on exactly that (caught by a live browser probe; the unit suite mocked the service). The effect also wraps the acquire in untracked() for caller-side hygiene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): expose the phone drawer as a named modal dialog Round-8 review feedback on #1332 (Codex P2s): - While open, the drawer carries role=dialog, aria-modal=true, and a variant-appropriate accessible name (categories / filters / settings sections) — assistive technology now hears that a named modal surface opened instead of an unnamed complementary landmark. Closed (and the always-visible desktop sidebar) stays a plain landmark. - The UI-guidelines drawer section no longer claims the drawer service is component-provided; it is root-provided from workspace/shell/util since the round-7 move, and the stale claim could have led a future change to re-scope it and silently break the AppComponent shortcut gate and the Embedded MPV overlay observer. Matching code comments updated everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): round-9 — gate M3U player keys behind the drawer, raise drawer stacking Greptile round-9 P1 + Codex round-9 P2 on #1332: - The M3U video player's document-level digit-key channel switching and Ctrl/Cmd+B sidebar toggle now apply the same inert-ancestor guard as every other routed-content key listener. A codebase sweep confirms this closes the class: every document-level key listener on routed content is now either gated by the shell (Escape, Cmd+F/K/R, '?') or opts out via closest('[inert]'); the guidelines now require the guard for any new listener. - The drawer moves from z-index 99/98 to 951/950: above the settings action bar (100) and the root EPG/update panels (900/901), which inert removes from interaction but not from paint order — below the CDK overlay container (1000), since dialogs opened from inside the drawer (Manage categories) must stack on top of it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
297e9fbef8 |
fix(stalker): send cmd in the reference MAG wire format (#1334)
* fix(stalker): send cmd in the reference MAG wire format
A real MAG sends cmd unencoded and the portal decodes its query exactly
once, so a cmd that already contains percent sequences (%3A tokens,
pre-encoded path segments) must pass through untouched. The previous
encodeURIComponent transport (
|
||
|
|
04b2f9b82e |
test(e2e): pin the phone-layout invariants from #1326 (#1333)
PR #1326 fixed the workspace on phone-sized screens (issue #1100) with SCSS-only changes and no automated coverage. This adds a mobile-layout smoke spec asserting the invariants that regressed before: no horizontal overflow on dashboard/Xtream/settings, rail links inside the 52px top bar, the context panel stacking above full-width content on portal routes, the settings section list ending above the Back footer, and the 640x360 landscape live route keeping the channel sidebar >= 72px with the player container inside the viewport. The Xtream tests import the portal at desktop width and then shrink the viewport, so the persisted inline rail widths from ResizableDirective — the exact #1100 regression scenario — are present when the phone rules must win. Run: pnpm nx run web-e2e:e2e-ci--src/mobile-layout.e2e.ts Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
aba89d64cf |
fix(downloads): resume interrupted Xtream VOD transfers (#1329)
* fix(downloads): resume interrupted Xtream VOD transfers * fix(downloads): validate partials before resuming * fix(downloads): propagate headers to episode transfers |
||
|
|
d44045de31 |
test(playlist): fix flaky refresh-preparation worker-event spec (#1331)
The spec raced a fixed 160ms sleep against the service's internal rAF + 120ms paint delay that runs before deleteXtreamPlaylistContent is called. Under parallel jest load the sleep could win, asserting before the mocked worker event was ever delivered. Await a deferred resolved by the mock right after it fires onEvent instead, so the assertion is causally ordered after the signal update. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3dbfefa3d8 |
test(stalker): enforce portal auth in the mock and cover the full-portal flow (#1324)
* test(stalker): enforce portal auth in the mock and cover the full-portal flow
The mock server implemented neither get_profile nor get_events and
validated no auth at all, and the e2e suite imported the portal through
/portal.php — which the app classifies as a *simple* portal. The entire
authenticated branch (handshake, token, watchdog, re-auth) therefore had
zero coverage, right before a series of PRs that reworks exactly that.
Mock server:
- serve the canonical /stalker_portal/server/load.php endpoint, which
enforces the Bearer token and the Infomir MAC format like the real
middleware; /portal.php stays tolerant so the existing suite keeps
covering the simple-portal branch
- auth-store.ts models the parts of Stalker 4.9.35 a client can get
wrong: plain-text auth failures with HTTP 200, a handshake that is not
yet a session, idempotent token re-presentation, and permanent
device_id pinning (including the blank-after-pinned lockout)
- add get_profile (status 0/1/2, device conflict, block_msg) and the
get_events watchdog; profile advertises watchdog_timeout/timeslot
- new login-required scenario MAC and POST /invalidate-session so tests
can force a mid-session token loss
- the /stalker proxy route now forwards the token as a Bearer header and
wraps auth failures in the { payload } envelope, matching web-backend
Also moves extractMac into request-mac.ts: importing it from the
categories handler dragged the whole data generator into any consumer,
which broke unit tests on the workspace alias.
E2E: new stalker-auth.e2e.ts asserts handshake precedes get_profile
precedes content, that content requests carry the token while the
handshake does not, that the plain-text failure body is never rendered,
and that the client re-authenticates after the portal drops the session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mock): address CodeQL findings in the new portal auth code
Two genuine defects in the code this PR added:
- readBearerToken's /Bearer\s+(.*)$/ backtracks polynomially on
"bearer" followed by a long run of spaces; require the token to start
with a non-space character instead
- the /stalker proxy route read query params as strings without
narrowing, so a repeated key (?url=a&url=b) arrives as an array and
String.prototype.includes silently changes meaning
The remaining three alerts (missing rate limiting x2, sensitive data in
a GET query) are web-service hygiene rules aimed at internet-facing
services. The mock servers bind to localhost, serve fabricated data,
ship in no artifact, and deliberately mirror the real backend proxy's
token-in-query contract; a rate limiter would break the E2E suite that
hammers them. Exclude only those two apps from analysis via a documented
CodeQL config; every shipped path keeps full coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mock): tighten portal-auth fidelity per review
Review follow-up on #1324 (Greptile 2xP1, Codex 3xP2), all valid:
- adoptToken only accepts tokens the mock actually issued (or the
already-bound one). The stock server pins any presented Bearer —
handshake is stateless there — but a fixture that does the same
cannot catch a client with a broken token pipeline; documented as a
deliberate strictness divergence.
- /invalidate-session clears tokens but keeps pinned device identity:
losing a token never unpins device_id on a real portal, so changed
identity after re-auth must still hit the device-conflict branch.
- The login-required scenario gates on actual do_auth completion
instead of auth_second_step: the app sends auth_second_step=1 on its
very first get_profile, so the parameter check was trivially
bypassed and the status-2 flow never exercised. do_auth is now the
faithful boolean step (non-empty credentials -> {js:true}, recorded;
empty -> {js:false}).
- /server/load.php — the second URL shape isFullStalkerPortal
recognizes — is now served and enforced, directly and through the
/stalker proxy predicate, so full-portal tests cannot silently fall
into the tolerant branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): prove content actually reloads after re-authentication
Review follow-up on #1324 (Codex P2, valid — the "passes for the wrong
reason" class): the re-auth test only polled for a fresh handshake and
a negative body-text assertion, both of which pass even if the original
content request is never replayed or stays unauthorized. Capture the
content token from the initial import, then assert a post-invalidation
CONTENT request goes out under a DIFFERENT token and that the ITV
categories actually render — the mock only answers content for an
adopted token, so this proves the new token round-tripped through
get_profile. Verified against a live mock that the token genuinely
rotates (old token -> "Authorization failed.", new token -> content).
Also documents the second Codex P2: the mock is deliberately strict on
/server/load.php (a real portal enforces auth there); the import dialog
vs session predicate divergence is a separate app bug the strict
endpoint will let a later PR cover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): serialize the portal specs and bind mocks to loopback
Review follow-up on #1324 (Codex, 4xP2):
- Parallel-reset race: under the workspace `fullyParallel` preset the new
auth file ran concurrently with stalker.e2e.ts against one shared mock
process, and each `beforeEach` wiped global state (sessions, favorites)
mid-assertion in the other. Reproduced locally: both suites green in
isolation, two failures when run together. Merged the auth tests into
stalker.e2e.ts and pinned the file to `mode: 'serial'`, which also
removes the pre-existing race between that file's own tests. 19/19
green across three consecutive runs.
- Watchdog was recorded but never asserted, so the suite would stay green
if the full-portal workflow stopped pinging or dropped its token —
`sendWatchdogPing` swallows failures. Now polls for an authenticated
`get_events`.
- Both mock servers listened on every interface (stalker: `listen(PORT)`
with no host; xtream: an explicit `0.0.0.0` default), which made the
CodeQL exclusion's "binds to localhost" rationale untrue. Both now
default to `127.0.0.1` with a `HOST` opt-in, and the config comment
states plainly what the directory-wide ignore trades away.
- Documented that the login-required scenario is HTTP-level only for now:
the client's `do_auth` path is dormant and sends empty credentials, so
the fixture is waiting on that client-side work rather than claiming
end-to-end coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): force a real auth failure before asserting it stays hidden
Review follow-up on #1324 (Codex 2xP2 + 1xP3), all valid:
- The "never surfaces the plain-text auth failure" test only performed a
successful import, so its negative body assertions were vacuous. It now
imports with a MAC outside the Infomir OUI: the strict endpoint answers
get_profile with a bare {status:1}, no token is ever adopted, and every
content request keeps returning "Authorization failed." Unlike an
invalidated session this cannot be repaired by the client retry, so the
failure is genuinely observed (asserted directly against the proxy) and
only then checked for not leaking into the UI.
- docs/architecture/xtream-mock-server.md still documented the wildcard
bind that
|
||
|
|
94efd7d379 |
fix(workspace): restore playlist info entry in header playlist dropdown (#1328)
The context-actions section of the playlist switcher lost its "Playlist
info" button when playlist actions moved into the per-row menu
(
|
||
|
|
8f861a3a1b |
fix(ui): make the workspace usable on phone-sized screens (#1326)
* fix(ui): make the workspace usable on phone-sized screens
The shell was half-adapted below 640px: the rail flipped to a horizontal
bar but the link lists inside it kept stacking downwards, so the navigation
was drawn outside the bar and over the header (#1100).
Three resizable rails — the shell context panel, the live-layout channel
sidebar and the M3U channel drawer — kept their persisted desktop width,
which left the content around 50px on a 375px screen. They now span the
full width and stack above the content. The inline width written by
ResizableDirective is why these rules need `!important`.
Found while walking the rest of the UI at 375px and 768px:
- The detail hero kept poster and details side by side, squeezing the
action row below its own labels until "Play" was clipped to its icon.
- The settings section list did not scroll and painted over the footer,
which also affected short desktop windows.
- Hiding the M3U channel list on a phone was one-way: the restore handle
was hidden and only Cmd/Ctrl+B could bring it back.
- The live header drew the channel count and the paginator on top of each
other up to tablet width, because the paginator does not shrink and the
meta collapsed to zero width and overflowed its box.
- The search scope checkbox was pushed off the right edge.
Live TV states a floor for the player instead of a ceiling for the lists,
so the video keeps a usable share of the screen under the categories panel
and the channel list.
Closes #1100
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): address review — keep the palette reachable and the video visible
Two findings from the Codex review on #1326.
Hiding the command-palette trigger on phones removed the only pointer-driven
way to open it: the rail renders route links plus Settings and emits nothing,
so `commandPaletteRequested` had exactly one source. The button stays and its
keyboard-shortcut label is swapped for an icon instead. Doing that exposed a
latent flex trap in the same row — an <input> keeps an intrinsic min-width
from its `size`, and `min-width: auto` honours it, so the field refused to
shrink and pushed the trigger out onto the buttons beside it.
The M3U drawer released the shared player floor, which on a short landscape
phone (600-640px wide) left the content container at half the shell body.
The inline guide inside it is `flex: 0 0 <basis>` and took its full 180px out
of a container that no longer had it, so the video could reach zero height.
The floor is restored and now yields on short viewports, the video states its
own minimum, and the guide is what gives way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): let the channel list keep its height on a landscape phone
Follow-up to the review: the player floor added in the previous commit was
measured against the viewport, not against what the shell had left. On a
640x360 landscape phone the stacked categories panel already takes 30vh, so
claiming another 50vh here drove the channel sidebar to zero height while it
was still marked expanded — no way to pick another channel — and pushed the
layout past the viewport.
The floor now applies only where the screen can afford it (`min-height:
600px`), the sidebar states a floor of its own so it cannot be squeezed out,
and the collapsed rule clears that floor so hiding the list still works.
Below that height the two panes simply share what is left.
Portrait is unchanged: categories 244px, channel list 220px, player 240px on
a 375x812 screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): address review — settings nav on landscape, poster dead space
Two more findings from the Codex review.
The stacked settings context panel capped itself at 30vh, which on a 360px
screen is 108px — less than the panel's own title and footer, so the seven
section rows collapsed to nothing behind an overlapping footer. On short
screens the caption gives way (the rail already labels the page), the footer
sheds its tall-screen padding, and the settings variant gets a slightly
larger cap: unlike the live routes there is no player below competing for
height, only a scrollable form.
The poster kept a 330px minimum from the skeleton fallback at the bottom of
the file — sized for the 220px desktop poster — while the stacked phone hero
renders it 140px wide with a ~210px aspect-ratio height. Every loaded detail
page carried ~120px of empty space between the poster and the title. The
override sits after that rule because it wins on source order, not
specificity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): let the playlist switcher yield to the search field on narrow phones
Codex review of
|
||
|
|
760099358b |
feat(downloads): redesign download manager (#1313)
* docs(downloads): specify manager MVP redesign * docs(downloads): plan manager MVP implementation * docs(downloads): tighten manager validation plan * fix(downloads): keep renderer download state global * fix(downloads): make active count accessible * feat(downloads): derive queue and library view model * test(downloads): close view model coverage gaps * fix(downloads): stabilize malformed view model data * refactor(downloads): isolate library navigation * fix(downloads): report library navigation failures * feat(downloads): add ready-to-watch library * feat(downloads): add active download queue * feat(downloads): finish manager MVP * docs(downloads): clarify detail-first offline behavior * docs(downloads): plan detail navigation follow-up * fix(downloads): open completed movies in details * test(downloads): cover pending series navigation * fix(downloads): honor the global cover size * fix(downloads): prefer local playback in shared details * fix(downloads): preserve external launch priority * fix(downloads): prefer local playback in Xtream details * test(downloads): cover offline detail journey * docs(downloads): document offline detail behavior * docs(downloads): format detail navigation plan * fix(downloads): open Stalker items in provider details * docs(downloads): clarify Stalker navigation fallback * fix(xtream): isolate reused detail identities * fix(xtream): ignore stale VOD positions * fix(downloads): keep offline Xtream playback available * docs(downloads): clarify provider playback availability * docs(downloads): design missing-file recovery * docs(downloads): plan missing-file recovery * feat(downloads): derive completed file availability * feat(downloads): recover missing completed files * feat(downloads): refresh missing local files * feat(downloads): separate missing files from ready media * feat(downloads): surface missing files for recovery * refactor(downloads): simplify ready cards * test(downloads): cover missing-file and series journeys * feat(downloads): finish missing-file recovery * docs(downloads): design offline detail views * docs(downloads): plan offline detail views * feat(downloads): persist offline metadata snapshots * fix(downloads): complete metadata snapshot bridge contract * feat(downloads): manage offline metadata snapshots * fix(downloads): harden metadata snapshot updates * fix(downloads): restrict snapshot artwork * fix(downloads): guard restart artwork URL * fix(downloads): refine artwork URL checks * feat(downloads): expose offline metadata updates * fix(downloads): keep metadata service change focused * fix(downloads): preserve metadata error conventions * feat(downloads): derive offline detail content * fix(downloads): preserve unknown episode coordinates * feat(downloads): add focused offline detail routes * fix(downloads): ignore fragments in shell route state * fix(downloads): normalize fragments before queries * feat(downloads): open ready cards in offline details * fix(downloads): use native disabled card styles * feat(downloads): enrich offline detail metadata * fix(downloads): harden offline metadata resolution * fix(downloads): preserve stalker provider titles * fix(downloads): distinguish stalker metadata seeds * fix(downloads): stabilize offline metadata refresh * fix(downloads): throttle sparse metadata refreshes * fix(downloads): type metadata language settings * feat(downloads): render offline movie and series details * fix(downloads): harden offline detail interactions * fix(downloads): close offline detail edge cases * feat(downloads): hand off to provider-only details * fix(downloads): preserve stalker provider handoff * feat(downloads): capture metadata at download time * fix(downloads): preserve snapshot source semantics * fix(downloads): preserve episode snapshot identity * docs(downloads): document offline details flow * docs(downloads): clarify stalker provider fallback * test(downloads): cover offline detail journeys * test(downloads): stabilize offline detail selectors * style(downloads): format changed files * docs(downloads): clean design spec formatting * fix(downloads): preserve offline library ownership * test(downloads): fix Windows workspace navigation * test(database): preserve Electron tsconfig resolution * perf(downloads): avoid blocking file availability probes |
||
|
|
46c58f4f57 |
fix(stalker): keep session headers on same-host redirects (#1322)
* fix(stalker): keep session headers on same-host redirects Since 0.22 requestWithValidatedRedirects stripped Cookie/Authorization whenever a redirect changed the *origin*, so a portal answering with an http->https upgrade or a port move lost the MAC cookie and Bearer token mid-session. Real Stalker/Ministra servers then reply with a plain-text "Authorization failed." body: categories fail to load, create_link never resolves, and no player receives a stream URL (#1158 regression window). Scope credential stripping to the host instead: same-host scheme/port redirects keep headers, basic auth, params, and request bodies; a redirect to a different host still drops all of them, preserving the original hardening intent (no credential leaks to third-party hosts). Also adds the Stalker API compatibility roadmap produced by the 2026-08-01 protocol audit (.plans/, force-added like earlier plans). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(electron): strip credentials on same-host https-to-http downgrades Review follow-up (Greptile P1 + Codex on #1322): the host-only check kept Authorization/Cookie/basic auth/params/body when an https request was redirected to http on the same host, replaying a TLS-obtained session in cleartext. Treat that downgrade like a host change: strip credentials and refuse to replay request bodies. Scheme upgrades and port moves on the same host keep headers — the actual #1158 scenarios. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0c59aace71 |
fix(playback): structure MPEG-TS diagnostics (#1327)
* docs(playback): design structured mpegts diagnostics * docs(playback): plan structured mpegts diagnostics * fix(playback): structure mpegts error evidence * fix(playback): structure HTML5 mpegts errors * fix(playback): share mpegts evidence across players * fix(playback): render structured mpegts evidence * docs(playback): document structured mpegts diagnostics * chore(playback): keep diagnostics lint clean * docs(playback): complete mpegts diagnostics plan |
||
|
|
9bb1811984 |
fix(playback): escape commas in mpv header fields on the TS paths (#1323)
* fix(playback): escape commas in mpv header fields on the TS paths PR #1321 fixed the comma truncation of --http-header-fields inside the native embedded-mpv addon, but the same OPT_STRINGLIST parsing bites three TypeScript call sites that join header fields with ',': - external MPV CLI launch (--http-header-fields=...) - external MPV instance reuse (set_property http-header-fields) - embedded MPV frame-copy loadfile options (opt.http-header-fields); the helper's %len% quoting protects only the option-list level, mpv still stringlist-parses the value afterwards The Stalker MAG user agent contains "(KHTML, like Gecko)", so strict portals received a truncated X-User-Agent and rejected live streams with HTTP 400 (#910). Escape backslashes and commas per field with the same scheme as the native fix, via a shared util. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(playback): cover the reused-instance IPC header escaping Review follow-up (Greptile on #1323): the regression coverage only exercised the CLI spawn path. Capture the JSON IPC traffic of a second, reused mpv launch and assert the escaped MAG user agent survives the set_property http-header-fields transport, so later serialization changes cannot silently reintroduce truncated headers. net is mocked with a passthrough default because the VLC specs bind a real ephemeral port via createServer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
814161274b | fix(playback): escape commas in mpv http header fields (#1321) | ||
|
|
b14ce2452b | fix(packaging): add verified source mirror fallback (#1325) | ||
|
|
9f4e11d6de |
fix(playback): structure Shaka diagnostics (#1318)
* docs(playback): design structured Shaka diagnostics * fix(playback): structure Shaka diagnostics * docs(playback): document Shaka evidence boundary * docs(playback): fix Shaka validation commands * fix(playback): preserve Shaka fallback evidence * fix(playback): preserve Shaka text error evidence |
||
|
|
9a50e7385b | fix(playback): structure Video.js diagnostics (#1317) | ||
|
|
46c7713841 |
fix(ui): preserve EPG in narrow channel rows (#1312)
Preserve current-program context and enabled actions in narrow channel rows while aligning loaded rows, skeletons, and virtual-scroll geometry across M3U, Xtream, Stalker, Favorites, and Recent views. |
||
|
|
2ac0de752f |
fix(skills): align repository guidance with implementation (#1315)
* docs(skills): design implementation synchronization * docs(skills): plan implementation synchronization * fix(release): filter internal notes from public body * docs(release): synchronize release workflow guidance * fix(stalker): normalize catalog series flags * fix(stalker): preserve progress with scoped episode IDs * fix(playback): expose strict position persistence * docs(stalker): record series position compatibility * test(skills): validate repository skill contracts * fix(database): keep SQL trace values private * docs(skills): refresh Nx and SQLite ownership * docs(skills): align provider and UI guidance * docs(skills): tighten validated guidance * docs(release): require exact release pushes * style(electron): remove trailing blank line * fix(ci): classify repository skills coverage |
||
|
|
99d167993d |
fix(playback): structure HLS diagnostics (#1316)
* docs(playback): design structured HLS diagnostics * docs(playback): plan structured HLS diagnostics * fix(playback): structure HLS diagnostics * docs(playback): document structured HLS evidence * fix(playback): keep HLS startup logs private |
||
|
|
bf13849d69 |
fix(playback): avoid false codec diagnostics (#1314)
* docs(playback): design accurate native diagnostics * docs(playback): plan accurate native diagnostics * fix(playback): classify native source errors from evidence * fix(playback): preserve Video.js HTTP error context * fix(playback): show explicit HTTP playback errors * docs(playback): document native error evidence |
||
|
|
32ba209b63 |
fix(portals): restore fresh-import pins atomically (#1311)
* fix(portals): restore fresh-import pins atomically * fix(portals): preserve Xtream restore retry state * fix(portals): serialize Xtream restore revisions |
||
|
|
78df3e7dbb |
fix(portals): match Greek titles whichever sigma the provider typed (#1310)
Greek Σ has two lowercase forms — medial σ and word-final ς — and neither the candidate query nor the confirmation treated them as one letter. The GLOB scan built each character's class from a one-way reach that only arrived at ς when it started from ς, so a request for "ΑΣ" never admitted a stored "Ας". Classes are now built from a fold group — every character sharing an uppercase form — derived by scanning the cased ranges at module load the way ACCENTED_BY_BASE already is. It generalises past sigma on its own: dotless ı folds with i, long ſ with s, historic Cyrillic letterforms with В Д О С Т Ъ Ѣ. Only the 24 groups of 767 that a per-character fold would miss are kept. Admitting the row was only half of it. normalizeTitleKeys then compared "ασ" against "ας" and discarded it, because toLowerCase picks the sigma form by position. Both SQL tiers already folded them together — SQLite's trigram tokenizer does full Unicode folding natively, unlike LOWER() — so the JS confirmation was the only tier that did not, making this a pre-existing gap on the FTS path as well. Normalization now rewrites ς to σ after lowercasing, which is what Unicode case folding does. Guards unchanged: a case mapping that changes length (ß → SS, İ) or a GLOB metacharacter still returns null rather than a partial pattern. |
||
|
|
063662028a |
feat(portals): find the same movie in your other playlists (#1286)
* feat(portals): find the same movie in your other playlists A movie that exists in several imported Xtream playlists now shows a "Sources N" chip on its detail page and in the player. Switching playlist mid-film keeps the timecode, a preferred source can be pinned per movie, and a failed stream offers the alternatives instead of a dead end. The governing rule is that a guess is never presented as a fact. Every metadata value carries where it came from — `api` (the provider said so), `parsed` (inferred from the title) or `probe` (we contacted the stream). Facts render as plain tags, guesses are prefixed `~` in a warning colour, and an unknown value renders no tag at all plus a "check" affordance. Ranking and failover read through `factualOnly()`, so a filename claiming 4K is structurally unable to outrank a source that was actually reached. A probe that could not complete reports "unknown", never "unavailable". Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces live content; both are additive later, since the candidate type already carries all three portal kinds. In the PWA every entry point is gated off and the chip renders nothing. Auto-failover is opt-in and off by default. Each source is tried at most once per session, so it terminates structurally, and the switch is never silent — the toast names the new playlist, offers an undo, and warns that the dub may differ only when both sides state an audio track as fact. Notable details: - Playlist names are routinely the pasted URL, credentials included. They are never rendered raw; a short host-only label is derived instead. - Quality is derived from pixel width, not height: a 2.39:1 1080p master is 1920x800, and bucketing that by height would publish "720p" as a fact. - Switching is a single `inlinePlayback.set()` so the player and engine survive and re-seek; the carried position is read before the 15s persistence throttle so it does not rewind. - Sources from one playlist collapse into a group, since the same film often appears there several times under different stream ids. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portals): stop stale source resolutions from committing Addresses three defects Greptile found in the multi-source review. **Concurrent switches committed out of order.** Selecting a second source before the first resolution returned let the slower request overwrite the newer selection and repoint Undo at itself. `switchTo` now takes a sequence number and drops its result if a newer switch already committed. **Stale switches crossed movie sessions.** Navigating to another film while a resolution was in flight let the continuation activate the old film's source inside the new controller — and restart it from that session's zero resume position. The controller is now snapshotted per operation and the movie session is revalidated after every await. `check()` had the same hazard across its two awaits and is guarded the same way. **Short titles skipped discovery entirely.** The trigram tokenizer cannot index tokens under three characters, so "Up", "It" or "Us" produced an empty MATCH expression and the query was discarded before SQLite was consulted — the chip could never appear for those films. Discovery now falls back to a bounded scan when FTS structurally cannot serve the title; the existing two-tier normalized confirmation still rejects loose hits like "Upgrade". Each fix carries a regression test; all three were mutation-checked by removing the guard and confirming exactly those tests fail. The previous test asserting that short titles return nothing encoded the bug and has been replaced. The host spec passed 400 lines, so its fixtures moved to a shared module and the race suite into its own file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portals): make the pin decide playback and keep failover going Second round of Greptile review findings. **A pin had no behavioural effect.** Loading a stored pin only decorated the row: Play still started the route's playlist and failover ranking ignored `isPinned`, so "make this the main source" survived a restart as an icon and nothing else. The primary action now starts from the pinned source when one is set, and the pin outranks everything else in failover ranking. **Failover stopped at the first unresolvable candidate.** An expired account or a failing `get_vod_info` on the top-ranked source ended the attempt, and since production calls `failover()` only once — on the original playback failure — a healthy lower-ranked source was never reached. It now continues through untried candidates. `switchTo` reports why it stopped so the loop can tell "could not resolve, try the next one" from "something newer owns the screen"; without that distinction a superseded switch would have spun forever, because only the former marks the candidate tried. **Identity ignored enrichment.** The key was `playlistId:contentId:title`, so when `get_vod_info` added a TMDB id and release year to an unchanged title the host saw no change, never reloaded, and kept yearless discovery and title-only pin keys — a `tmdb:`-keyed pin could never be found. The key now covers every field that affects matching. **A server refusing HEAD read as unavailable.** Some stream hosts answer 405 or 501 to HEAD yet serve the media over GET. The probe now retries once with the ranged GET the main process already supported, instead of caching a working source as failed and penalising it during failover. Greptile also flagged a missing token check after the resolve await in `switchTo`; that guard landed in |
||
|
|
b1f77c678e |
test(performance): prevent renderer heartbeat omission (#1308)
* test(performance): normalize sub-ms IPC clock skew * test(performance): prevent heartbeat coordinated omission |
||
|
|
deae0a2a4d |
fix(xtream): keep sparse VOD details playable (#1303)
* fix(xtream): keep sparse VOD details playable * fix(xtream): scope VOD fallback to active playlist * fix(xtream): render sparse VOD before recovery * fix(xtream): recover Similar VOD provider categories |