mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
d44948f2fa0c10ccc9e80c3d4560c138dd60ae6e
665
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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 (
|
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
f80eb4d1b9 |
fix(playlist): open playlists handed over by the OS (#1299)
Opening an .m3u/.m3u8 file from the command line or a file association did nothing. The renderer parsed `process.argv` and sent an `OPEN_FILE` IPC event that had no `ipcMain` handler and no preload channel, so `sendIpcEvent` logged it as an unknown type and dropped it. The path now belongs to the main process, which is where the OS actually delivers it: - argv is parsed on first launch (skipping the executable and Chromium switches) and normalized to an absolute path; - macOS gets an `open-file` listener registered before `whenReady`, since Launch Services never puts the path in argv; - the single-instance guard forwards a second launch's argv and working directory instead of discarding them, so opening a playlist against a running app works too. Requests are queued in the main process until the renderer subscribes to the `OPEN_FILE` push and drains the queue, which closes the startup race. The import itself reuses the existing file path, so persistence, playlist-scoped EPG and the navigation to the new playlist behave exactly like a dialog import; a failed open now surfaces a snackbar instead of silence. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
80af9257a0 |
refactor(portals): share external-button and position-writer logic (#1298)
The Xtream and Stalker VOD detail views each carried a private copy of two behaviours: deriving the Play/Stop button state from the active external (MPV/VLC) session, and throttled persistence of the inline player position. A Play button or a resume point that behaves differently per portal is the kind of divergence users notice, so both now read from one implementation. Extracts `createExternalPlaybackButtonState` and `createInlinePlaybackPositionWriter` into portal/shared/util, and lifts the Stalker VOD download errand into its own helper. Behaviour is unchanged; the shared helpers are deliberately identical to the copies they replace. This also brings both hosts back under the 400-line ESLint limit, neither of which was baselined: vod-details.component.ts 389 -> 333 stalker-catalog-detail.component 394 -> 325 vod-details-playback.service.ts 345 -> 275 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a2fafcfc08 |
test(performance): add end-to-end Xtream benchmark harness (#1300)
* docs(performance): plan Xtream benchmark * feat(xtream-mock-server): add deterministic 100k fixture * style(xtream-mock-server): apply repository formatting * fix(xtream-mock-server): harden performance fixture data * feat(xtream-mock-server): add performance control plane * docs(performance): correct Xtream capture plan * fix(xtream-mock-server): harden performance controls * fix(xtream-mock-server): harden control lifecycle * feat(performance): add Xtream preload markers * feat(performance): trace Xtream main phases * feat(performance): mark Xtream store publications * feat(performance): trace Xtream database phases * feat(performance): trace Xtream delete cancellation * feat(performance): capture Xtream phase attribution * feat(performance): mark Sources Xtream refresh * test(performance): define Xtream benchmark evidence contracts * test(performance): add Xtream benchmark runner * test(performance): surface failure evidence writes * test(performance): align database read clock * test(performance): preserve capture failure contracts |
||
|
|
bfad82c26c |
fix(settings): stop settings silently reverting on restart (#1272)
Settings live in the renderer's IndexedDB, and two failure modes made them look saved while nothing reached disk. A second app instance sharing the same userData directory cannot take the Chromium storage lock, so its renderer reads defaults and every write is dropped. The app now holds a single-instance lock and focuses the running window instead of starting a rival copy. The lock is requested after the userData override so E2E runs with their own data dir keep independent locks, and after Squirrel event handling. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local CDP debugging. updateSettings() patches in-memory state before persisting and the submit path had no rejection handler, so a failed write produced an unhandled rejection and no user-visible feedback. SettingsStore now records which half of the round trip failed, and the settings page surfaces it through a dismissible error snackbar; the dialog stays open on failure so the save can be retried. Two follow-ups from review, both wider than the report: - a second launch now re-creates the main window when the lock owner has none left, so closing the last window on macOS no longer leaves a second launch quitting silently with nothing on screen - App.onMainWindowCreated() re-runs window-owned bindings for every rebuilt window, so the downloads broadcaster stops holding a destroyed window. This also fixes the same bug on the pre-existing dock `activate` path. Closes #1156 Closes #102 |
||
|
|
24f0dee6f0 |
test(performance): add formal M3U import benchmark (#1287)
* test(performance): add formal M3U import benchmark * test(performance): harden formal capture validity * test(performance): address benchmark review feedback |
||
|
|
e55d55b47f |
feat(mock-data): add shared screenshot-safe poster catalog (#1271)
Moves the fictional movie catalog into `libs/shared/marketing-fixtures` so the
Xtream and Stalker mocks describe the same titles, and adds 20 rendered posters
plus the shared fixture types behind them.
Supporting changes made while getting it green:
- `shared-marketing-fixtures` is classified Tier B in the coverage policy. Not
Tier A: it is fictional fixture data, so a statement percentage over it means
nothing, and a Tier A entry would pull it into the merged coverage map and the
ratchet. Tier B still runs its spec in CI. `stalker-mock-server` needs no entry
of its own — it is already Tier C and the Tier B/C runner falls back to
`pnpm nx test <project>`, so its new `marketing-poster-url.spec.ts` runs.
- Two release-capture defects the catalog reorder introduced, both fixed in
`tools/release/capture-app-driver.ts`:
- VOD stream ids are `MARKETING_VOD_STREAM_ID_BASE + index` and the generator
now lists the showcase movies first, so 62000-62002 became Black Harbor, The
Paper Astronaut and Summer Static while the dashboard seeding still mapped
those ids to the previous titles' backdrops.
- the raw `tsx` spawn of the Xtream mock lacked `--tsconfig
tsconfig.base.json`, so the mock could not resolve
`@iptvnator/shared/marketing-fixtures` and the capture never started. Both
mock projects' own serve targets already passed the flag.
|
||
|
|
0579d253c4 | fix(electron): fail closed on unknown performance completions | ||
|
|
f9e71a6d8d | fix(electron): isolate refresh performance correlation | ||
|
|
e3ce60a35e | chore(electron): add preload performance markers | ||
|
|
08b868d6c1 |
test(electron): harden runtime boundary coverage (#1267)
Adds contract-focused regression coverage for the Electron HTTP server, remote-control events, settings events, and managed download paths, and makes Tier A coverage fail closed when instrumentation fails or a runtime-owning production file disappears from a project or from the merged Istanbul report. The old `coverage:ci` exited 0 despite a `Failed to collect coverage` diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged map. All 30 Tier A reports are now required, the merged map covers 710 files, and effects.ts is reported as 0/159 instead of silently disappearing. Also fixes remote static-file path containment for encoded, malformed, NUL, POSIX and Win32-style traversal inputs, with behavior-preserving testability seams. Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%, remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%. |
||
|
|
1ab82b04a1 |
refactor(deps): drop uuid for a shared crypto-based id helper (#1266)
Supersedes #1252 and #872. uuid 14 is ESM-only, apps/web/jest.config.ts only kept v9 working by mapping `^uuid$` at a `wrapper.mjs` that v14 no longer ships, and the specifier also has to be synced in libs/shared/m3u-utils/package.json or @nx/dependency-checks fails lint. All four call sites only used `v4()`, so the dependency goes away instead. `createRandomId()` prefers `crypto.randomUUID()` and falls back to building the same v4 shape from `crypto.getRandomValues()` — that fallback is load-bearing, because randomUUID is only exposed in secure contexts and the self-hosted PWA is regularly served over plain http on a LAN address. getRandomValues stays available there, and it is what uuid's own v4 used. `@types/uuid` goes too; it only existed for the untyped v9 package. |
||
|
|
9ae53e4515 |
fix(playback): make the "Show subtitles" setting reach the web players (#1269)
The persisted subtitle preference only ever had an owner behind the default-off shared web-controls flag, so with the shipping controls it did nothing: Video.js never read it, ArtPlayer declared the input but never used it, and the HTML5 player only ran a one-shot pass after play() resolved — before hls.js had added its text tracks. No portal host bound the input at all, so it never reached Xtream or Stalker pages either. Extract the source-local track controllers into the adapter-free WebVideoSourceTracks and have WebVideoSourceControlsBridge wrap it, so both controls modes apply the preference through the same code. The preference-off players bind it directly (VjsLegacyTracks for Video.js), and WebPlayerViewComponent reads the preference from SettingsStore instead of an input so every host inherits it. The preference means different things depending on who renders the caption UI: shared controls stay authoritative for the session, while vendor chrome is source-default — the preference seeds each new source and is released once the media reports playing, so the engine own caption menu keeps working. Mode selection is an optional playbackStarted probe passed to the HLS, native and Shaka helpers; in that mode the HLS helper deselects the track rather than hiding it, since subtitleDisplay would silently override the vendor menu. Closes #1155 |
||
|
|
f147d4fe37 | perf(m3u): stop cancelled refresh workers (#1268) | ||
|
|
3032cfa88d | fix(m3u): avoid persisting hydrated favorites (#1232) | ||
|
|
a5bb8dc25c |
fix(tmdb): series cast was the latest season only, not the show (#1242)
* fix(tmdb): series cast was the latest season only, not the show
TMDB documents a TV id's `credits` as the credits of the LATEST SEASON.
We requested exactly that and rendered it as "the cast", so every
long-running show lost every regular who had left: The Boys showed
whoever appears in the newest season, not the ensemble.
The TV details request now also appends `aggregate_credits`, which spans
the whole run — but per TMDB omits the newest season, so neither payload
alone is the cast. `unifiedTvCast` unions them: whole-run billing order
first, then people who appear only in the newest season, deduplicated by
person id. Characters come from the aggregate `roles[]` shape.
Deliberately NO cache-key bump. Rows cached before this simply lack
`aggregate_credits` and keep the previous behaviour until they expire,
which avoids invalidating every user's details cache twice — the roadmap
schedules one consolidated bump once the remaining append_to_response
additions (images, certifications, alternative_titles) land together.
Movies are untouched: /movie/{id} has no aggregate_credits and its
`credits` is already the full cast.
Tests: departed regulars retained, newest-season arrivals appended after
show billing order, characters read from roles[], no duplicates across
the two payloads, graceful fallback for pre-aggregate cache rows.
Refs docs/architecture/tmdb-roadmap.md A2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): reserve cast slots so newest-season arrivals survive the cap
The union was appended aggregate-first and then truncated to ten, so on
exactly the shows it was built for — long-running ones, where the
whole-run cast alone exceeds the limit — every newest-season arrival was
sliced back off. The original fixture had two aggregate members and
could not catch it.
unifiedTvCast now holds back up to three slots for the top-billed
arrivals instead of appending them where the cap discards them, and
gives the slots back when nobody is new.
Tests: a 12-member aggregate plus two arrivals keeps both arrivals and
top billing; an aggregate with no arrivals still gets all ten slots.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(tmdb): split the series-cast suite out of the merge spec
The merge conflict resolution put both new describes back into
tmdb-merge.spec.ts, pushing it to 499 lines — past the 400-line
max-lines cap. The aggregate-credits suite moves to its own file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): stop the cast union from shrinking, and bound what it caches
Three follow-ups from a review pass over the aggregate-credits union:
- The reserved arrival slots were subtracted from the aggregate even when
the aggregate was shorter than the cap, so a show with four regulars and
five newcomers returned seven names instead of nine. The reservation is
a floor for arrivals now, not a quota.
- An aggregate member's character came from the first role with any text,
so a one-episode cameo could outrank the part the actor is known for.
Pick the role with the most episodes.
- aggregate_credits carries a show's whole-run cast AND crew, and details
payloads are cached verbatim — orders of magnitude of JSON for a list
the merge truncates to ten people. Cache the billing-order prefix and
drop the crew nothing reads.
Extracting the people-related helpers into tmdb-credits.ts keeps
tmdb-merge.ts under the line cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): keep the aggregate ids the arrival check depends on
Trimming the cached cast to its top 40 broke the property it was supposed
to preserve: `known` is built from the aggregate ids, so a returning actor
billed below the cut read as a new arrival on the cached path and took a
reserved slot. The same show then showed a different top ten on its second
open than on its first.
Keep the whole cast, and cut the two things nothing reads instead: the
aggregate crew, and every `roles[]` entry except the one the merge picks
(most episodes). A merge over the trimmed payload now provably returns
what a merge over the full one does — covered by a test that runs both.
Also points CLAUDE.md and the doc's module table at tmdb-credits.ts, where
the credit helpers now live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(tmdb): add the release note for the series-cast fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): let the cache trim reuse the merge's own role choice
The trim picked the role with the most episodes; the merge picks the
NAMED role with the most episodes. TMDB uses unnamed roles for uncredited
appearances, so a member whose blank role outranked their real one lost
their character on every render after the first.
Both now call pickAggregateRole, which is the point — two copies of the
same choice are what let them drift.
Also adds a test pinning the property the earlier truncation defect broke:
the displayed cast is the cap or everyone available, whichever is smaller.
Which people make the cut at the cap is the reservation's job and is
deliberate; the count is not negotiable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(tmdb): state the aggregate-credits contract as TMDB actually words it
TMDB describes the endpoint in one sentence that contradicts itself: "it
does not return the newest season. Instead, it is a view of all the entire
cast & crew for all episodes belonging to a TV show." The doc and the code
comment asserted the first half as settled fact.
The union never depended on that reading — arrivals are a set difference,
so under "whole run" they are simply empty — but the comment implied an
assumption the code does not make. Say what TMDB says, note the ambiguity,
and note why either reading is safe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
0b967d66d4 |
feat(tmdb): metadata cache panel with a clear button in settings (#1244)
* feat(tmdb): metadata cache panel with a clear button in settings Adds "Metadata cache — N entries · X MB" with a Clear button to Settings > Metadata (TMDB), next to the API key it belongs to. Three things it is good for: dropping stale or wrong metadata so the next open refetches it, seeing what the cache actually costs on disk, and reclaiming rows that a lookup-key version bump has orphaned — a bump makes rows unreachable, not deleted, so nothing else would ever collect them. Sizing the cache is a full table scan (LENGTH() on TEXT counts characters, so the SUM casts to BLOB to get bytes), which is why stats load lazily and only once the TMDB section is the active one rather than on every settings open. Clearing is always safe: enrichment refetches on demand, so the only cost is the next few requests. Works in both environments — the PWA has no bridge, so the service reports and clears its session-scoped in-memory map instead. i18n: 4 keys across all 19 locales via the tools/i18n workflow; placeholder integrity verified. Contract fixtures updated for both the preload bridge and the DB-worker payload shapes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): make cache clearing durable and stop reporting failures as empty Four review findings, all real: - A metadata write already in flight when the user cleared would land afterwards and silently restore what they removed. Writes now carry the generation they started in; a write that outlives a clear is dropped (PWA) or undone (Electron). - The PWA byte count used String.length, i.e. UTF-16 code units, so localized payloads under-reported and disagreed with the SQLite BLOB byte count. TextEncoder now measures actual bytes. - A failed stats read returned a valid zero-entry result, so the panel claimed an empty cache and disabled Clear while rows were still there. getStats/clear now return null on failure and the panel says so instead of inventing state. - No behavioural coverage existed for either side. Tests: SQL ops (entry/byte reporting, empty table, missing row, delete count) and the service (encoded bytes, clear count, and a write racing a clear). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): make the cache clear precise and version skew visible Review follow-ups on the cache panel: - A write that was in flight when the user cleared used to trigger a second full-table clear once it landed, which also deleted anything written in between. clear() now waits for the writes issued before it and lets the single clear take them; later writes survive. - An Electron shell without the maintenance ops fell through to the renderer map, which is always empty there — it reported an empty cache and disabled the Clear button while SQLite was full. Both operations now report unsupported instead. - Component coverage for the panel (deferred scan, clear + re-read, failed clear, failed read) and Electron-path service coverage. - The canonical IPC and settings sections of the enrichment doc, plus the matching CLAUDE.md lines, now list the maintenance ops. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): drop Promise.allSettled from the cache clear The web target compiles against lib es2018, so allSettled broke the Windows frontend build (TS2550). The pending writes swallow their own errors, so a plain Promise.all over neutralized promises does the job. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): keep a synchronous bridge throw inside the cache write Moving the write into a tracked promise dropped the try/catch that used to cover the call itself, so a bridge that threw synchronously would escape set(). Wrap it in an async IIFE, which turns that back into a rejection the same handler swallows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): retry the cache size read when the section is reopened The effect skipped the read once cacheError was set, so one transient IPC failure left the panel showing "could not read the cache" for the life of the settings page — and the only enabled control that could shift it was the destructive Clear button. Gate on the stats signal alone: reopening the section retries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): queue writes that start while the cache is being cleared Awaiting the in-flight writes closed one side of the race and left the other open: a set() that started during that wait dispatched its IPC immediately, was absent from the snapshot, and could reach SQLite just before the delete — so a row written after the user clicked Clear was removed anyway. clear() now holds its own promise for the whole operation and set() waits on it, which puts such a write on the far side of the delete. Rows are stamped when they are dispatched rather than when set() was called, since a write may have waited. Covered by a test that fails without the guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(tmdb): add the release note for the cache panel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(tmdb): cover the cache panel with an Electron E2E The panel drives IPC and SQLite, and nothing exercised that path end to end. The new test seeds a row through the preload bridge — enrichment itself needs a TMDB key that CI does not have — then opens the section, asserts the reported size, clears, and reads the database back to confirm the row is gone rather than merely hidden. Verified both ways: dropping the DELETE from clearTmdbMetadata fails it. Settings nav buttons gained a data-test-id so the section can be opened without matching translated labels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
d76b2d2a57 |
fix(tmdb): stop a broken provider tmdb_id from suppressing enrichment (#1239)
* fix(tmdb): stop a broken provider tmdb_id from suppressing enrichment
Providers ship dead and stale tmdb_id values, and enrich() trusted them
unconditionally:
parseProviderTmdbId(query.tmdbId) ?? await resolveIdBySearch(...)
A garbage-but-integer id short-circuited the title search entirely. The
details fetch then 404'd, the outer catch swallowed it, and the item was
left permanently unenriched — no plot, no cast, no artwork — for a title
the search would have matched. Failed detail fetches cache nothing, so
the wasted request repeated on every re-open. The stale-but-valid case
was worse: it never threw, nothing sanity-checked the resolved title, and
we confidently rendered another film's metadata.
enrich() now treats the provider id as a hint. If it fails to resolve, or
resolves to something whose title matches none of the search variants we
would have queried, the confidence-gated title search gets its turn — and
proven-bad ids are negative-cached (7d, language-independent row) so the
404 is not repeated forever.
Deliberately NOT a hard rejection on title mismatch: TMDB returns titles
in the REQUEST language, so a Russian provider title legitimately fails
the name check against an en-US payload. A mismatch only lets the search
compete; when the search finds nothing confident, the provider payload is
kept. The change can therefore only add enrichment, never remove it.
Extracts the search resolution and the bad-id cache into
TmdbIdResolverService — tmdb-enrichment.service.ts was at 290 lines
against the 300-line target, and the resolver is independently testable.
Tests: new tmdb-enrichment.service.spec.ts covers the happy path issuing
exactly one details call and no search, 404 fallback, stale-id override,
the keep-the-payload safety property, bad-id skip, and the no-match case;
matcher spec covers detailsMatchProviderTitle and the namespaced cache key.
Refs docs/architecture/tmdb-roadmap.md A1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): only blame a provider id when TMDB confirms it does not exist
Review found the bad-id negative cache too eager in two ways, both of
which could deny enrichment to items whose id was fine.
1. Any failure recorded the verdict. A 401, 429, 5xx or an offline blip
would mark a perfectly valid id as dead for seven days, so after the
service recovered — or the user fixed their API key — titles that the
search cannot resolve confidently stayed unenriched until the marker
expired. TmdbApiService now throws a typed TmdbApiError carrying the
status, and only a confirmed 404 is recorded.
2. Title mismatches were recorded too. That id EXISTS; it is merely wrong
for this item. The row is keyed by id alone and shared across
playlists, so a stale mapping on one item disabled the direct lookup
for every other item that legitimately used the same id. Mismatches
are no longer cached at all — the search verdict is cached anyway, so
the repeat cost is a single details fetch.
Documents the row kind in the cache contract, which listed only two of
the (now six) lookup_key shapes.
Tests: 404 records, 429 does not, network error does not, mismatch does
not.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): keep provider details when the competing search fails
detailsForProviderId only runs the search to see whether it can beat a
title-mismatched provider payload. A throw from that best-effort search
(offline, rate limit, 5xx) propagated to enrich()'s outer catch and threw
away details we already had — the searched-details fetch right below it
was already tolerant. Fail to the details in hand instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): decide a suspect provider id on evidence, not on the title
The title check alone was both too weak and too dangerous.
Too weak: normalizeTitle strips trailing years, so "Blade Runner 2049"
carrying the 1982 film's id matched and the wrong film was rendered —
exactly the stale-id case this was meant to catch.
Too dangerous: an ALL-CAPS leading token reads as a language tag, so
"IT - Chapter Two" normalizes to "chapter two". The correct payload
failed the name check, and a year-less search for "chapter two" would
confidently return the 1979 film and overwrite it. Master trusted the
provider id here and got it right.
assessProviderId weighs both signals: title or year agrees means use the
details; both years known and incompatible means the search may take
over; a title-only mismatch is inconclusive and keeps the details. The
search branch now always has a year, so its own gate corroborates
whatever it returns instead of matching on name alone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): do not search after a transient provider-id failure
enrich() reads a null from detailsForProviderId as "the id is unusable,
try the search". A 401/429/5xx/offline failure gave it that null, so an
outage turned into a second request that would fail too — and if it did
come back, a title match replaced a provider id that was probably fine.
Only a 404 falls through to the search now; everything else rethrows and
leaves the id retryable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(tmdb): add the release note for the provider-id fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
6bdd6fd8a8 |
fix(playlists): serialize per-playlist collection writes to prevent lost updates (#1255)
* fix(playlists): serialize per-playlist collection writes to prevent lost updates All per-playlist mutations (portal favorites, recently viewed, playlist meta/favorites updates) used an uncoordinated read -> patch -> replace-whole-row pattern, so two overlapping mutations on the same playlist were last-write-wins and silently dropped each other's changes (flagged by Greptile on PR #1253). Chain every read-modify-write through a per-playlist promise queue (Map<playlistId, Promise>) inside defer(), covering both the SQLite upsert and IndexedDB update paths while keeping the Observable-based public API, laziness, and emitted values unchanged. A failed mutation does not wedge the queue, and different playlists are not serialized against each other. Regression coverage: overlapping favorite+recently-viewed adds, two rapid favorite adds, IndexedDB-path overlap (all three fail on the old code), plus queue-continues-after-error and cross-playlist independence guards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): close serialization gaps flagged by review bots Greptile P1 (writers bypassing the queue): route updateManyPlaylists and updatePlaylistPositions through the same per-playlist write queue. Auto-refresh batch writes now re-read the stored row inside the queue and preserve user-owned fields (favorites, recently viewed, position) instead of writing a pre-refresh snapshot over them; position updates re-read and patch inside the queue on both storage paths. Codex P1 (callers precompute stale snapshots): add an atomic PlaylistsService.transformPlaylistFavorites(playlistId, transform) that applies the favorites transform to the freshly-read row inside the queue, and convert every read-then-set call site to it: UnifiedFavoritesDataService M3U add/remove/clear/reorder and Stalker reorder/clear, GlobalFavoritesService M3U removal, DashboardDataService M3U removal. Reorders now keep concurrently added favorites (appended after the dragged order) instead of dropping them. New coverage: overlapping favorites transforms, auto-refresh batch write vs queued favorite add, position update vs queued favorite add, and a public addFavorite race through UnifiedFavoritesDataService; existing specs updated to the transform-based contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): share the canonical refresh merge with the auto-refresh batch The batch path previously spread the stale refresh snapshot over the freshly read row and pinned only favorites/recently-viewed/position, so a queued metadata mutation (hiddenGroupTitles, curated EPG sources) finishing before the refresh write could be reverted. Extract updatePlaylist's merge into mergeRefreshedPlaylist() and use it for both the single-playlist update flow and updateManyPlaylists: refresh-owned data (parsed content, count, EPG detection) comes from the payload, user-owned state comes from the current row, and manual/disabled EPG configuration is resolved through resolvePlaylistEpgSourceState instead of being overwritten. Regression test: queued hiddenGroupTitles meta update overlapping an auto-refresh batch write keeps both the metadata change and the refreshed content, including preserved manualEpgUrls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): let the current row decide autoRefresh during batch refresh The batch path force-set autoRefresh: true after the merge, so disabling auto-refresh while a refresh was in flight was reverted by the completing write. Drop the override — mergeRefreshedPlaylist already prefers the current row's autoRefresh over the snapshot — and add a count fallback to the snapshot value for rows without a stored copy. Regression test: disabling auto-refresh concurrently with the batch write keeps autoRefresh false while still applying the refreshed content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9885178f32 |
fix(stalker): refresh stale embedded-series snapshots from favorites and dashboard (#1253)
Favorites and recently-viewed rows store Stalker items as full JSON snapshots, so a vclub-style embedded series[] episode list froze at the moment the row was written: a series favorited when only episode 1 was out kept showing one episode forever when opened from favorites, recents, Continue Watching, or any dashboard rail. New withStalkerSnapshotRefresh() store feature renders the stored snapshot immediately and re-fetches the item from the portal in the background via a title search (get_ordered_list&type=vod&search=..., matched by id, paginated up to 5 pages, wildcard-category retry), patching fresh episodes and cmd into the active selection. The patch is guarded on both the item id and the active playlist id, since Stalker ids are only unique per portal. Only the in-memory selection is patched — the stored snapshot row is deliberately left alone, because every entry path into the detail view runs this refresh and writing it back would add an uncontrolled background writer to the whole-playlist read-modify-write that every favorite/recent mutation performs. Also fixes the stalker-mock-server embedded-series scenario, which generated series[] as objects the app's vclub adapters filter out instead of the episode-number arrays real portals send. Regular type=series and Ministra is_series items are unaffected; Xtream is unaffected (get_series_info is never cached). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4d63f76407 |
perf(stalker): skip wasted series-seasons request for non-series items (#1241)
`setSelectedItem` mirrored every selection's id into `selectedSerialId`, and `serialSeasonsResource` fires a `get_ordered_list&type=series&movie_id=<id>` portal request on every change of that id. Opening any Stalker detail page — plain VOD, vclub items with embedded `series[]` (whose result `mapRegularSeriesSeasons` discards), Ministra `is_series` items, and ITV channel clicks — therefore issued a pointless request, on every entry path (browse, favorites, recent, dashboard, search). Set `selectedSerialId` only when `selectedContentType === 'series'`, clearing it otherwise. The gate is deliberately on content type alone, not item shape: under the `series` content type `serialSeasonsResource` is the only episode source (the detail templates render `<app-stalker-series-view />` with no `vodWithSeries` input, and `isVodSeries()` requires content type `vod`), so gating on `is_series`/`series[]` would leave a series-section item carrying either field with a silently empty episode list. Adds selection-state and request-level regression coverage, and corrects a stale invariant in the Stalker architecture docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8e1320cb34 |
feat(tmdb): series production-status chip and person death dates (#1240)
Two fields TMDB already sends us and the merge threw away — no new API calls, no cache-key bump, they light up on existing cached payloads. Series detail views (Xtream and Stalker) gain a production-status chip: "Ended" tells you a show is finished before you commit to it, "Returning" that it is not. TMDB returns `status` as an ENGLISH string even under language=ru-RU, so it is normalized to a stable token (normalizeSeriesStatus) and rendered through translated labels (seriesStatusLabelKey). Unknown values are dropped rather than shown, so a status TMDB adds later can never leak raw English into 19 locales. Person pages render `deathday`, which mapPersonProfile has always parsed into ActorProfile and no template ever read. i18n: 7 keys across all 19 locales via the tools/i18n workflow. Tests: status normalization (token mapping, case-insensitivity, the British "cancelled" spelling, unknown/missing dropped). Docs: tmdb-metadata-enrichment.md, CLAUDE.md. Refs docs/architecture/tmdb-roadmap.md C1 and the zero-extra-call tier. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |