Commit Graph
147 Commits
Author SHA1 Message Date
4gray d2a83164ec feat(stalker): protocol-correct auth lifecycle (#1354) 2026-08-03 23:06:47 +02:00
4gray 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
2026-08-03 08:53:44 +02:00
4grayandClaude Fable 5 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>
2026-08-02 18:01:45 +02:00
4grayandClaude Fable 5 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>
2026-08-02 13:24:16 +02:00
4gray 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
2026-08-01 18:09:31 +02:00
4gray 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
2026-07-31 08:00:59 +02:00
4gray 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
2026-07-30 07:40:03 +02:00
4grayandClaude Opus 5 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 4db3a2fd and sits on the line directly below.
Answered on the thread rather than changed.

The host service passed 400 lines again, so the pin, probe, switch-notice and
current-row concerns moved into focused modules beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(portals): record the behaviour the review rounds changed

The architecture doc and CLAUDE.md described the feature as first written, not
as it now behaves: pins were documented as a stored preference without saying
they decide playback, failover was described as stopping at the first
unresolvable candidate, the probe as HEAD-only, and discovery as pure FTS with
no mention that short titles cannot be tokenized at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): invalidate the session while the movie identity is empty

The staleness guard added in 4db3a2fd bumped the session only inside `load()`,
which leaves a window the guard does not cover: route navigation empties the
movie identity first, and `load()` for the replacement runs only once a title
is knowable again. A resolution completing in that interval still carried a
session number that matched, so it passed the check and started the previous
movie's source over the page the user was navigating to.

The binding effect now bumps the session as soon as the identity goes null, so
anything already in flight is invalidated at the moment the old movie stops
being the one on screen rather than when the next one finishes loading.

`lastMovieKey` is deliberately left alone: returning to the same movie should
not re-run discovery, and the controller's state is still correct — only the
in-flight operations needed invalidating.

Regression test added and mutation-checked: removing the bump fails exactly
that test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop the source list from losing the real alternatives

Six review findings, all in how multi-source decides what to show and what
it is playing.

Discovery: the current playlist is now excluded in SQL rather than after the
fact, so its own duplicate rows can no longer spend the whole row budget
before a single alternative is read. The short-title scan matches the token
as a word instead of a substring and orders by title length in a wider
window, so "Titanic" and "The Italian Job" cannot push the real "It" out of
it.

Session: metadata enrichment re-runs discovery for the film already on
screen. That is a refresh, not a new session — a second identity key
(playlistId:contentId) now separates the two, so the source the user
switched to keeps playing and stays named, the tried set stays burned, the
position survives and a switch in flight still commits.

Resume: the multi-source controller no longer records the engine's pre-seek
timeupdate at ~0. The playback service's one-shot latch now reports whether
the position can be believed, and until it can, the requested start time
stands in — so a switch during the initial seek does not restart the film.

UI: the in-player sources picker gets the same auto-failover setting and
match kind as the detail page's, instead of always rendering the default and
dropping the toggle. The caption counts distinct playlists, not stream
variants, since the popover groups a portal's copies under that portal.

Session mechanics and the pin toggle move into their own modules to keep the
host service inside the line budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep the playing row when the refined year rejects it

Follow-on from keeping the session across a rediscovery. The rerun can
legitimately drop the row that is playing: enrichment supplies the release
year, and the year gate then rejects a copy the yearless search had admitted
— "Dune" 1984 while the user is watching the 2021 film.

Off the list is right; it is not the same film. Off the screen is not. It is
what is streaming, so it stays as a row and keeps the playing badge, rather
than letting the caption name a playlist that is not sending any bytes.

Also covers the new session key directly in the identity spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop a pin write from landing on the next movie

Two findings from the review of the previous round.

A pin write is an IPC round-trip, and the user can navigate during it. The
continuation then applied one film's answer to another film's controller —
and because unpinning returns "nothing pinned", it would clear the pin the
new movie had just loaded and its Play action would quietly stop starting
from the preferred source. It now commits only while the same film is still
on screen, like every other async path here.

The short-title scan drops its row limit. FTS keeps its window because it
ranks by relevance, so what it keeps is what matters; a scan cannot rank, so
a window there silently decides which valid sources the user is allowed to
see. It also bought nothing: the GLOB cannot use an index, so SQLite reads
every row either way and the limit only truncated the answer. What bounds
the scan is its predicate — reaching it means the whole title is one or two
characters.

The switch-notice type moves to the module that builds it, which also
removes a circular type import between the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep external playback, the pin and the resume point honest

Five findings from the round-5 review.

An external player launched for an alternative carries that playlist's ids,
so the page disowned its own session: the primary button never became Stop,
stopping found nothing to stop, and another click opened a second player.
Multi-source now tells playback which source is actually active, and the
matcher accepts either that or the route's own stream.

Stop also has to beat the pin. The primary action consults the pin first —
that is what makes a pin decide where playback starts — but while a session
is running the same button reads Stop, and consulting the pin there made the
control do the opposite of its label.

A pinned source started from the Resume button resolved at zero, because
nothing reports a live position until the first timeupdate. The controller is
now seeded from the persisted position, one-way: a live value always wins,
since the stored one lags it and applying it would rewind.

A pin whose write failed was still shown as pinned, promising a preference
that reopening the movie would not have.

Portal failures in this path logged raw errors. An Xtream error message
carries the stream URL, and that URL is built out of the username and
password, so they now go through the redacting logger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): make every alias of a pin agree, and stop losing rows

Four findings from the latest review pass.

A pin lookup accepts several aliases of the same movie, but a write only
touched the most-trusted one — so after enrichment the title alias still
pointed at whatever was pinned before, and a reopen that read it (because
TMDB had not landed yet, or its request failed) started the source the user
had just replaced. Writes now go to every alias.

That alias set was also missing one. Enrichment supplies the year as well as
the id, so a pin set before either existed is stored yearless; the candidate
list skipped that form entirely and orphaned the row.

Discovery could lose whole playlists: one playlist listing a film in dozens
of categories produces identically ranked rows that fill the window before
another playlist is read. The collapse now happens in SQL, before the limit,
rather than in TypeScript afterwards where the missing rows are already gone.

And an abandoned source pick finishing late cleared the spinner from the row
the user was actually waiting on.

Removes `isExhausted()` from the host service — no caller outside its own
tests, where the assertion above it already proved the same thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): probe like playback, and stop the pin answering for a remake

Five findings from the latest review pass.

Writing a pin to every alias — last round's fix for stale aliases — was
wrong in the other direction: `title:{base}:` is shared by every remake, so
a known-year decision stored there answers for a different film. Pin Dune
(2021), open Dune (1984) before its year arrives, and it would start the
2021 source. A write now clears every alias and stores only the canonical
key, which retires the stale ones without making any of them ambiguous.

The probe checked a bare URL while playback sends the playlist's User-Agent,
Referer and Origin. A panel that requires them answers 401/403, so a stream
that plays perfectly was reported dead and penalised in failover ranking.

The switch toast interpolated the raw playlist name. Users routinely name a
playlist after the URL they pasted, so that line could put credentials over
the video; the notice now carries the same safe label the rows use.

External players have no timeupdate, so their polled position IS the live
one. Feeding it through the seed — which stops at the first value — froze
the resume point where playback started, and a switch an hour in rewound to
the beginning.

And auto-failover concluded "nowhere to go" when a stream failed before
discovery answered, stranding the user on the error screen.

Moves `switchTo` into the session module, which is where the rest of the
switch mechanics already live, and splits the route spec along the same
rendering/behaviour seam the other suites use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): re-check the movie after waiting, and follow the alternative

Three findings, two of them regressions from the previous round.

Awaiting a pending discovery before failover let the user navigate during
that wait: the continuation then ran against whatever controller was current
and could answer one film's playback failure by starting another film's
alternative. Both waits — failover and pinned Play — now re-check that the
same movie still owns the screen.

Pinned Play also needed the wait it did not have. Pressing Play while the
pin lookup was still out concluded "nothing is pinned" and started the
route's own source, making a persisted preference depend on worker latency.

And the position bridge still accepted only the route's ids, so an external
player running an alternative had every progress update discarded: the
resume point stayed where playback began and a switch an hour in rewound the
lot. The session matcher and the bridge now share one ownership predicate,
since a page that shows Stop for a session whose progress it throws away is
the bug in two halves.

The test for the external case previously set the position signal directly,
which bypassed the very filter that was broken; it now drives the bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop a remake matching, and let a pin survive its own playlist

Three findings from the latest review pass.

`normalizeTitleKeys` strips bracketed segments as tag noise, so "Dune (1984)"
normalizes to exactly "dune" — an EXACT match for the 2021 film, ranked above
every fuzzy one, with the year never consulted because that tier skipped the
gate. Auto-failover could switch the user to the other film entirely. The
year is now read out of brackets too, and a stated disagreement rejects the
row on either tier.

Playback positions are keyed by (playlist, stream), so watching through a
pinned alternative stores progress under ITS ids while the page loads the
route copy's row. Starting the pin therefore resumed from a position
belonging to a different copy — usually zero. It now loads its own.

And a pin can point at another copy of the film inside the playlist being
viewed, which discovery excludes wholesale: the pinned row was absent from
the list, so nothing showed as pinned and Play ignored the preference. The
pin is now read before discovery, which keeps that one row.

Moves the pin-shaped decisions into the pin module, where the persistence
helpers already live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep a pinned play, a same-playlist copy and Check honest

Five findings from the latest review pass.

Reading a pinned source's own position is a database round-trip, and the user
can navigate across it — the continuation then handed one film's source id to
whichever movie now owned the screen. Guarded, like every other await here.

Allowing a pinned copy to live in the current playlist made "is this the
route's own source?" a two-part question, and the ownership check still asked
only about the playlist: an external session for that copy was disowned, so
Stop vanished and its progress was dropped.

The yearless title alias is shared by every remake, so clearing every alias
before a write could delete a different film's pin. Writes and unpins now
touch only keys that name one film — plus the ambiguous row this session
actually read, which is the one the user is looking at and the one whose
absence would make an unpin come back.

Restart left the seeded position in the controller, so a failure before the
first timeupdate resolved the next source back at it.

And the alternative rows on the playback-error screen had a Check button
wired to nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop a rediscovery restoring the pin it started with

A same-movie rediscovery read the pin, then held that snapshot across its
source lookup and applied it afterwards. A pin made while the lookup was out
was therefore overwritten by the older value: the row and the primary Play
action named a source the database no longer held.

The snapshot is now applied as soon as it is read, so a later write simply
wins on ordering rather than needing to be detected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): write the pin before retiring it, and keep the badge honest

Three findings from the latest review pass.

Repinning cleared the old rows and then wrote the new one, so a write that
failed after the clear left nothing persisted while the row still showed the
old pin. The order is reversed: the new key is stored first and the stale
ones retired only once it landed. Lookups are most-trusted-first, so a
leftover alias never outranks what was just written.

Starting a source from the picker, or letting a pin decide the primary Play,
never recorded the movie as recently viewed — unlike every other way of
playing it.

And closing an alternative's player and pressing Play started the route
stream while the controller still marked the alternative active, so the
picker and caption named a source that was not running.

Moves the discovery pass into the session module beside the switch and
failover mechanics, and splits the pin spec along the persistence/playback
seam, both to stay inside the file-size rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop claiming playback, a cached answer and a resolution

Three findings, all of them the same rule: never state as fact something the
app has not established.

The "Playing from …" caption appeared as soon as discovery marked a source
active — before Play was pressed, and again after the player was closed. It
now requires a player that is actually running.

Probe answers were cached by URL alone, but the request now carries the
playlist's headers. Two playlists sharing a stream URL could therefore be
told the other's answer, marking a source dead without ever asking it.

And any width below 900 was labelled 480p, published with `api` provenance:
a 640x360 stream stated 480p as a fact, and a 720x576 PAL source likewise.
Widths below HD only resolve with the height — 720 is NTSC 480p or PAL 576p
— so an unrecognised shape now carries no quality tag at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): match the sub-HD formats, and drop the caption on failure

Two follow-ups to the previous round, both the same rule again.

The 800-wide band still answered from the width alone, so 800x600 and
800x450 were labelled 480p — published with `api` provenance, so read as a
measurement. Sub-HD formats are now matched against known shapes with the
same 5% tolerance the height path uses, and anything unrecognised carries no
tag at all.

And "Playing from ..." survived a playback failure: the inline host stays
mounted while the diagnostic is on screen, so the page named a source for a
stream it had just reported it could not play. The caption now clears on
failure and returns when the engine produces time again.

Splits the route playback spec along the "what it does" / "what it claims"
seam and lifts the repeated active-source stub into one helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): let the height veto a width match, and hold the failure state

Two follow-ups to the previous round, both in code it introduced.

A width that matched exactly one sub-HD format ignored the height entirely,
so 640x480 came back as 360p — a measurement the numbers contradict. The
height now vetoes, but only in the direction that can be wrong: cropping
removes lines, so a SHORTER frame is a letterboxed master of that format and
the width still names it, while a taller one is a different shape and gets
no tag. That keeps the reason width is preferred in the first place.

And picking a source off the error screen cleared the failure state before
the switch resolved, so an alternative that could not be resolved left the
diagnostic on screen while the caption went back to claiming playback. The
flag now clears only once a switch actually starts something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): release the resume latch when the target cannot be reached

Carrying a position into a shorter cut of the same film — two hours into a
90-minute source — leaves the engine unable to ever report that time, so the
one-shot latch never released: every position save was suppressed for the
rest of the session, and the impossible start time kept being reported to
multi-source for the next switch.

The latch now also opens when a known duration puts the requested point out
of reach, while a reachable one still waits as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): say "Playing" only while something is playing

`isActive` means "the source a switch or Play would use". Discovery sets it
the moment the page opens and it survives closing the player, so it could not
back the two claims the UI made in the present tense: the "Playing from"
caption and the source row's Playing badge. Both appeared on a page where
nothing had started, and came back after the player was closed.

`playbackLive` is now that statement, and both read it. Inline it needs a
timeupdate — `inlinePlayback()` is only the REQUEST to play, non-null while
the engine is still opening the stream and still non-null after it fails —
and external it needs the session past `launching`. A row that is merely
selected reads "Current" (new key, filled for all 19 locales).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): start a never-watched pinned source from the beginning

Positions are keyed by (playlist, stream). When the pin points at a copy the
user has never opened, the lookup returns nothing and the controller was left
holding the ROUTE copy's position — so Play dropped them 42 minutes into an
unstarted film, and the first save wrote that timecode back under the pinned
source's key, making it permanent.

The spec asserted the old behaviour, so it is flipped rather than extended; a
second case covers the host that supplies no lookup at all, where "never
watched" was never established and the position must be left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): describe the copy the primary button will actually play

Two gaps found by review.

A pin makes the primary button play a copy the page never loaded a position
for — positions are keyed by (playlist, stream). The label, timecode and
Restart affordance still came from the route copy's row, so the button could
read "Resume 42:18" and start an unwatched copy at zero, or read "Play" and
jump into the middle of one already watched. `createPrimaryActionPosition`
lets the pinned copy's row govern, including when that row is absent: never
watched is an answer, not a fallback to someone else's progress.

A manual source switch also mounts a DIFFERENT stream in the same host while
marking the new source active at once, so the previous stream's timeupdate was
still vouching for it — the caption and the badge claimed the new source while
it was still opening. That path now clears the latch like Play and Restart do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep the route's own resume point, and honour a closed pin

Two more from review, both variations on "selected is not playing".

`vodPlaybackPosition` followed whichever copy last reported — so after an
alternative played, Resume and its label described that copy's row while
starting the route's stream, jumping it to a timecode nobody reached in it.
It now splits: `vodPlaybackPosition` stays the last position seen (the
progress bar and the switch handoff want the stream on screen), and
`routePlaybackPosition` holds the route copy's own row for everything that
acts on the route's stream.

`pinnedSourceAwaitingPlay` skipped the pin whenever its row was active, but
`isActive` means selected — the pinned row stays selected after its player is
closed, so the next Play went to the route copy and ignored the stored
preference until the page was reopened. It now takes `playbackLive` too.

The host service crossed the 400-line cap on the way, so the four derived
alternative counts moved into `vod-multi-source-counts.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep the primary button honest across navigation and pins

Four follow-ups from review, all consequences of splitting the position
signals.

- Route reuse (the Similar rail) cleared only `vodPlaybackPosition`, so the
  button kept the previous movie's Resume label — and start point — until the
  new lookup landed. Both signals and the playback latch now reset together.
- The primary button's fall-through past an unresolvable pin reached the
  service directly, skipping the bookkeeping a route start needs: the
  controller kept the alternative's timecode and the old stream's timeupdate
  still vouched for the new one. It now goes through the route's own wrappers,
  and Resume seeds the controller with the ROUTE copy's position.
- `alternativePlaylistCount` counted the playlist being watched whenever it
  held a second copy, so "also found in 2 other playlists" could mean one.
- The pinned copy's stored row went stale the moment the user watched it; its
  live position now wins while it is the one playing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): do not spend a source's failover turn on mere selection

`setActiveSource` marked the source tried, but discovery calls it the moment
the page opens and a pin or the picker can call it before anything plays. So
opening a movie burned the route copy's turn: if a pinned alternative then
failed, failover skipped a healthy untouched source — and with only one
alternative, reported the options exhausted.

Selection and attempt are now separate. `setActiveSource` selects;
`markPlaying` also spends the turn, and only the three places that really
start playback call it. `runFailover` additionally retires whatever is on
screen before picking, so the failing source is spent however it got there —
relying on the start paths alone would leave one hole per path, and the cost
of missing it is a ping-pong between two sources.

One existing spec asserted the old behaviour (a route copy burned by a switch
it never played); it now plays first, so it still covers what it meant to —
that the tried set survives a rediscovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(portals): carry VOD source pins through playlist backup

The new pins table was invisible to backup: exporting a playlist and
re-importing it on a new machine silently dropped every "main source" choice,
with nothing in the archive to say the choice had ever been made.

Pins now ride along under the playlist they point AT — carrying them anywhere
else would restore a preference for a portal the archive never contained.
`matchKey` names the film rather than the portal, so it survives untouched and
only the playlist id is remapped to the imported copy.

`sourcePins` is the one optional collection in the Xtream user state: archives
written before multi-source existed simply do not have it, so its absence is
age rather than damage. Only a wrong type is rejected, and pins without a
usable match key or content id are dropped, since writing one would occupy the
unique key of a film it does not describe.

Adds `DB_LIST_VOD_SOURCE_PINS` through the usual six seams (operation, worker
case, event, preload, bridge contract, service).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(portals): follow the normalized restore state's new collection

`normalizeXtreamPendingRestoreState` now always emits `sourcePins`, like every
other collection it canonicalizes, so three specs that assert the exact
normalized shape had to follow. Adds coverage for the sanitizing itself: a pin
without a usable match key or content id is dropped, and a non-string
`updatedAt` is discarded rather than carried.

Caught by CI, not locally — the earlier full run served `playlist-shared-ui`
from the Nx cache, so it reported green on a stale result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(portals): lift the VOD route's orchestration out of the component

The details route had grown to 864 lines — the repository's hard maximum is
400, and while the file predates the rule, a baselined exemption is not a
budget to spend.

Three component-provided services now hold what the component was
accumulating: `VodDetailsMultiSourceUiService` (the playback-evidence latch,
the caption, the primary button's position, source actions and the failover
toast), `VodDetailsSimilarService` (the rail and its cross-portal lookup), and
`VodDetailsDownloadsService`. The component keeps its public API, so the
template and the existing specs are untouched. 864 -> 566 lines.

The downloads move also fixes a latent bug: `downloadVod` and `playFromLocal`
read `route.snapshot.params`, which is stale once the router reuses this
component for detail-to-detail navigation (the Similar rail) — so a download
started from a film reached that way fetched the previous one. They now read
the same route-params signal everything else uses, with a regression test.

Also from review: pins are applied on the FRESH-import path too. A new
playlist has no content when the archive is read, so its user state is parked
and replayed after the import — the merge path I wired first never ran there,
and every pin was dropped. A failed pin write now propagates instead of being
ignored: the backup entry is reported failed, and the parked state is kept so
a transient failure can be retried rather than silently losing the preference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): read array-shaped codecs, bound short-title scans, honour alias clears

Three from review.

`info.video`/`info.audio` are declared — and sent by the mock server and many
panels — as string arrays, but the resolver only read the ffprobe object shape.
Every array response therefore lost the provider's codec, so those source rows
showed no codec fact and the "dub may differ" warning could never fire.
`readStreamInfo` now accepts both, and states nothing when the provider stated
nothing.

The FTS-empty fallback scan matched only the FIRST token, which is fine for a
one-word short title but not for "I Am": every catalog row containing the word
"i" came back for TypeScript to throw away — a full scan of a large catalog on
the single database worker, just to open a detail page. Every token must now
appear.

`writePin` reported success when the canonical write landed but retiring the
old alias failed. Lookups read aliases before the canonical key, so reopening
the movie before enrichment would start the source the user just replaced,
with the icon promising otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): write a pin and retire its aliases in one transaction

Split across two calls, a half-failure had no honest outcome. Reporting
success left a surviving alias to win the next lookup and start the source the
user had just replaced; reporting failure — which the previous round changed
it to — left the canonical row durable while the UI showed a pin that was no
longer the stored one. Review was right both times, which is the tell that the
two-step shape was the problem.

`setVodSourcePin` now takes the keys to retire and does both inside one
`db.transaction()`, with the synchronous `.run()` form the better-sqlite3
driver requires there (issue #1137's lesson). `retireKeys` rides through the
worker op, the IPC contract, the preload bridge and the service, so there is
one call and one outcome.

Also corrects the architecture doc: the scan path is reached whenever no token
clears the trigram minimum, not only when the whole title is one or two
characters — the claim the previous commit's code change had already falsified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): tell a superseded pinned play from an unusable pin

Double-clicking Play while a pinned source resolves put both handlers into
`playPinnedSource()`. The second supersedes the first, so the first returned
`false` — which the route read as "no usable pin" and answered by starting the
route source over the playback the second click had just begun.

`playPinnedSource` now reports `played` / `superseded` / `unavailable`, and
only `unavailable` falls through. This is the same distinction `runFailover`
already draws between "keep going" and "stop, something newer owns the screen";
the pinned path simply never had it.

The host crossed the 400-line cap again on the way, so the pinned-play errand
(wait out an in-flight discovery, re-check the session, start the source) moved
into the pin module beside `playPinned`, and the pin-toggle commit went with
it. 388 lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): restart honours the pin, and a switch replaces the player

Three from review, all in the pinned-playback seam.

A pinned copy watched through resolved to its stored seconds, so the button
read Play — the label uses the in-progress rule — and then started near the
end. Both now go through one `isResumablePosition`, so the label and the start
point cannot disagree.

Restart sat beside a Resume that honours a foreign pin, but called `playVod`
and started the ROUTE copy — silently switching the user's playlist. It now
restarts whatever the primary button acts on, falling back to the route source
only when there is no usable pin.

Switching sources left a running external player alone. With MPV or VLC and
instance reuse off the backend spawns a second detached process, so both
sources kept playing and Stop owned only the newer one.

Also merges master, and puts the five host specs on a shared harness — they
each carried the same 31-line TestBed, which is what pushed two of them over
the file-size cap as cases were added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): find short Unicode titles, and stop two pickers racing

Five from review.

Greptile's P1: a short non-ASCII title was undiscoverable. SQLite's `LOWER()`
and GLOB classes are ASCII-only, so "он" never matched a stored "Он" and the
source simply never appeared. ASCII tokens keep the word-boundary GLOB; a
non-ASCII token falls back to a substring test against both the folded and the
as-typed form, which the normalized confirmation afterwards makes safe.

A probe now retries the ranged GET for 400 and 403, not just 405/501 — those
are what a WAF returns for an unexpected HEAD on a URL it serves happily over
GET, and calling that source dead also ranked it below worse ones.

Three races, all the same shape as ones fixed earlier in this branch:
- a pinned play awaiting its resume lookup did not notice a source picked
  across it, and finished last, replacing the user's choice;
- two overlapping switches both saw the same external session, both awaited
  its close, and both launched — two detached players again;
- the primary button showed the ROUTE copy's Resume while the pinned copy's
  row was still loading, so a click started somewhere else entirely.

Also puts the races spec on the shared host harness, which is what keeps it
inside the file-size rule now that it carries two more cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): close the player we launched, not the one we now own

Three follow-ups, two of them to last round's own fixes.

The external-session close was defeated in exactly the case it was written
for: `switchToSource` marks the DESTINATION active before handing playback
over, so by the time the service ran, the process still playing no longer
looked like ours and was left running beside its replacement. The service now
remembers the ids it launched with, independently of what is active.

The ASCII/Unicode branch was decided from the NORMALIZED token, which folds
diacritics — "Ça" arrived as "ca", looked like plain ASCII, and took the GLOB
path while the stored title still read "Ça". Decided from the raw token now.

Backup restore upserted archived pins but never removed the playlist's
existing ones, so a present-but-empty collection left stale preferences alive
— unlike the playback positions cleared beside it. An absent collection (an
older archive) still means "no opinion" and is left alone.

Four files crossed the size cap on the way; the split ones now share
`title-sources.spec-data.ts` and `playlist-backup.xtream-fixtures.ts`, and the
external-session ownership moved to its own module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): absent is not empty, and every start claims the generation

Four more from review, three of them defects in last round's fixes.

The restore normalizer materialized `sourcePins: []` for archives that never
had the field, so "absent means no opinion" became "this archive says there
are no pins" and a merge cleared the user's. Absent now stays absent. My test
for that behaviour had passed for the wrong reason — it stubbed an empty pin
list, so the clear was skipped whether or not the guard worked.

`startGeneration` was claimed only by the switch path, so a plain Play, Resume
or Restart could be overtaken by a switch still awaiting its close. Every
start claims it now.

Raw and normalized tokens were paired by position, which breaks when
normalization drops a whole word: "FR: Ça" normalizes to "ca" and got handed
the raw token "FR:", sending it down the ASCII branch it cannot match from.
They are paired by normalized form instead.

And the ambiguous yearless alias (`title:dune:`) is no longer written or
retired beside a precise key — it may hold another remake's pre-enrichment
pin. It stays available when it is the only key there is, since refusing to
pin at all would be worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): a failed close must not leave the page claiming a dead source

When `closeSession()` rejected, `startResolvedPlayback` rejected with it and
never launched — while `switchToSource` had already marked the destination
active and reported the switch as successful. The page then named a source
that nothing was playing.

The close failure is logged and the replacement starts anyway. A close that
rejects usually means the session was already gone, and a possibly-lingering
process is the lesser of the two evils: the alternative is a UI that lies
about what is on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(portals): split the external-playback handoff out of the service

Both the service and its spec crossed the 400-line cap with the close-failure
handling, so the handoff — deciding which process is ours, closing it, and
surviving a close that rejects — now lives in
`vod-details-external-session.ts` with its own spec file.

Two tests had to start awaiting: replacing a running external player is a
round-trip, and the handoff now yields once even when there is nothing to
close, so the new playback is mounted a microtask later than before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style(portals): format the extracted external-session module

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): fold diacritics in the title index

Cross-playlist matching compares normalized titles ("Amélie" -> "amelie")
against an index built from the raw title, and the trigram tokenizer does not
fold diacritics by default. Every accented title was therefore invisible to
the FTS path: two identical `Amélie` entries produced no candidates at all.
That is the broadest of the Unicode gaps review found, and it predates the
short-title work.

The tokenizer is fixed at CREATE time, so existing databases recreate and
rebuild the index once behind a migration marker. `remove_diacritics` needs
SQLite 3.45+, so support is probed on a temp table first: an older runtime
keeps its working index untouched and the migration is not recorded as done,
leaving a later version free to upgrade it.

Case folding for non-ASCII remains impossible in stock SQLite — "ОН" cannot
find "Он" by any available predicate — and is documented as the known limit
rather than patched around again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): clear a playlist's pins by playlist, not by key list

Restoring over a playlist reused the keyed clear, which caps its input at
MAX_KEYS_PER_LOOKUP to bound an IN clause. A playlist with more than eight
pinned movies therefore kept the surplus while the call still reported
success, and the restore then wrote the archive's pins on top — leaving the
union of two states, which is neither the one the user asked for.

Clearing is now a dedicated delete-by-playlist operation with no key list to
truncate, and it refuses a blank playlist id rather than deleting everything.
A failure fails the entry instead of being swallowed: `listForPlaylist`
returns `[]` on error and `clear` returns `false`, so ignoring the result made
a failed read indistinguishable from "there was nothing to clear".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep a pin readable under every identity of its film

Two defects in the pin/position subsystem, both reported in review.

A pin was stored under the movie's most-trusted key alone and its other
keys retired. But a movie's identity GROWS: the film keyed `tmdb:438631`
today was `title:dune:2021` before enrichment, and reopening it cold asks
for the poorer key first. The preference was therefore ignored until
enrichment landed — and permanently when enrichment is off or never
answers. The decision is now written under every key in `write` (never
the yearless form, which every remake shares), one upsert per key plus
the leftover retirement in the same transaction. `setVodSourcePin` also
reports failure for a pin with no usable key instead of claiming a write
it never made.

The primary button asked whether the pinned copy's position had loaded
by testing presence rather than identity, so re-pinning left it wearing
the previous copy's timecode until the new lookup returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(portals): record the key-addressing limit a pin write cannot close

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): fold non-ASCII case in the scan, and read years as tags

I was wrong about SQLite twice over, and both errors cost matches.

GLOB character classes are NOT ASCII-only. `patternCompare` reads them as
UTF-8 code points, so `'Он' GLOB '*[Оо][Нн]*'` is true — only `LOWER()` is
ASCII-only. The scan tier now folds the case in JavaScript, where Unicode
case mapping is real, and hands SQLite one class per character. A short
Cyrillic or Greek title stored in a different case is found instead of
being silently absent from the Sources chip. The builder returns `null`,
leaving the substring tests as the whole answer, for a token holding a
GLOB metacharacter (GLOB has no escape character) or a case mapping that
changes length. The FTS tier is untouched and still cannot fold — that
needs a stored normalized-title column.

The movie's own year came from `extractYear`, which reads a year from
anywhere in the title. That is right where a year is a search hint, wrong
where it is an identity: `2001: A Space Odyssey` was treated as a 2001
film, so every genuine 1968 copy failed the year gate and the movie had
no alternatives at all — and its pin key moved the moment enrichment
supplied the real year. `releaseTagYear` accepts only bracketed and
trailing forms; the repo's own TRAILING_YEAR_PATTERN already documented
this exact hazard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): cover a letter spelled two ways in lower case

Greek Σ lowercases to σ, but a word-final sigma is written ς and is
equally a lowercase of it, so a class built only from the character in
hand knew one spelling of two. Each class now also carries the uppercase
form's own lowercase, which reaches the other one.

One-way on purpose: σ → Σ → σ never arrives at ς. Left so because ς is
only correct at the end of a word, which is exactly where the request's
last character sits — the pair that occurs in real titles is covered, and
closing the other direction needs a fold table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): let an exact title keep a number that is part of its name

Both match tiers weighed the same year, taken from a trailing four-digit
tail or a bracketed tag. On the exact tier that rejects the very copy it
was meant to confirm: reaching it means both titles are the SAME string,
so the trailing digits belong to both, and comparing them against a
release year out of metadata makes "Blade Runner 2049" disagree with its
own stated 2017 — the genuine alternative disappears at the moment
enrichment lands, which is when the user has most reason to expect it.

The exact tier now reads the bracketed form only. Brackets are never part
of a name, so "Dune (1984)" is still rejected against 2021. The base tier
is unchanged: it has just stripped a trailing year, and that year is the
only thing separating "Dune 1984" from "Dune 2021".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(database): verify the title index folds, rather than trust the marker

`createTables` declares content_title_fts with the plain trigram
tokenizer, and the diacritics migration declares it again with folding.
Two sources of truth for one tokenizer: if the table ever went missing
after the marker was written, `CREATE TABLE IF NOT EXISTS` would restore
the unfolded form and the migration would skip it on the marker alone.

The upgrade now reads the live table's own DDL from sqlite_master and
rebuilds unless it really folds. A degraded index is invisible from the
outside — discovery just stops finding "Pokémon" for "pokemon" — so the
record has to be checked against the thing it describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): give the route's own row the facts the page already has

Two provenance defects found in review.

The current-source row is never resolved — nothing needs to fetch a URL
for the stream already playing — so it carried no provider metadata at
all, while every alternative got its facts from the resolve preceding
playback. `audioDiffersFactually` requires a fact on BOTH sides, so the
"dub may differ" warning was structurally unreachable on the commonest
switch there is: route to alternative. It could only ever fire between
two alternatives that had both been resolved. The row now carries what
`get_vod_info` already told the page, via a `providerVodMetadataOf`
mapper shared with the resolver so the two cannot describe one movie
differently.

Quality bucketed every width from 900 to 1199 as 576p, so a 960x540
stream — an ordinary 540p encode — was published as "576p" with `api`
provenance: a measurement its own pixels contradict, from the one field
that is supposed to mean the provider said so. That range holds two
standard formats, so it is matched now rather than bucketed, exactly as
the sub-HD sizes already were. A width matching no known format yields
no tag and a check chip, which is the honest answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): let the height veto a width-derived quality, and refresh route facts

Both of these are gaps I saw and chose not to close last round; a
reviewer was right that neither survives its own reasoning.

The shape check only ran below 1200, so the HD ranges kept publishing
wrong-but-confident labels: 1440x1080 is anamorphic 1080 and 1600x900 is
900p, and both were "720p" with `api` provenance — the provenance that
means the provider said so. Ranges are fine up there, the standard widths
really are far apart, but only once a known height can veto the answer.
Same rule the matched formats already used: a shorter frame is a
letterboxed master, a taller one is a different shape and gets no tag.

And the route row picked up provider facts only when discovery reran. On
a sparse panel `get_vod_info` can answer with no year and no TMDB id, so
the movie key is unchanged, nothing reruns, and the row keeps stating
nothing — leaving `audioDiffersFactually` one-sided and the dub warning
unreachable on exactly the switch it exists for. It now takes those facts
on without rediscovering, merged onto the existing row so a probe result
already sitting there survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): a codec is not a dub, and two waits needed a switch guard

Three findings from review.

The "dub may differ" warning compared audio CODECS. AAC and AC3 routinely
carry the same dub, and two AC3 tracks can carry different ones, so it
fired on every identical-language re-encode and stayed silent on the dub
changes it exists for — wrong in both directions, which is worse than
absent, because a warning people learn to ignore is not a warning. Worse,
the previous commit made it reach the common route-to-alternative switch
for the first time, so the false claim was about to get louder.

It now reads a new `audioLanguage`, taken from the track's language tag
and never from the codec. `audio` stays as a display fact. Few panels tag
a language, so the warning is usually silent — the same answer the rest
of this feature gives when it does not know.

`failover()` validated only the session across its wait for a discovery
in flight. The session moves when the FILM does, so a source the user
picked — or the route stream they restarted — during that wait was then
treated as the thing that failed and switched away from. It claims and
rechecks a switch generation, as the pinned path already did.

And the scan's ASCII branch could not find "Ça" from a folded "ca", while
the non-ASCII branch found "Ca" from "Ça" — so whether two playlists
could see each other depended on which one was open. Each ASCII letter
now carries its accented forms, derived by decomposition rather than
tabulated, so it cannot drift from the normalizer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(portals): pin what the declared audio shape can and cannot say

The array shape the mock server and many panels send carries a codec and
no language, so the dub warning is silent for every source arriving that
way. Asserted rather than assumed, alongside the ffprobe shapes that do
carry one — otherwise a later reader sees an unused field and wires the
codec back into the warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): a failed pin read must not export as "no pins"

Three findings, all in code from this session.

Backup called the lenient `listForPlaylist`, which turns a failed read
into `[]`. Since `e0ebbeaf` made restore treat `sourcePins` as
authoritative — clearing the playlist's pins before applying it — an
export whose read failed produced a file that looks complete and wipes
every pin on restore. Losing them is bad; losing them through the one
feature meant to protect them is worse. Backup now uses a strict listing
that throws, so the export fails instead.

The diacritic map stopped at U+024F, which is tidy and leaves Vietnamese
out: `ố` is U+1ED1, the normalizer folds it to `o`, and the scan filtered
those rows out before confirmation. Latin Extended Additional is included
now; the filter decides what belongs, so the range only has to be wide.

And two panels spelling one language differently (`eng` vs `en`, or
`en-US`) raised a dub warning between identical tracks. Tags are
canonicalized before comparison — 639-2 collapses to 639-1, both German
forms meet at `de`, regions drop, and `und` becomes nothing. Anything
that survives longer than three characters is not a language code, so the
comparison is declined rather than guessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop two backup paths from deleting pins they never read

Two data-loss paths, both P1, both mine.

A web export wrote `sourcePins: []`. Pins are Electron-only, so out
there we cannot read them — which is not the same as knowing there are
none, and restore treats the collection as authoritative. A backup made
in the browser was therefore an instruction to delete every pin the
moment it was imported on the desktop. There are three answers here, not
two: pins exist, there are none, and "could not look". The last omits
the field, exactly as an archive written before pins existed does. The
same rule now covers Electron with the bridge method missing.

I had written a test asserting the unreachable-store case resolves to an
empty list "because a backup made there is complete". That reasoning was
wrong: empty was true of what the runtime could see, never of the
playlist.

Restore also cleared the playlist's pins and then wrote the archive's one
by one. A write failing partway left the previous pins already gone and
only a prefix applied — a state belonging to neither, reported as a
failure the user could not undo. `DB_REPLACE_VOD_SOURCE_PINS` does the
clear and every write in one transaction, so the playlist ends up as the
archive describes it or exactly as it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:53:42 +02:00
4gray 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
2026-07-27 23:14:30 +02:00
4gray f9e71a6d8d fix(electron): isolate refresh performance correlation 2026-07-26 22:37:52 +02:00
4gray 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.
2026-07-26 22:27:31 +02:00
4gray f147d4fe37 perf(m3u): stop cancelled refresh workers (#1268) 2026-07-26 09:25:51 +02:00
4grayandClaude Opus 4.8 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>
2026-07-26 01:55:44 +02:00
4grayandClaude Opus 4.8 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>
2026-07-26 01:13:02 +02:00
4grayandClaude Opus 4.8 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>
2026-07-26 00:34:17 +02:00
4grayandClaude Fable 5 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>
2026-07-25 19:24:42 +02:00
4grayandClaude Opus 4.8 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>
2026-07-25 15:50:56 +02:00
4grayandClaude Opus 5 4ca2b6852e feat(playback): Up Next episode rail for the inline series player (#1231)
* feat(playback): Up Next episode rail for the inline series player

On wide windows the inline series player now docks left and fills the
leftover stage column with a Netflix-style "Up Next" rail: the rest of the
current season plus next-season spillover, the playing episode highlighted,
and watch-progress bars from playback positions. Clicking an episode plays
it inline through the host's existing episode flow (Xtream serial-details
and Stalker series view).

- New app-up-next-rail component + buildUpNextRailItems() util in
  ui/playback; entries carry the host's raw episode object so selection
  needs no id lookup.
- PortalInlinePlayerComponent measures the theater stage with a
  ResizeObserver and docks the rail only when the leftover beside the 16:9
  player is >= 320px; narrower stages keep the centered theater/ambient
  behavior from #1223. Movies and live never show the rail.
- New playerUpNextRail setting (Settings > Playback, default on, built-in
  web players only), mirroring playerAmbientMode; enforced at runtime for
  non-web engines.
- i18n: SETTINGS.PLAYER_UP_NEXT_RAIL(+_DESCRIPTION) and PORTALS.UP_NEXT in
  all 18 locales.
- The rail renders as an opaque panel on top of the stage, so the ambient
  fill stays behind it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): address Greptile review on the Up Next rail

- Stage overflow: `.player-shell__viewport` had no border-box sizing (the repo
  has no global reset), so the docked-rail modifier's 12px padding widened the
  stage past its container and the right edge was clipped.
- Width gate: compute the width the rail actually receives (stage minus the
  docked layout's padding, the height-driven 16:9 player, and the flex gap)
  instead of raw stage slack, and observe the stage's border box so the
  modifier's own padding cannot feed back into the measurement.
- Stalker lazy seasons: Ministra VOD-series seasons hold no episodes until
  opened, so the rail's next-season spillover stopped at the current season.
  Prefetch the following season while an episode plays inline.

Adds regression coverage for the gate boundary, gate stability across the
padding toggle, and the lazy-season prefetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stalker): stop the rail spillover prefetch from retrying forever

A failed or genuinely empty Ministra season resets isLoading while leaving
episodes empty, so the prefetch effect re-requested the same season on every
emission for as long as inline playback continued. Remember which seasons this
view already requested and ask at most once each.

Regression test asserts the empty-response case fetches exactly once and does
not retrigger on further playback in the same season.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stalker): let a failed spillover prefetch recover on the next episode

The previous guard was permanent, so a transient network or authorization
failure disabled the rail's next-season prefetch for the component's lifetime.
Distinguish the two outcomes instead:

- Answered (even with zero episodes) — a real answer, never asked again.
- Failed — the claim is released, but pinned to the episode that triggered it,
  so the retry waits for the next playback change. Retrying immediately would
  loop, since the failure itself flips isLoading and re-runs the effect.

The claim is taken synchronously; awaiting first let the isLoading flip re-run
the effect and fire a duplicate request before the answer arrived.

`loadEpisodesForSeason` now reports whether the portal answered; existing
callers ignore the result and are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:16:09 +02:00
4grayandClaude Fable 5 b94f40dc74 feat(i18n): add Hungarian translation (hu) (#1236)
Integrate the community-contributed Hungarian translation by
Tibor Hermann (@htibcsike) as the 19th locale:

- add apps/web/src/assets/i18n/hu.json (1,142 of 1,175 keys translated;
  32 keys added after the contribution fall back to English, plus the
  new LANGUAGES.HUNGARIAN endonym)
- register HUNGARIAN = 'hu' in the Language enum, SUPPORTED_LANGS,
  Angular date locale registration, and the TMDB language map (hu-HU)
- add LANGUAGES.HUNGARIAN = "Magyar" to en.json and all other locales
  via tools/i18n/fill-missing.mjs
- update README.md and CLAUDE.md language counts to 19

Closes #1192, refs #1140.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:20:16 +02:00
4grayandClaude Fable 5 b4c0cce741 fix(backup): export and restore hidden Xtream categories by real xtream IDs (#1224)
Category rows crossed the DB-worker IPC boundary with Drizzle's camelCase
property names while the renderer contracts declare snake_case, so backup
export dropped hidden-category IDs and restore degraded to a type-only
match that hid every category. Project category ops to the declared wire
shape, normalize restore state from untrusted sources (dropping entries
without a numeric xtreamId), reject entries with missing user-state
collections, and add full export→import round-trip coverage (unit
manifest-equality + Electron e2e) plus regression tests.

Closes #1017

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:32:45 +02:00
genrichh93-ui 188f5c4b56 feat(downloads): pause and resume support for the download manager (#1147)
Adds a paused state to the Electron download manager with a full partial-file lifecycle:

- Pause keeps the .part and byte progress; cancel discards them; every lifecycle stage (queued, active, mid-transfer) is pausable.
- Resume continues via HTTP Range with If-Range entity validation (strong ETag / Last-Modified persisted in the new resume_validator column, idempotent migration incl. legacy-table rebuild). Non-206 answers restart from zero over the same .part; the 206 Content-Range offset is verified; responses that end before the advertised size are retained for a Range retry instead of being committed as completed.
- Crash recovery converts interrupted transfers to paused, keeps queued-with-partial rows resumable, and commits finalizations that crashed before the DB update.
- Destination collisions are non-destructive (retained partials finalize to the next numbered name); locked .part files never lose their DB owner across cancel/remove/restart; resume claims rows atomically and the queue dedupes ids.
- Stored request headers are re-filtered through the User-Agent/Origin/Referer allowlist on read, URL-derived extensions are sanitized, resume appends never follow symlinks, and transfer errors are logged by message only.
- UI: pause/resume/cancel/retry/remove surface failures in a snackbar; paused items show an active Resume button in VOD/episode detail views; translations for all 18 locales.
- Runtime split into download-runtime/transfer/finalize/broadcast modules; +30 unit tests and an Electron E2E covering pause -> retained .part -> Range/If-Range resume -> byte-exact assembly.

Co-authored-by: genrichh93-ui <genrichh93@users.noreply.github.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-24 18:40:30 +02:00
4grayandClaude Opus 4.8 5aa44d19d4 feat(tmdb): clickable director/creator chips and directing credits on person pages (#1227)
* feat(tmdb): clickable director/creator chips and directing credits on person pages

Directors were plain merged text — no photos, no navigation — while the
data was already sitting in the cached TMDB payloads (credits.crew and
created_by both carry id + profile_path; they just were not typed or
parsed).

- tmdb-merge: enrichedDirectors (crew, job === 'Director', deduped by
  person id) and enrichedCreators (created_by) produce the same chip
  shape as the cast (TmdbEnrichedCastMember) into a new tmdb_directors
  field on all three merges (Xtream VOD, Xtream series, Stalker); types
  widened (crew id/profile_path, created_by id/profile_path).
- Detail views (shared VodDetailsComponent, Xtream vod/serial routes,
  Stalker series view) render the Director row as clickable avatar chips
  when tmdb_directors is present — same markup and openActor handler as
  the cast strip — falling back to the plain text otherwise. Stalker
  re-normalization allowlist preserves the new field.
- Person pages: mapPersonFilmography now merges combined_credits.crew
  (jobs Director/Creator) into the filmography — acting wins the
  per-title dedup, directing-only titles show the job in the character
  slot. Everything else (library matching, All-portals scope, filters,
  search fallback, back button) works unchanged because the person page
  is role-agnostic. Existing caches work as-is: crew/created_by were
  always part of the stored payloads.

Tests: merge spec (director/creator chips + crew-row dedup ×3 merges),
person spec (crew credits, Producer excluded, acting-wins dedup),
stalker-vod.utils passthrough. Docs updated (CLAUDE.md + architecture).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): address director-pages review — split oversized spec, stable track keys, translated crew roles

- tmdb-merge.spec.ts grew past the 400-line lint ceiling — the Stalker
  merge suite moved to tmdb-merge-stalker.spec.ts (fixes the CI Lint job).
- All cast/director chip loops now track by TMDB person id with an
  index fallback ('p<id>' / 'i<index>') instead of member.name — distinct
  people can share a name and creator payloads carry no dedup (greptile).
- Directing-only filmography credits carry the role in a new crewJob
  field ('Director' | 'Creator') instead of stuffing TMDB's raw English
  job into character; ActorViewComponent renders it through translated
  labels (XTREAM.CREW_JOB_DIRECTOR/CREATOR, added to all 18 locales via
  the i18n patch workflow, matching each locale's existing glossary —
  pt "Diretor", de "Regisseur") (Codex).

Tests: person spec asserts character/crewJob separation; merge suites
green after the split (15 + stalker file).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 08:42:41 +02:00
4grayandClaude Opus 4.8 db70b07093 feat(playback): theater stage and opt-in ambient fill for the inline portal player (#1223)
* fix(tmdb): purge obsolete search cache rows

* feat(playback): theater stage and opt-in ambient fill for the inline portal player

On wide-short windows the VOD/series inline player left a strip of app
surface next to the video: with `width: auto`, the viewport's `max-height`
transferred through `aspect-ratio` into a max-width (CSS transferred size
constraints), re-clamping the stage to 16:9 and leaving the leftover
outside it.

- Theater stage: give `.player-shell__viewport` a definite `width: 100%`
  so it always fills the content row; the player renders as the largest
  16:9 box that fits the stage height, centered — the leftover is always
  the stage's black background, never app surface (YouTube-style
  letterbox). Applies to every inline engine.
- Ambient fill: new `playerAmbientMode` setting (default off, Settings >
  Playback, web players only) renders a blurred, dimmed copy of the
  poster behind the player, filling the letterbox margins. Enforced at
  runtime too: Embedded MPV never gets the extra DOM layer. Live channels
  and non-http(s) poster URLs are excluded.

Verified live via CDP at 1720x760 (stage 1362x532, player 946x532 with
symmetric 208px margins) and 1280x950 (stage exactly 16:9, no bars).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(i18n): add ambient-mode setting keys to all remaining locales

The i18n drift gate requires SETTINGS.PLAYER_AMBIENT_MODE and its
description in every locale; the feature commit only covered en and ru.
Translated via the i18n-fill workflow (per-locale patch + mechanical
merge, glossary-matched against each existing file).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(settings): include playerAmbientMode in expected default settings

settings.component.spec asserts the persisted settings object with
toEqual; the new default-off field has to be part of the fixture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 08:02:11 +02:00
4grayandClaude Fable 5 402382421c feat(matching): strip appended language/quality tags in title normalization (#1211)
* feat(matching): strip appended language/quality tags in title normalization

Real-world portal catalogs duplicate one show under dozens of tagged
variants ("|ALB| Fallout", "4K-DE - The Pitt (2025) (US)",
"Breaking Bad-eng", "Fallout_esp", "The Last of Us (2023) AF"). A third
of them normalized to polluted keys, silently skipping TMDB enrichment
and staying invisible to cross-portal title matching.

normalizeTitleKeys() now handles, conservatively:
- wrapped pipe tags:      "|ALB| X", "|MULTI| X"
- longer/compound leads:  "EXYU| X", "4K-DE - X", "AR-SUBS - X",
                          "4K-OSN+ - X" (dash/pipe only; colon stays
                          2-3 chars so "NCIS: LA" is untouched)
- underscore suffixes:    "X_eng", "(US)_msub" (single-underscore only,
                          "The_Last_of_Us" stays intact)
- double-dash suffixes:   "X--esp"
- joined dash tags:       "X-DE", "X-eng" (vocabulary-gated and
                          case-uniform only; "Spider-Man", "Kick-It",
                          "Peut-être" are untouched)
- bare trailing tags:     "X (2025) DE", "Breaking Bad ES" (UPPERCASE
                          vocabulary only, skipped for ALL-CAPS titles;
                          "Rocky II", "Made in USA", "Making It" are
                          untouched)

Every leading-tag segment must contain a letter, so numeric titles
("1917 - ...") are never treated as tags. The display-side
stripCountryPrefix() learns the same compound/plus-sign prefixes and the
numeric guard.

buildSearchLookupKey() gets a |v2 suffix so cached negative TMDB match
resolutions keyed on old polluted titles are invalidated.

Measured on 248 real catalog names from four shows (The Pitt, Fallout,
The Last of Us, Breaking Bad): clean matching keys 65% -> 99%, display
strip 91% -> 100%. The corpora are committed as spec fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(matching): use ES2015-safe trailing trim in title normalization

String.prototype.trimEnd is ES2019; the shared-interfaces lib compiles
against an older lib target (TS2550 in typecheck:web). Replace with a
regex-based trimRight helper. Jest uses its own tsconfig, so this only
surfaced in the CI typecheck, not local unit runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(matching): guard tag stripping against real-title false positives

Address code-review findings on the tag-stripping rules:

- underscore suffix is now vocabulary-gated, so "Mr_Robot",
  "Cowboy_Bebop", "Mrs_Davis" keep their second word
- leading single-segment tags before a spaced dash stay 2-3 chars
  (only hyphen-compounds like "4K-DE" and pipe-tags like "EXYU|" may be
  wider), so "DUNE - Part Two" and "ALIEN - Covenant" are left intact
- "IN" is excluded from the weak joined-dash/underscore paths so
  "drive-in" and "Plug-in" are not truncated (India still strips via
  the strong "IN| " / "IN - " forms)

The display-side stripCountryPrefix() mirrors the narrowed dash rule.
Corpus coverage is unchanged at 99% (245/248); new counter-example
tests lock in the guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:42:20 +02:00
ec6fc403a3 feat: manual EPG-to-channel mapping with mapping fallback (#1165)
* feat(epg): manual EPG-to-channel mapping with mapping fallback in all EPG paths

* fix: epg mapping in live tv list

* fix(epg): harden manual EPG mapping — upgrade safety, perf, playlist-scoped keys

Follow-up fixes on top of the manual EPG-to-channel mapping feature:

- epg-database: dedupe existing epg_programs rows before creating the
  unique (channel_id, start, title) index — a plain CREATE UNIQUE INDEX
  crashed the EPG worker on upgrade when historical duplicates exist;
  replace INSERT OR REPLACE with ON CONFLICT DO UPDATE so the
  epg_programs_fts delete trigger is not bypassed (REPLACE skips delete
  triggers unless recursive_triggers is on), with a plain-INSERT
  fallback when the index cannot be created
- db: add idx_content_epg_channel — the mapping fallback scanned the
  whole content table on every single-channel EPG lookup
- keys: scope Xtream mapping keys per playlist via shared
  buildXtreamEpgMappingKey (xtream:{playlistId}:{id}) — bare stream ids
  collide across portals; the backend fallback now joins categories to
  resolve the playlist id
- pwa: hide "Map EPG channel" entries behind the supportsEpgMapping
  capability — the menu item was a dead end in the PWA
- parser: parse the XMLTV offset sign from the string — Math.sign(0)
  dropped the minutes of ±00:xx offsets
- cleanup: typed window.electron access instead of ad-hoc casts, drop
  unused resolveChannelId and dialog data field, shared
  EpgMappingDialogComponent.open() for all seven call sites
- dialog UX: minimum-characters search hint, save/remove snackbars,
  current mapping shows the EPG channel display name,
  takeUntilDestroyed on the search stream
- i18n: fill the new keys in all 17 locales
- tests: cover mapping CRUD/search escaping, the dedup-index guard,
  offset parsing and playlist-scoped keys; update stale stream-resolver
  specs for the new 50-item limit and 10s timeout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(epg): escape backslashes in EPG channel search LIKE pattern

CodeQL js/incomplete-sanitization: a lone trailing backslash in the
search term paired with the closing wildcard under the ESCAPE clause
and corrupted the pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(epg): batch mapping lookups in the viewport preview queue

Expose the existing getEpgMappingsBatch operation over a new
EPG_MAPPING_GET_BATCH IPC channel and use it in resolveManualMappings —
the per-entry lookup issued one IPC round-trip per visible channel on
every scroll event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(epg): batch mapping prefetch in the collection preview loader

Resolve all candidate mapping keys for an Xtream preview batch with a
single getEpgMappingsBatch IPC call instead of per-channel lookups.
Also fix a worker early-exit: a channel without tvgId/name returned out
of the shared-iterator loop and silently killed one of the three
concurrent preview workers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 09:00:14 +02:00
4grayandClaude Fable 5 45b6d8a041 fix(m3u): parse playlists with URLs longer than 2084 characters (#1204)
* fix(m3u): parse playlists with URLs longer than 2084 characters

Pluto TV style playlists (issue #1189) embed a session JWT in every
stream URL (~2200 chars). validator.isURL inside iptv-playlist-parser
rejected anything over its IE-era 2084-char default, and the parser's
stalled item index then collapsed the whole playlist into a single
channel.

Sync the 4gray/iptv-playlist-parser fork with upstream v0.15.2, which
removes URL validation entirely and adds an explicit branch so '#'
comments and unknown directives are never treated as URLs. Two fork
deltas are preserved on top: the radio attribute (radio player
detection) and pipe stripping (item.url is cut at the first '|' while
|User-Agent=/|Referer= params still land in item.http). The now-dead
validator/is-valid-path dependencies are dropped from the fork.

- pin iptv-playlist-parser to the fork commit SHA
- add a parser contract spec guarding long URLs, comment handling,
  radio, pipe stripping, and header EPG attrs
- document the parser fork contract in the M3U architecture doc

Fixes #1189

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(m3u): bump parser to optimized fork build

Pulls the fork's optimized parse() rewrite (2.5-3x faster: 100k
channels ~780ms -> ~285ms, 10k ~79ms -> ~25ms) and the README
documenting fork deltas. Output is differential-verified byte-identical
to the previous build; all parser-contract, unit, and import E2E suites
rerun green against the new pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(m3u): bump parser for input robustness and library hygiene

Pulls the fork's real-world input tolerance: UTF-8 BOM, blank lines and
whitespace before the header, and case-insensitive #EXTM3U no longer
reject the playlist (all VLC-accepted forms); Node Buffers are decoded
as UTF-8 and other non-string input throws a clear TypeError. Also
brings truthful types (url?: string), fork metadata, an enforced 100%
coverage gate, and the fork CHANGELOG.

Extends the contract spec with a BOM regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(m3u): pin parser to the tagged fork release v0.15.2-iptvnator.1

Same commit as before (33f5e9c) — the readable tag replaces the raw
SHA in package.json while pnpm-lock still records the immutable
codeload tarball by commit. Fork release:
https://github.com/4gray/iptv-playlist-parser/releases/tag/v0.15.2-iptvnator.1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(types): make ParsedPlaylistItem.url optional to match runtime

The parser fork's d.ts now truthfully declares url?: string (a trailing
#EXTINF without a stream URL yields url === undefined at runtime, and
always has). The local ParsedPlaylistItem mirrored the old type lie and
made the production typecheck reject the parser's Playlist type.
createChannel and createPlaylistObject already tolerate the absent url.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:56:26 +02:00
d61fd5db19 feat: add strip country prefix setting (#1162)
* feat: add strip country prefix setting

* feat: scope country-prefix stripping to live content and cover missing surfaces

- narrow the heuristic: pipes always strip, dash/colon separators only
  when the prefix is a short uppercase tag ("UK - BBC One" strips,
  "Sky - Sports F1" and "Mission: Impossible - Fallout" stay intact)
- fall back to the original name when stripping would leave nothing
- scope stripping to live content only: grid type (live/itv/radio),
  playback isLive, external sessions without contentInfo, dashboard
  cards with contentType 'live'
- cover previously missed surfaces: M3U player EPG timeline header,
  M3U inline player title, radio player, dashboard live rails
- replace hardcoded settings strings with translate keys and add
  SETTINGS.STRIP_COUNTRY_PREFIX(_DESCRIPTION) to all 18 locales
- add unit specs for the utility plus regression specs for
  channel-list-item and external-playback-dock

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: cover strip-country-prefix call sites for codecov

- dashboard-rail: new spec for cardTitle live/movie/series scoping
- grid-list: strip enabled/disabled, VOD passthrough, 'No name' fallback
- portal-inline-player: live strip vs VOD passthrough
- unified-live-tab: timeline channel name strip + M3U name precedence
- video-player: timeline/radio/inline titles with the setting on and off
- settings-store: default false + persisted true round-trip
- settings-form.utils: new spec for form default and ?? false fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:43:37 +02:00
4gray beb62db314 feat(settings): add shared web player controls toggle (#1198)
* docs(playback): design shared controls setting

* docs(playback): plan shared controls setting

* feat(settings): persist shared web controls preference

* test(settings): harden shared controls normalization coverage

* feat(settings): expose shared web controls toggle

* fix(settings): label shared controls toggle

* feat(playback): resolve shared controls from settings

* test(playback): cover shared controls setting

* docs(playback): document shared controls preference

* fix(playback): await settings before host creation

* fix(settings): normalize shared controls updates
2026-07-17 13:38:06 +02:00
4grayandClaude Fable 5 26271fc076 feat(embedded-mpv): frame-copy rendering engine (experimental, macOS Apple Silicon) (#1169)
* spike(embedded-mpv): frame-copy pipeline prototype (helper + shm ring + Electron viewer)

Standalone macOS spike for the frame-copy unification direction from the
2026-07-10 analysis: a helper process renders mpv offscreen into a GL FBO,
reads frames back through an async PBO ring, and publishes BGRA frames into
a 3-slot POSIX shm seqlock ring; a minimal Electron viewer copies the newest
frame via a plain-C N-API addon and uploads it to a WebGL canvas per rAF.

First numbers on M1 Pro (see spike README): 4K60 HEVC hwdec sustained at
60 fps end to end, ~1.2 ms shm copy + ~3.5 ms texture upload, ~10 ms
produce-to-upload age, zero torn frames. Remaining gates: weak hardware,
long-run pacing, HDR, latency flash test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): add RESULTS.md measurement log with M1 Pro baseline

Structured per-machine table with repro commands so the pending Intel Mac
and Windows iGPU runs can be appended and compared one-to-one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): pacing/judder instrumentation + 50/25 fps and HDR gate results

Viewer now measures inter-frame intervals on both clocks (present side and
producer side): stddev/p99/max, late-frame counters vs the producer's median
interval, and a cumulative LONGRUN summary every 30 s. The addon exposes the
producer timestamp (produceMs) for this.

Measured on M1 Pro: 50 fps and 25 fps cadences are clean (late frames only
at startup; residual jitter is 120 Hz rAF grid quantization, bounded by one
display tick), and 4K25 HDR10 PQ/BT.2020 is tonemapped to SDR by mpv before
readback at full rate with unchanged copy costs. RESULTS.md carries the
tables and HDR-clip repro commands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): record 10-minute 4K60 HEVC long-run results

Zero dropped frames and zero torn reads after the first-minute warmup over
~8.5 minutes; steady-state late frames (~0.4%) track the 12 s test clip's
--loop restarts, not the copy pipeline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): viewport-scaling measurement + integration design draft

Confirms the render-at-viewport-size claim (4K source in a 720p FBO costs
720p: 0.17 ms readback / 0.16 ms copy / 0.17 ms upload at 60 fps) and adds
DESIGN.md — the draft integration architecture: per-session helper process
linking bundled libmpv on all platforms (finally full-featured + Wayland-
agnostic Linux), JSON-over-stdio control evolving the Linux wid protocol,
unchanged EmbeddedMpvSession renderer contract, shm generations for resize,
packaging via the existing vendored-runtime tooling, rollout behind its own
flag with the docked path as default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): auto-detect Homebrew prefix and Node headers for Intel Macs

BREW_PREFIX was hardcoded to /opt/homebrew (Apple Silicon) and NODE_INC to
one nvm version; both now resolve via brew --prefix and the PATH node's
execPath, so the pending Intel Mac run needs no Makefile edits. README gets
a fresh-machine checklist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): self-contained measurement bundle for machines without Node/pnpm

make-bundle.sh assembles a tarball with the spike sources, vendored N-API
headers (Makefile prefers them when present, so no Node install is needed),
pre-generated 4K HEVC/HDR10 test clips, and an official Electron dist
download for the target arch. collect-results.sh builds and runs the full
RESULTS.md scenario suite automatically (plus an optional --long 10-minute
run) and writes one results-<host>-<date>.txt to send back. Target-machine
prerequisites shrink to Xcode CLT + brew mpv — built for the pending Intel
Mac baseline run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): support MacPorts libmpv and legacy-macOS bundles

Makefile and collect-results.sh now detect libmpv in the Homebrew prefix or
MacPorts /opt/local (Homebrew is unsupported on legacy macOS like High
Sierra; 'sudo port install mpv +libmpv' provides libmpv there). make-bundle
takes ELECTRON_VERSION/BUNDLE_SUFFIX overrides — Electron 27+ needs macOS
10.15, so High Sierra bundles ship Electron 26.6.10 (LSMinimumSystemVersion
10.13).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spike(embedded-mpv): scope macOS frame-copy engine to Apple Silicon only

Owner decision 2026-07-10: skip Intel Mac measurements and gate the future
frame-copy engine on arm64. Intel Macs able to run the app at all are a
shrinking 2015-2020 cohort and keep the docked/external/web player paths;
the macOS hardware gate closes with the M1 Pro numbers, and remaining
hardware risk moves to the Windows/Linux ports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): frame-copy helper process and shm frame reader (native layer)

iptvnator_mpv_helper: one-process-per-session libmpv host that renders
offscreen at viewport size (headless CGL + async PBO ring, validated in
spikes/mpv-frame-copy), publishes BGRA frames into a seqlock shm ring with
resize generations, plays audio directly, and speaks a stdio protocol —
tab-separated commands in, JSON events out. The snapshot event mirrors
NativeEmbeddedMpvSessionSnapshot; status semantics (END_FILE reasons,
eof-reached with keep-open, pause gated on loaded path, fatal-only status
flips) are ported from embedded_mpv.mm.

embedded_mpv_frame_reader.node: plain-C N-API reader the preload script
uses to memcpy the newest complete frame into a V8 ArrayBuffer (Electron's
memory cage forbids zero-copy). Stub exports off macOS.

Both build as extra binding.gyp targets through build-embedded-mpv.js; the
helper gets the same libmpv dependency-path rewrite + ad-hoc re-sign as the
addon and is validated by the forbidden-link check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): frame-copy engine wiring in main process and preload

EmbeddedMpvFrameCopyAdapter implements the NativeEmbeddedMpvAddon surface
over a per-session helper process (spawn, stdio protocol, snapshot cache,
graceful quit->SIGTERM->SIGKILL teardown), so EmbeddedMpvNativeService
reuses its polling/diff/power-blocker/recording logic unchanged. The
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY flag (darwin/arm64 only) routes
getAddon() to the adapter and reports engine: 'frame-copy' in support.

The preload frame pump loads the shm reader addon, copies the newest frame
once per rAF into a reused buffer, and uploads it to WebGL2 on the
renderer's canvas — no frame data crosses the contextBridge; the bridge
only gains attachEmbeddedMpvFrameView/detachEmbeddedMpvFrameView. The
experiment flag relaxes the window sandbox for that native require;
contextIsolation and nodeIntegration:false stay on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): frame-copy canvas mode in the player component + docs

EmbeddedMpvPlayerComponent renders <canvas data-embedded-mpv-frame> when
support reports engine 'frame-copy' and the session controller starts/stops
the preload frame pump around the session lifecycle. The bounds provider
skips HIDDEN_BOUNDS and the popover cutout for this engine — the canvas is
ordinary DOM, dialogs and popovers stack above it natively; bounds sync
still drives the helper's render size. Adapter unit tests cover spawn args,
snapshot caching, shm generations, protocol encoding, unexpected-exit
mapping, and dispose escalation. Architecture doc and CLAUDE.md describe
the engine, its flag, and the sandbox trade-off.

Verified end to end in the built app (M1 Pro): engine detection, helper
spawn, lavfi playback onto the canvas via CDP-injected smoke — including an
orientation fix (helper FLIP_Y already yields texture-order rows; the pump
shader must not flip uv again).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): close helper stdin on dispose + lifecycle logging

Live testing surfaced a stray idle helper that survived a session switch;
until the root cause is pinned down, dispose now also closes the child's
stdin (the helper exits on EOF) as a second kill path besides quit ->
SIGTERM -> SIGKILL, and spawn/dispose/exit are logged with the session id
so leaks are attributable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): reap sessions when the renderer reloads or crashes

Root cause of the stray idle helper found during live testing: session
teardown lives in the renderer's Angular lifecycle, which never runs on a
renderer crash or hard reload — the main process kept the session (and its
frame-copy helper process / native mpv handle) alive until app shutdown.
EmbeddedMpvNativeService now watches the main window's webContents for
render-process-gone and did-navigate (full reloads only; in-app Angular
routing emits did-navigate-in-page) and disposes every session. Applies to
both engines. Verified live: location.reload() during frame-copy playback
logs 'Disposing 1 session(s): renderer reloaded' and the helper exits
cleanly. Regression test drives both events against the service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): Settings toggle for the frame-copy engine

New 'Embedded MPV: frame-copy engine' checkbox in Settings > Playback,
shown only when the machine can run it (macOS arm64 with the helper binary
present — support now reports frameCopyAvailable). The choice persists to
the main-process config store because the engine relaxes the window sandbox
for the preload frame pump, which is fixed at window creation: main.ts
reads the store before creating the window and sets the engine env var; an
explicitly set env var (including '0') always wins, and the UI shows a
restart hint while the saved choice differs from the active engine.
Localized in all 18 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): aspect-fit rendering in the frame-copy helper

The helper now observes dwidth/dheight and renders its FBO at the
aspect-fit size of the video inside the requested viewport, bumping a shm
generation on change — letterbox bars are never baked into frames (the VOD
watch shell's ~2:1 box no longer shows black side bars; the canvas
background is transparent so the sides show the app surface, while
fullscreen keeps its black backdrop). Frames also get smaller than the
viewport when aspects differ, trimming copy cost. Aspect override changes
refit automatically. Snapshots now carry videoWidth/videoHeight, and the
adapter forwards IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY to mpv's audio-delay
for lip-sync tuning until proper calibration lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): require frame-copy artifacts in macOS package validation + docs

macOS packages that ship embedded_mpv.node must also ship the
iptvnator_mpv_helper binary and the embedded_mpv_frame_reader.node addon —
they come out of the same binding.gyp run, and a package missing them would
silently lose the frame-copy engine. Covered in the package-identity test.
Architecture doc and CLAUDE.md document the Settings toggle, aspect-fit
rendering, audio-delay passthrough, and the renderer-reload session reaping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(electron): inline TS helpers so the sandboxed preload keeps working

The frame pump's async/await (target es2015 + importHelpers) made webpack
externalize tslib in main.preload.js. Sandboxed preloads can only require
Electron's built-in module whitelist, so the entire preload script failed
to load and window.electron disappeared for every run without the
frame-copy flag. importHelpers:false for electron-backend keeps the preload
bundle self-contained — and future async code in preload can no longer
silently reintroduce the breakage. Verified live: sandboxed run now has the
bridge, reports engine 'native' and frameCopyAvailable true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(embedded-mpv): commit the frame-copy analysis handoff + source inventory

The 2026-07-10 analysis that led to this branch now lives next to the spike
(spikes/mpv-frame-copy/ANALYSIS.md), and the architecture doc's What To
Commit section lists the frame-copy engine sources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): address review findings on the frame-copy engine

- Stale pump attach can no longer win over a newer session: attach/detach
  bump a shared epoch and async attach waits re-check it after every await,
  so an attach for a replaced session aborts instead of installing itself
  (greptile P1).
- A failed frame-view attach (no canvas, no WebGL2, reader missing) now
  disposes the session and surfaces the error UI instead of leaving audio
  playing behind a black canvas (codex P2).
- A stale frame-copy opt-in without the helper binary falls back to the
  native engine instead of reporting embedded MPV unsupported, and the
  Settings checkbox stays visible while a saved opt-in exists so it can
  always be cleared (codex P2). Regression test covers the fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(packaging): make the darwin frame-copy packaging test host-agnostic

On non-macOS CI hosts validatePackagedEmbeddedMpv also reports that macOS
link validation needs a macOS host, so the success-path assertion now
checks only the frame-copy artifact requirement instead of expecting an
empty error list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(embedded-mpv): Windows/Linux porting handoff for the frame-copy engine

Self-contained entry point for porting sessions on other machines: current
state and coordination constraints, per-OS task lists (Linux EGL first,
then Windows WGL + named shm — the decisive iGPU perf gate), the
hard-won gotchas from the macOS integration (preload/tslib sandbox
breakage, V8 memory cage, frame orientation, stale-attach epoch, dispose
escalation, node-gyp naming, snapshot protocol semantics), testing
recipes, and the suggested milestone order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(embedded-mpv): branching and merge strategy in the porting handoff

Port work goes to stacked branches off the frame-copy branch (PR base =
frame-copy branch, sequential merges, stack depth one), never into the
frozen PR #1169 branch itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(embedded-mpv): drop stale uncommitted note from porting handoff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): harden frame-copy helper startup

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:48:07 +02:00
4grayandClaude Fable 5 64839e3495 fix(tmdb): fall back to original language for missing trailers too (#1136)
* fix(tmdb): fall back to original language for missing trailers too

TMDB language-filters videos the same way it filters overviews: a
Russian-only title has its trailer tagged iso_639_1=ru, so an en-US
request returns no trailer — the trailer only appeared when the user
switched the app language. The original-language fallback previously
triggered on an empty overview only, so titles that had a translated
overview but a language-only trailer never got one.

detailsFallbackLanguage now also triggers when the payload has no usable
YouTube trailer, and fillDetailsFromFallback fills the overview and the
trailer independently — a present app-language overview is kept while the
trailer is pulled from the original-language payload. The fallback fetch
stays best-effort and cached per language.

Tests: extended tmdb-language-fallback.spec.ts (trailer-missing trigger,
independent overview/trailer fill, YouTube-only guard). Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(tmdb): cover combined overview+trailer fallback fill

Adds the missing case where the primary payload lacks both overview and
trailer and the fallback supplies both — the two fill branches are
independent (greptile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:33:12 +02:00
4grayandClaude Fable 5 862b271eac feat(tmdb): dashboard trending + hero enrichment, cross-portal Similar rail (#1132)
* feat(dashboard): TMDB trending rail and hero enrichment (backdrop, badges, S/E)

Two dashboard additions, both async after first paint so the page renders
exactly as fast as before:

Trending rail ("Trending this week", dashboardRails.tmdbTrending toggle,
default on, rendered only when TMDB is opted in AND the Electron DB
worker is available):
- TmdbTrendingService fetches /trending/{movie,tv}/week (one request
  each, cached one day per language in tmdb_metadata under
  trending:week), merges by popularity; exposed via the enrichment
  facade (getTrendingWeek)
- DashboardTrendingService matches the titles against imported Xtream
  playlists with ONE batched DB_MATCH_TITLES request, applying the same
  two-tier + year-compatibility rule as actor pages; matched cards show
  the playlist name and navigate straight to the detail view, unmatched
  cards open the global search prefilled (?q=)
- The load fires only after the dashboard's own recent/favorites data
  is in (never competes for the worker at startup) and once per session
- DashboardRailCard gained optional queryParams for the search links

Hero enrichment:
- DashboardHeroTmdbService patches the hero with a TMDB backdrop (only
  when the item has none), a rating badge and up to two genre chips —
  via the enrichment facade, so previously opened items resolve from
  the SQLite cache without network; memoized per title per session,
  staleness-guarded against hero changes in flight
- Series heroes show the tracked "S{n}·E{n}" badge from the playback
  position; the watch-progress bar no longer applies to live heroes

Settings: new dashboardRails.tmdbTrending toggle in Settings > Dashboard.
i18n: 3 new keys translated into all 17 locales via tools/i18n patches.

Tests: dashboard-trending.service.spec.ts (gating, matching, year guard,
single-flight); settings fixtures updated. Docs updated
(tmdb-metadata-enrichment.md Dashboard Integration section, CLAUDE.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): resolve hero TMDB extras for Stalker embedded-series items

Stalker vclub items carry type 'movie' in activity rows but are TV shows
on TMDB, so the hero's movie lookup found no confident match and the
backdrop/badges never appeared — while the detail view (which resolves
via is_series) showed them. When a movie-typed hero item has no movie
match, retry the lookup as TV: the detail view has usually already
cached that resolution, and misses are negative-cached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmdb): cross-portal "Similar" rail — Stalker gets it, Xtream gains other-portal matches

The Similar rail only existed on Xtream because it matched against the
locally loaded catalog; Stalker catalogs are server-paginated, so its
detail views had no rail despite tmdb_recommendations being cached.

New CrossPortalSimilarService (libs/services) matches recommendations
against ALL imported Xtream playlists with one batched DB_MATCH_TITLES
worker request — the same two-tier normalized-title + year-compatibility
rule as actor pages and the trending rail. Electron-only; resolves to []
in the PWA.

- Stalker: the shared VodDetailsComponent (movies; covers catalog and
  inline detail hosts) and stalker-series-view (series) now render a
  "Similar" rail from cross-portal matches, each card badged with the
  source playlist and navigating into that portal's detail view.
- Xtream: vod/serial detail rails keep instant local-catalog matches and
  append cross-portal matches (current playlist excluded, deduplicated
  against local hits by normalized exact title), also playlist-badged.
- Loads async after the detail view renders, staleness-guarded; the
  section only appears when there is something to show.

Tests: cross-portal-similar.service.spec.ts (PWA gate, navigation
targets, playlist exclusion, type/year guards). Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmdb): drop TestBed from cross-portal similar spec

The services Jest target has no @angular/core/testing (same CI failure
as the cache spec earlier) — construct the service via Injector.create +
runInInjectionContext instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): address PR review — reactive opt-out gates, retry after empty trending load

- Trending rail and hero TMDB extras now vanish immediately when the
  TMDB opt-in is switched off mid-session: the render computeds read the
  settings signal through isAvailable/isEnabled instead of trusting data
  loaded earlier (Codex P2 ×2).
- loadedOnce latches only after a successful non-empty load, so a
  transient TMDB outage on first visit no longer suppresses the rail for
  the whole session — the next dashboard visit retries (greptile P2).
- Unified the duplicated heroTmdbExtras() read in the hero computed
  (greptile P2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:21:11 +02:00
4grayandClaude Fable 5 9635f398c1 fix(tmdb): restore trailers in packaged builds, fall back to original language for missing text (#1130)
* fix(tmdb): restore trailers in packaged builds, fall back to original language for missing text

Two production-only gaps reported against the packaged 0.22 build:

1. Trailers showed YouTube "Error 153 — Video player configuration
   error". YouTube requires a Referer on /embed requests; the packaged
   app loads the renderer from file://, which never sends one (dev works
   because the origin is localhost). The Electron main process now
   injects the project site as Referer for YouTube embed hosts when the
   header is absent (request-header-overrides.service.ts, registered at
   startup via registerStaticHeaderShims). Existing Referers are never
   overwritten, other hosts untouched.

2. Descriptions vanished for content whose original language differs
   from the app language: TMDB does not fall back on missing
   translations, so a Russian-only series has empty overviews in en-US.
   When the app-language payload carries no text, the enrichment
   refetches once in the content's original_language and fills ONLY the
   missing text fields (tmdb-language-fallback.ts): details overview,
   season overview and per-episode names/overviews. Genres, credits and
   artwork stay in the app language; both language rows share the cache.

Tests: 3 new shim cases in request-header-overrides.service.spec.ts,
new tmdb-language-fallback.spec.ts (9 cases). Docs updated
(tmdb-metadata-enrichment.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmdb): address PR review — best-effort fallback fetch, /embed/ scope, names-only trigger

- A failing original-language fallback fetch no longer costs the
  already-fetched primary details payload (Codex P2) — it is wrapped
  best-effort and the primary payload is returned on error.
- The YouTube Referer shim now scopes to /embed/ paths, so regular
  www.youtube.com requests keep their real (missing) Referer (greptile).
- seasonNeedsTextFallback also triggers when episode overviews exist but
  ALL episode names are empty (partially translated seasons; greptile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:53:46 +02:00
4grayandClaude Fable 5 ce24e16e32 feat(portals): two-state VOD detail pages (browse ↔ watch) with season tabs (#1127)
* feat(ui): slim ContentHero to non-scrolling hero block

The page scroll container moves out of the hero into the upcoming
PortalDetailShellComponent; the hero no longer hosts a default content
slot for player/seasons/extras.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ui): add PortalDetailShellComponent with browse/watch states

Two-state detail layout shell: owns page scroll, hero collapse animation
(~300ms, reduced-motion aware), Escape-to-close-player handling, and an
About block that re-stamps host-provided *detailTags/*detailMeta
templates in watch state. The [detail-player] slot is never wrapped in a
shell conditional so the host-owned player subtree survives state
changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): restyle inline player header as now-playing bar

Adds a back button (emits the existing closed output — in watch state
back returns to browse, not route navigation) and replaces the
hardcoded header strings with i18n keys (PORTALS.NOW_PLAYING,
CLOSE_PLAYER, BACK_TO_BROWSE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(xtream): migrate VOD and serial detail templates to detail shell

Hero chips/meta/actions become *detailTags/*detailMeta/*detailActions
templates; the inline player moves to the full-width [detail-player]
slot; trailer and similar rail move to [detail-extras]. Escape and the
now-playing back button close the inline player back to browse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(stalker): migrate shared VodDetails and series view to detail shell

The shared VodDetailsComponent (Stalker VOD, collections, search) and
StalkerSeriesViewComponent move their hero content into
*detailTags/*detailMeta/*detailActions templates and host the inline
player in the full-width [detail-player] slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ui): season tabs replace two-level seasons navigation

SeasonTabsComponent renders a pill row (dropdown beyond 6 seasons), an
optional season description and a back-to-playing chip. The season
container auto-selects a season (inline-playing episode's season →
most recent in-progress → first), emits seasonSelected for
auto-selections so lazy-load/enrichment hooks keep firing, highlights
the inline-playing episode, and loses the seasons grid + 'Back to
seasons' button. Download/progress helpers move to pure utils; season
strings are now translatable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(portals): season descriptions under the season tabs

Xtream reads season overviews from get_series_info; Stalker exposes the
TMDB season overview through TmdbEnrichmentService.getSeason (same
cache rows as the episode enrichment) and keys it per tmdbId so views
reused across detail navigations cannot leak descriptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: document two-state detail layout and season tabs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): app-prefix detail template directive selectors, build fixes

detailTags/detailMeta/detailActions → appDetailTags/appDetailMeta/
appDetailActions per @angular-eslint/directive-selector; type the
Escape host listener as Event; drop a redundant ?? in season tabs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(i18n): translate new detail-page keys across all 17 locales

13 PORTALS.* keys (now-playing bar, About block, season tabs, episode
empty states) translated via the i18n-fill workflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): keep now-playing bar actions inside the viewport

The nowrap title made the header grid track grow to min-content and
pushed Copy/Close out of the page; min-width: 0 on the header/title row
lets the title truncate instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ui): expandable season description and episode info dialog

Season descriptions get the same show more/less toggle as the hero
(new shared ExpandableTextComponent); episode cards and list rows get
an info button that opens a dialog with the full plot, duration, air
date and a Play action — card heights stay fixed and click-to-play
semantics stay intact. Also: drop the per-frame backdrop blur on the
player card and make the watch-enter scroll instant so the hero morph
stops competing for frame budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): re-run TMDB season enrichment when the show match arrives

With season tabs the first seasonSelected fires as soon as seasons
load — usually before the async show-level TMDB enrichment has written
tmdb_id — so the season fetch silently no-oped and was never retried,
leaving episode stills/plots and the season description empty. Both
detail hosts now key the fetch on (tmdb_id, selected season) in an
effect, so it runs whichever arrives last. Also adds breathing room
above the About divider in watch state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): move episode info button into the card body, compact overlay actions

Three overlay buttons crowded the thumbnail on hover and the new info
button had no styling at all. Info belongs with the content text: it
now sits as a quiet ghost button at the end of the title row (revealed
on card hover, MDC touch target clamped so it cannot swallow the
play-on-click card area). Download + watched-toggle stay on the
artwork, shrunk to 28px with a subtle edge border. List view already
used the shared action style and is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): single watched indicator-toggle on episode cards

Watched state was signalled three times on one card: the green badge,
the hover toggle in the top-right cluster, and a checkmark appended to
the title. Now one control does both jobs — a toggle at the top-left
of the artwork that looks like the old green badge when watched
(always visible) and appears as a ghost circle on hover when not.
The right cluster keeps only the download action; the title checkmark
is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): back arrow in watch mode navigates straight to the list

Leaving the theater took two clicks: back to browse, then back to the
list. Watch state already shows everything browse offers (episodes,
About, extras), so the bar's back arrow is now route-level back
(new backClicked output wired to each host's goBack), while Close
player and Escape keep exiting to browse without navigating. Replaces
PORTALS.BACK_TO_BROWSE with a generic top-level BACK key (all locales).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ui): compact episode stills in the list view

When a season has genuinely distinct per-episode art (TMDB stills or
provider images), list rows show a 96x54 thumbnail with the episode
number riding on it instead of the number square. When every episode
repeats the same image (providers often send the series poster), the
plain number square stays — a column of identical posters is worse
than none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ui): compact list-thumbnail spec under the max-lines limit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): address Greptile review — real fallback asset, untracked overflow measure

default-episode.png never existed (pre-existing in the grid card, copied
into the list thumbs) — both onerror handlers now fall back to the real
default-poster.png. The expandable-text and content-hero measuring
effects read the expanded signal via untracked so toggling show
more/less no longer rebuilds their ResizeObservers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:28:00 +02:00
4gray 865a44c874 refactor(tmdb): split enrichment services, extract Stalker season overlay, cap PWA cache (#1126) 2026-07-04 18:16:41 +02:00
4grayandClaude Fable 5 dc05a2566e feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals (#1123)
* feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals

Adds an opt-in TMDB integration (Settings > Metadata) that enriches
detail views with a field-level merge — the provider stays authoritative
for stream data, TMDB fills editorial fields when the match is confident.

Enrichment:
- Movie/series details: plot, cast (avatar chips), director, genres,
  rating, poster/backdrop, official YouTube trailers
- Confidence-gated matching: provider tmdb_id trusted; otherwise
  normalized-title search with year gate (±1; series accept earlier
  premieres), season-suffix stripping, Cyrillic search-language override,
  and language-prefix fallback variants
- Lazy season/episode enrichment: real episode names, overviews, stills
- "Similar" rail (Xtream): TMDB recommendations matched to the catalog
- Actor pages per portal with full filmography, availability filter and
  an Electron-only "All portals" scope backed by a batched DB_MATCH_TITLES
  worker op over the trigram FTS index

Infrastructure:
- SQLite cache table tmdb_metadata (details, search verdicts, seasons,
  persons; per-language, TTL-guarded), in-memory fallback for the PWA
- Settings: enable toggle, own-API-key override with a live "check key"
  button; TMDB attribution in Settings and About
- Embedded key stays an empty placeholder; CI injects TMDB_API_KEY via
  tools/tmdb/inject-tmdb-key.mjs when the secret is configured
- normalizeTitle shared between renderer and DB worker
- CSP: allow YouTube embeds (frame-src was 'none'; trailers never worked)

Fixes and refactors along the way:
- fix(stalker): Advanced Search sent bare get_ordered_list requests and
  skipped the auth handshake when isFullStalkerPortal was missing on the
  active-playlist meta — full portals answered "Authorization failed."
  and search looked empty; now mirrors the catalog request shape and
  routes through makeAuthenticatedRequest with URL-based detection
- fix(stalker): TMDB fields survive info re-normalization; detail views
  prefer the store copy patched by async enrichment over stale snapshots
- refactor(xtream): split oversized vod/serial detail components into
  component-scoped playback services; detail routes re-initialize on
  route param changes (router reuses them for detail-to-detail nav)
- i18n: all new keys translated across the 18 locales

Docs: docs/architecture/tmdb-metadata-enrichment.md + CLAUDE.md updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmdb): provide route params observable to inline collection details, linearize regexes

The global-collection inline detail host builds a fake ActivatedRoute for
VodDetailsRouteComponent/SerialDetailsComponent with only snapshot.params.
Since the detail components now read route.params via toSignal() (detail->
detail re-init), the missing observable crashed component construction and
the content hero never rendered — broke dashboard-activation, favorites and
recent Electron E2E on all platforms. Provide the params observable
alongside the snapshot and assert it in the component spec.

Also resolves both CodeQL js/polynomial-redos alerts: bracket-stripping in
normalizeTitle now excludes opening delimiters inside the classes, and
youtubeEmbedUrl extracts watch?v= ids with a linear two-pass match instead
of "watch\?.*v=". Combining-diacritics range rewritten as explicit \u
escapes (greptile note).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmdb): surface TMDB-only VOD score in the rating badge, drop youtube.com from CSP

Review follow-ups on PR #1123: the Xtream VOD detail badge renders
rating_imdb, but the merge wrote the TMDB score only into `rating`, so a
TMDB-only score was never displayed (Codex P2) — fill rating_imdb when the
provider left it empty, mirroring the Stalker merge. All trailer iframes
are normalized to youtube-nocookie.com, so the extra youtube.com frame-src
allowance was dead surface (greptile) — removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmdb): resolve confirmed review findings — matching correctness, race guards, cache schema

Fixes the confirmed findings from the PR #1123 code review:

- Stalker search: setSelectedContentType now runs BEFORE setSelectedItem,
  so the TMDB enrichment gate in the selection hook no longer sees the
  content type of the previously open tab (wrong/no enrichment after
  ITV -> search -> movie).
- Title normalization is now two-tier (normalizeTitleKeys): the exact
  normalized form keeps a trailing year, the base form strips it and
  remembers the tag. Year stripping is anchored to the end of the title
  ("2001: A Space Odyssey" keeps its year) and language-prefix stripping
  is UPPERCASE-only ("It: Chapter Two" is no longer amputated).
- All catalog matching (similar rail, actor pages, DB worker
  DB_MATCH_TITLES) compares exact forms first and only accepts
  year-stripped matches when the stripped tag is year-compatible (+-1)
  with the TMDB year — "Blade Runner" (1982) can no longer claim a
  catalog "Blade Runner 2049". CatalogTitleMatch carries the stripped
  trailingYear so the renderer can apply the guard to worker matches.
- mergedBackdrops tolerates a plain-string backdrop_path; enrichment
  merge+patch blocks are wrapped in try/catch so a malformed provider
  payload can no longer become an unhandled rejection.
- loadGlobalMatches (both actor routes) guards against actor->actor
  navigation races — a slow match for the previous person no longer
  overwrites the current one's results.
- tmdb_metadata media_type CHECK widened to ('movie','tv','person') and
  person rows now use the honest 'person' type (TmdbCacheMediaType).
  Pre-release dev DBs with the narrow CHECK are rebuilt in place — the
  table is a pure cache, so the migration is a self-healing
  drop-and-recreate keyed off sqlite_master.

Docs updated (tmdb-metadata-enrichment.md, CLAUDE.md). New regression
coverage: title-normalization.util.spec.ts, two-tier cases in
tmdb-similar.util.spec.ts and title-match.operations.spec.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:07:46 +02:00
4grayandClaude Opus 4.8 0b526b119a feat(epg): add vertical list view for the live EPG panel (#1115)
* feat(epg): add vertical list view for the live EPG panel

Add an EPG list view — a vertical, single-day programme list — as an
alternative rendering of the live EPG panel, selectable via a new
Settings → EPG → "Guide view" toggle (epgViewMode: 'timeline' | 'list',
default 'timeline' so existing users see no change).

- New EpgListViewComponent (app-epg-list-view) mirrors
  EpgTimelineComponent's input/output contract 1:1, so all four live
  hosts (M3U player, unified live tab, Xtream, Stalker) swap the panel
  with a plain @if and identical bindings.
- Reuses the shared view-agnostic EPG modules (classifyTimelineWhen,
  hasProgramsForDateKey, epg-archive.util catch-up gating,
  epg-summary.util collapsed-summary maths, epg-date helpers,
  EpgProgrammeDialogService, app-epg-timeline-empty-state) — no
  duplicated logic.
- Rows show time range, title, optional description, live progress on
  the on-air row, catch-up "Watch" on past rows when archive playback
  is available, and a details dialog; keyboard activation guards
  nested buttons (target === currentTarget).
- Auto-focuses the on-air row on channel select, restores it across
  collapse/expand remounts, and shows a sticky in-flow "On now" strip
  (never overlaying rows) when the current programme is scrolled away;
  all scroll maths is rect-based relative to the scroller.
- List mode raises only the inline panel height via an epg--list
  modifier (--epg-inline-height clamp); timeline and collapsed heights
  are unchanged.
- Setting flows end-to-end (Settings interface → DEFAULT_SETTINGS →
  SettingsStore/StorageMap → segmented control in the EPG section);
  Electron-only UI, PWA stays on the timeline default. i18n keys added
  to all 18 locales.
- Tests: new component/row/utils/scroll-controller specs, settings
  persistence spec, swap tests in all four host specs, and Electron
  E2E for the settings round-trip and the rendered list view. Docs
  updated (m3u-playlist-module.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(epg): address list-view review findings from Codex and Greptile

- Reset the list view to today when a new channel's programme set
  arrives while the user is parked on another day (timeline parity):
  the scroll controller now keys by the full programme-set identity
  (programsFocusKey) and commits today before focusing, instead of
  silently stranding the new channel on the stale day. (Codex P2)
- Centralise the 'timeline' fallback as a resolvedEpgViewMode computed
  on SettingsStore; the four live hosts consume the derived signal
  instead of duplicating the `?? 'timeline'` expression. (Greptile P2)
- Extract the component's reactive plumbing into
  registerEpgListViewEffects(), bringing the component back under the
  300-line guideline (290). (Greptile P2)
- Controller spec rewritten around programme-set fixtures with new
  coverage: return-to-today on channel switch, day navigation left
  alone, no-takeover when today has no data, empty-set no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(epg): drop malformed programmes from the list-view day filter

Reject programmes whose stop is not after their start in
buildEpgListRows — same as the timeline's buildTimelineBlocks. Bad
provider data would otherwise render impossible time ranges and could
even be offered as catch-up playable. (Codex P2 on e6fd0d08)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:05:08 +02:00
4gray 1679373bb2 feat(xtream): auto-select stream output format 2026-06-27 12:42:53 +02:00
4gray e801028005 feat(epg): support playlist-scoped sources
Add playlist-scoped EPG source support for M3U playlists.
2026-06-21 22:53:07 +02:00
4gray 3d4550f6c5 Merge remote-tracking branch 'origin/master' into agent/dashboard-rail-performance
# Conflicts:
#	apps/electron-backend/src/app/database/operations/content.operations.spec.ts
#	libs/shared/interfaces/src/lib/electron-api.interface.ts
2026-06-20 23:43:04 +02:00
4gray 89916af9d8 feat(search): add workspace global search with M3U support
Moves global search into a routed workspace view, adds M3U live/radio results, lazy pagination, and DB-backed matching improvements.
2026-06-20 23:05:48 +02:00
4gray b6c5367d63 fix(dashboard): speed up startup rails 2026-06-20 21:46:42 +02:00
4gray 20d0f01428 fix(xtream): address portal review feedback 2026-06-14 13:47:42 +02:00
4gray 254879f92b fix(xtream): improve portal compatibility 2026-06-14 13:21:56 +02:00
4gray adf37e7504 feat(dashboard): add configurable dashboard rails
* feat(dashboard): add configurable dashboard rails

* fix(dashboard): address rail review feedback
2026-06-14 11:52:44 +02:00
4grayand4gray e8aa7c3a34 [codex] Add scoped EPG security trust controls (#1054)
* Add scoped EPG security trust controls

* fix: address scoped trust review feedback

---------

Co-authored-by: 4gray <fourgray@proton.me>
2026-06-12 20:46:24 +02:00
4grayand4gray 9043116ebd [codex] fix Electron hardening follow-ups (#1053)
* fix: clean up Electron hardening follow-ups

* fix: remove duplicate Xtream probe comment

---------

Co-authored-by: 4gray <fourgray@proton.me>
2026-06-12 20:46:01 +02:00
Salem 2c032cd3c8 fix(security): complete Electron hardening and review follow-ups
* fix(security): harden Electron IPC against MITM, SSRF, path and injection risks

S1 TLS: validate certs by default on playlist/EPG fetches (opt-out via IPTVNATOR_ALLOW_INSECURE_TLS); new util/secure-https.ts.
S2: write-file IPC restricted to save-dialog-authorized paths.
S3: XTREAM_PROBE_URL guarded by assertRemoteUrlAllowed + maxRedirects:0; new events/url-safety.ts (+19 tests).
S4: EPG titles rendered via interpolation, not [innerHTML].
S5: downloads reveal/play limited to recorded download paths.
S6: Stalker cmd encoded (slash-preserving) to block query injection.
EPG-worker and Stalker fetches reject file://-style/credentialed URLs; LAN/self-hosted targets remain allowed.

* perf(player): lazy-load web video players via @defer

Wrap Video.js/HTML5/ArtPlayer in @defer (on immediate) so video.js, hls.js,
artplayer and mpegts.js split into a deferred chunk loaded on first playback
instead of eagerly on the player route. Embedded MPV (native) stays eager.
Spec uses DeferBlockBehavior.Playthrough.

* fix(player): remove leaked HTML video listeners on destroy

volumechange used a mismatched removeEventListener reference, while
loadedmetadata and timeupdate were never removed at all. Bind all three to
stable handler fields used for both add and remove, and add a teardown
regression test asserting each listener is detached on destroy.

* refactor(dashboard): extract pure navigation helpers from DashboardDataService

Move the 8 stateless link/navigation-state/type-kind helpers into a new
dashboard-navigation.util.ts so the routing logic is independently testable and
the 1260-line god-service shrinks. DashboardDataService keeps the public methods
as thin delegators (facade) so the public API and the single consumer
(workspace-dashboard-rails) are unchanged. First slice of the DashboardDataService
decomposition; verified by the existing service spec (33/33) and the app typecheck.

* fix(review): address PR feedback (IPv6 link-local, write-path cap, @defer placeholder)

- url-safety: broaden IPv6 link-local detection to the full fe80::/10 range
  (fe80:: through febf::), not just the fe80:: prefix (+ regression tests).
- playlist.events: cap authorizedWritePaths (evict oldest past 32) so a save
  dialog opened without a following write cannot accumulate entries until restart.
- web-player-view: add a @placeholder to each @defer (on immediate) player block
  to avoid the one-frame blank/layout-shift before the chunk resolves.

* fix(security): close Electron network and download gaps

* test(downloads): cover cancellation and restart cleanup

* fix(downloads): address Greptile review gaps

* test(security): reproduce remaining Greptile findings

* fix(security): close remaining Greptile findings

* test(downloads): reproduce early database queue stall

* fix(downloads): release queue after setup failures

* test(downloads): reproduce completion queue stall

* fix(downloads): release queue after completion failures
2026-06-12 15:24:29 +02:00
4grayandClaude Fable 5 8e0abe6feb feat(ui): custom title bar with window controls for Windows and Linux (#1042)
* feat(ui): add custom title bar window controls for Windows and Linux

Hide the native title bar on win32/linux (titleBarStyle: 'hidden', frame
untouched so native resize borders and snapping keep working) and render
minimize / maximize-restore / close buttons in the renderer, mirroring the
existing macOS traffic-light setup.

- New WINDOW:* IPC contract (minimize, toggle-maximize, close, get-state)
  handled in window.events.ts, resolved from the sender WebContents;
  close goes through win.close() so window-bounds persistence still runs.
- WINDOW:STATE_CHANGED pushed on maximize/unmaximize/fullscreen so the
  maximize/restore glyph stays correct for OS-triggered changes; controls
  hide while fullscreen.
- WindowControlsComponent mounts once in app-root as a manual popover so
  it stays in the browser top layer above CDK overlays (dialogs,
  multi-EPG) - same behavior as macOS traffic lights.
- Theme-aware via CSS vars (--app-on-surface, --app-hover-overlay);
  Windows-red close hover. Drag regions get right padding through a
  body-level frameless-platform class.
- Gated by RuntimeCapabilitiesService.usesCustomWindowControls; PWA and
  macOS never mount the controls.

Includes unit specs for the component and IPC handlers, an Electron E2E
suite (window-controls.e2e.ts), and a window-chrome section in
docs/architecture/workspace-shell.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(linux): upgrade Electron to 41 for frameless window decorations on Wayland

With the native title bar hidden, Linux windows lost the WM-drawn shadow
and rounded corners. Electron draws client-side decorations only on
native Wayland, and frameless-window CSD (GTK drop shadow + extended
resize boundaries) landed in Electron 41 - before that, frameless
windows render as plain rectangles.

- electron ^39.8.5 -> ^41.7.2 (Wayland auto-detected since 38.2; X11
  sessions remain undecorated, matching other frameless Electron apps;
  Windows keeps its DWM shadow and rounded corners).
- better-sqlite3 pinned to exactly 12.9.0: the last release shipping
  prebuilt binaries for both Node 20 (ABI 115, Jest) and Electron 41
  (ABI 145, runtime). 12.10.0 dropped the Node 20 prebuilds, forcing a
  from-source build that fails without a C++ toolchain.
- pnpm override node-abi 3.85.0 -> 3.92.0 so electron-builder
  install-app-deps can map Electron 41 to ABI 145.

Reviewed Electron 40/41 breaking changes: only the renderer clipboard
deprecation, which this app does not use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(e2e): address review feedback and window-managerless Linux CI

- Skip the three window-manager-dependent E2E assertions (maximize
  toggle, main-process state sync, minimize) on Linux CI: GitHub's
  ubuntu runners drive Electron under xvfb without a window manager, so
  maximize/minimize state never materializes there. Windows CI and
  local Linux/macOS runs keep the coverage.
- WINDOW:TOGGLE_MAXIMIZE now returns the requested state instead of
  re-reading isMaximized() right after the call, which races on Linux
  window managers where maximize()/unmaximize() complete
  asynchronously; the WINDOW:STATE_CHANGED push stays authoritative.
- Skip attaching window-state push listeners on macOS, where the
  custom controls never mount and the IPC traffic had no subscriber.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): gate custom window controls on the full bridge surface

Include getWindowState and onWindowStateChange in the
usesCustomWindowControls capability check — the controls rely on both
for initial state and for keeping the maximize/restore glyph in sync
with OS-triggered changes, so a partial bridge should not mount them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:03:27 +02:00
4grayand4gray 4cc9b93321 [codex] Cover playlists service behavior (#1027)
* cover playlists service behavior

* clarify playlists service test contract

---------

Co-authored-by: 4gray <fourgray@proton.me>
2026-06-06 17:49:10 +02:00
4gray 1badb9a057 refactor(electron): share typed preload bridge contract (#1018)
* refactor(electron): share typed preload bridge contract

* refactor(electron): tighten bridge review fixes

* refactor(electron): tighten playlist bridge returns

* refactor(electron): use bridge epg progress type

* refactor(epg): alias progress bridge types
2026-06-01 09:52:22 +02:00