Files
iptvnator/docs/architecture/xtream-portal-compatibility.md
2738bc28a1 docs(portals): document forced MPV/VLC launch and pending-start contracts (#1809)
* docs(portals): document forced MPV/VLC launch and pending-start contracts

The rules #1792 settled for forced external launches and playback-start
bookkeeping lived only in code comments and specs. Record them as
contracts in the owning documents:

- embedded-inline-playback.md: the shared detail-host rules (one external
  player per title, unconfirmed teardown cancels, ownership rechecked
  after every await, owner-scoped pending state).
- xtream-portal-compatibility.md: the series launch chain, page-token
  duplicate guard and queued choice.
- vod-multi-source.md: the movie menu launch and reset follow the copy
  the primary button acts on; pending resets are a list of targets.
- stalker-portal.md: the per-series launch queue, the batch-held choice
  and the movie launch/reset pending start.

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

* docs(portals): correct launch-contract claims against the code

- A launching session is published before the launch IPC resolves; what
  it lacks until then is an exact closer.
- The series watched/reset batch and the Xtream movie launch gate are
  page-wide, not owner-scoped.
- Only the Stalker movie hosts retire a pending start, and that does not
  clear the repeat guard of a launch still in flight.
- Name only the specs that exercise the Xtream series rules.

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

* docs(portals): tighten closer timing and page-wide gate wording

A launching session may already have its closer before the launch IPC
resolves, the Xtream movie launch gate does not cover the inline
player's diagnostic fallback, and the hero-state spec covers only the
external-player and reset rows.

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

* docs(portals): fix stale session-timing comment and search guard wording

- serial-details-external-launch.ts: Electron publishes a `launching`
  session as soon as the launch IPC arrives, not only after the launch
  settles. Comment only; no behaviour change.
- stalker-portal.md: the search host's selection check does not include
  the content type; a switch to a series supersedes the launch through
  the playback owner key instead.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 17:19:59 +02:00

445 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Xtream Portal Compatibility
This document captures the Xtream Codes compatibility rules shared by the
Electron and PWA paths.
## Runtime Selection And Ownership
`provideXtreamDataSource()` selects `ElectronXtreamDataSource` only when
`RuntimeCapabilitiesService.supportsXtreamSqliteDataSource` proves that the
complete SQLite-backed Xtream bridge is available. Otherwise it selects
`PwaXtreamDataSource`. A generic Electron or `window.electron` check is not the
data-source capability contract. Older favorites/recent branches that still
probe `window.electron` directly are migration debt, not an alternate runtime
selection rule; changes in those paths should follow the selected data source
and explicit capabilities.
Ownership follows the workspace boundaries:
- routed screens and screen-session orchestration:
`libs/portal/xtream/feature`
- Xtream API, cache, Signal Store, and data sources:
`libs/portal/xtream/data-access`
- provider-neutral collection services and reusable multi-source
discovery/resolution: `libs/portal/shared/data-access`
- reusable presentation: `libs/portal/shared/ui`
- pure provider-neutral contracts/helpers: `libs/portal/shared/util`
Persisted Xtream identity is playlist-scoped and content-type-aware:
`playlist_id + content.type + xtream_id`. Mixed collection keys likewise
include type plus provider ID because live, movie, and series IDs can collide.
Do not confuse the normalized SQLite row ID with provider `xtream_id`,
`stream_id`, or `series_id`, especially when recovering a hidden provider
category for detail playback.
See [Nx Workspace Boundaries](./nx-workspace-boundaries.md),
[SQLite DB Worker](./sqlite-db-worker.md),
[Portal Detail Navigation](./portal-detail-navigation.md), and
[VOD Multi-Source](./vod-multi-source.md).
## Connection Input
Xtream server URLs are normalized through
`normalizeXtreamServerUrl` from `@iptvnator/shared/interfaces`.
Rules:
1. Only `http` and `https` URLs are accepted.
2. URL username/password credentials are rejected.
3. Leading and trailing whitespace is ignored.
4. Trailing slashes are removed.
5. Full API or playlist URLs ending in `/player_api.php`, `/panel_api.php`, or `/get.php` are
reduced to the portal base URL.
6. Provider subpaths are preserved. For example,
`https://example.test/panel/player_api.php?...` becomes
`https://example.test/panel`.
The Xtream import form may extract `username` and `password` from full
`get.php` or `player_api.php` URLs, but stored playlist metadata should keep
the normalized `serverUrl` plus trimmed credentials.
### Explicit protocol discovery
Add and Edit source share `XtreamConnectionTestService` and form-owned
`createXtreamConnectionTestState` (`@iptvnator/services`). The explicit
**Test HTTPS and HTTP** button has a visible, accessible pre-request notice
that credentials may be sent over unencrypted HTTP. Clicking that action supplies
`allowHttpFallback`; the service defaults it to false, so an ordinary programmatic
test cannot authorize plaintext credentials. No modal or persistent opt-in is
needed. The test first probes the entered base, including the existing account-action variants.
Only an initial `ECONNREFUSED` or TLS wrong-version failure permits one HTTP
candidate on the same hostname/path. Default HTTPS port 443 becomes HTTP 80;
nonstandard explicit ports are preserved, never scanned. DNS, timeout, reset,
certificate, HTTP authorization, and redirected-destination failures cannot
trigger a downgrade. Aggregate and nested cause errors require positive
evidence from every address; mixed failures, cycles and truncated error trees
fail closed. TLS verification diagnostics include incomplete certificate chains.
A response from an earlier account-action variant also
prevents downgrade. HTTP failures (including panels returning 500 for unsupported actions)
still try the remaining account actions on the same candidate.
An active account on HTTP replaces only the form's `serverUrl`, with localized
copy explaining that HTTP is unencrypted. Add/Save persists through the existing
metadata path; Test never writes storage. Edits, reset, destruction and newer
tests invalidate pending results and prevent a stale fallback request. Add/Save
is disabled while that form's test is running. Empty or whitespace-only
credentials produce a localized validation message without a network request. Passive status checks, startup,
refresh and playback never perform protocol discovery.
Every completed current test refreshes `PortalStatusService` for the exact
connection: account responses publish status and expiration; terminal failures
publish unavailable with no expiration, without another network request.
Older passive checks cannot overwrite this explicit evidence; stale form
results do not publish it.
Both transports return an optional, credential-free `connectionFailure` envelope
only for `connectionTest` requests. Electron returns it rather than throwing
through IPC (which loses custom error fields); the PWA proxy strips the control
parameter before contacting the provider and preserves validated redirect-chain
evidence. PWA URL/DNS-policy refusals and Electron URL/redirect-policy errors are local
connection failures, never reported as provider HTTP statuses or used to
authorize HTTP. Older backends without the envelope cannot authorize HTTP discovery.
Provider JSON remains nested in `payload` and cannot provide this evidence.
The saved base drives catalog refresh, provider EPG, live/VOD/series/catch-up URL
construction and fresh Favorites/Recent resolution. The routed Xtream session
observes metadata connection changes and bootstraps the new connection. Separate
XMLTV source URLs, provider-supplied absolute URLs and already-issued download
or playback sessions retain their own URLs; they are not rewritten. No database
schema change or migration is required.
## Account Status
Account status handling uses `resolveXtreamPortalStatus`.
Compatibility rules:
1. Status text is case-insensitive, so `Active`, `active`, and `ACTIVE` are
treated the same.
2. `auth` values `1`, `'1'`, and `true` can mark a response as active when no
status text is present.
3. `auth` values `0`, `'0'`, and `false` mark the response inactive.
4. `exp_date` values `0`, negative numbers, missing values, or invalid values
are treated as no expiry.
5. A past positive `exp_date` marks the account expired even when status is
active.
Status probes try account-info-compatible Xtream variants in this order:
1. `action=get_account_info`
2. no `action`
3. `action=get_profile`
This fallback exists because real panels differ even when they advertise
Xtream Codes compatibility.
## Request Construction
Electron IPC and the PWA backend both construct API requests by appending
`/player_api.php` to the normalized portal base URL. They must not append
`player_api.php` to an already full `player_api.php` or `get.php` URL.
Credentials sent to the API are trimmed before serialization.
The PWA backend only proxies Xtream requests through registered provider
targets. Those targets are validated when registered and revalidated before the
`/xtream` proxy request, including protocol, URL credentials, DNS resolution,
and private-network checks.
## User-Agent
Electron's `XTREAM_REQUEST` and stream-probe handlers plus fresh Xtream movie
and series-episode download requests share the exported
`XTREAM_CLIENT_USER_AGENT` fallback. A playlist's explicit User-Agent,
Referer, and Origin are propagated to either download kind; the explicit
User-Agent still wins over the fallback. Legacy
download rows without a stored User-Agent receive the fallback when retrying,
resuming, or recovering a missing completed file. Download rows intentionally
survive individual source deletion; when the playlist row is already gone and
its type can no longer be recovered, a headerless legacy download receives the
same IPTV-player fallback, while a still-identifiable Stalker row remains
unchanged. Some Xtream
panels sit behind a WAF (e.g. Cloudflare) configured to challenge
generic/incomplete browser-looking User-Agents while allowlisting known IPTV
player clients; a player-style User-Agent (currently a VLC signature) avoids
that challenge page, whereas a browser-looking but non-browser TLS/HTTP client
(axios/curl with a Chrome or empty User-Agent) can be blocked even though a
real browser or a VLC-style client passes. Keep all three request sites using
the shared constant instead of inlining the string again.
## Playback URL Formats
When account info includes `user_info.allowed_output_formats`, the current
Xtream playlist keeps those formats for the active session. The default
application format is `auto`: live stream URL construction chooses `m3u8` when
the provider allows HLS, falls back to `ts` when MPEG-TS is the only known
standard format, and otherwise uses the first provider-advertised format. If
the provider does not advertise output formats, `auto` falls back to `m3u8`.
Manual `ts` and `m3u8` settings remain supported; when a manual setting is not
allowed by the portal, URL construction falls back to the first
provider-allowed format.
### Initial Auto HLS failure
The routed Xtream Live TV host attaches `ResolvedPortalPlayback.liveAutoTsUrl`
only when the current user preference is Auto and the current account explicitly
advertises both HLS and TS. `XtreamUrlService.constructAutoLiveTsUrl` uses the
same credential encoding and provider-subpath URL builder as the initial URL;
no URL string replacement, manifest preflight, stream download, or API probe is
introduced. Unknown/empty formats and manual HLS/TS have no alternative.
`WebPlayerLiveAutoFormat` may consume that alternative once per mounted logical
live session, in the same selected web player, after an owned terminal network
diagnostic with HTTP 4xx/5xx, before the native video element reports `playing`.
A successful manifest is not playback. HLS key/media/unknown-stage failures,
DRM payloads, content-info VOD/episodes, and catch-up are excluded. Generic
network failures, status zero, cancellation, timeouts without an HTTP response,
and decode errors keep the normal explicit recovery surface.
The old application is first removed from the render tree. The next render
callback starts TS only after the web engine's synchronous loader teardown and
only while source, logical session and user intent still match. The TS payload
keeps the original headers, title and playback metadata; Electron applies them
through its existing scoped header handoff. HTTP-media requests retain the
existing transport/trust rules and do not feed new evidence or admission into
the separate portal-API host-connectivity guard. Recovery recommendations still
require a user action and never auto-switch engines. No settings or playlist
cache is changed. A TS failure displays normal recovery actions; Retry repeats
TS without rearming the automatic attempt. Changing the actual logical channel
or playlist, or mounting a new host, creates a new session; same-session metadata
or provider-source refresh does not rearm it. Same-channel replay and metadata
refresh preserve the chosen TS while the candidate URLs, headers and mode stay
the same; an actual transport/provider change discards that selection. This deliberately avoids a sticky
playlist preference and reevaluates advertised formats on the next channel.
| Player/path | Initial Auto failure support |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTML5 / ArtPlayer, hls.js | One advertised TS attempt after terminal HLS manifest/level/segment HTTP failure; waits for the engine's own bounded retries |
| Video.js / VHS | Only an observable terminal HTTP diagnostic can trigger an attempt. A sole HLS rendition's failing segment can remain in VHS retry/exclusion cycles without such a diagnostic; use manual TS for that case |
| External MPV / VLC | Manual TS only. Process launch/exit and best-effort playing telemetry do not reliably identify the initial HLS media HTTP failure |
| Embedded MPV | Manual TS only; no equivalent structured HTTP diagnostic |
| Unified Favorites/Recent live resolution | Manual TS; it does not own fresh session account-format evidence |
For manual recovery, stop the current player, choose **Settings > Playback >
Stream Format > ts**, save, and reopen the channel. This keeps the chosen player
and avoids a second uncorrelated external process. The settings description
explains this route. This is partial playback coverage of #1513, not a claim
that all external-player and VHS scenarios automatically recover.
Synthetic regression media lives in the mock's `live-fallback:live-fallback`
account: HLS manifest 200 plus segment 403, manifest 403, a playable local
H.264/AAC TS alternative, a failing TS alternative, and a delayed HLS segment.
Tests assert actual video progress and the number of concrete TS requests.
If stored Xtream playback credentials contain an invalid server URL or blank
username/password, stream URL construction returns an empty URL instead of
throwing during playback.
VOD playback uses the standard
`/movie/{username}/{password}/{streamId}.{containerExtension}` URL after
resolving the stream id and container extension as one source. Fields from
`movie_data` take priority, with the top-level catalog fields used as a
fallback when extended metadata is absent or incomplete. A source requires a
positive integer id and a non-empty container extension; otherwise URL
construction returns an empty string.
The Electron catalog cache does not persist the container extension. If the
merged detail response and cached row still cannot resolve a source, the detail
loader requests the exact VOD from its provider category and merges that raw
catalog row in memory. An Electron route category uses the SQLite row id, so
the loader maps it through the complete persisted category set, including
hidden categories, before sending the provider `category_id`. Cross-portal
Similar links already carry that provider id, so recovery accepts either the
SQLite `id` or `xtream_id` representation while preserving local-id lookup
precedence. If the numeric representations collide, candidate provider ids are
deduplicated and tried in that order until the exact VOD is found. PWA falls
back to its API-backed visible categories, and an unresolved database id is
never sent as though it were a provider id. This recovery request is skipped
when the detail response or owner-valid cached catalog fields are already
sufficient. Recovery is best-effort and never gates the detail page: the initial
sparse selection is published and the loading shell ends before the category
lookup completes, then a successful result upgrades that same fallback
reactively with playback actions. Detail, recovered, and cached
playback fields remain separate candidates: the first complete pair wins, so
two incomplete rows can never synthesize a source. A failed lookup leaves the
already-rendered item safely unplayable. Detail requests are generation- and
playlist-guarded, and detail teardown invalidates the active generation, so a
late response cannot replace a newer selection or repopulate a closed detail.
In-memory VOD category and stream arrays record the playlist that populated
them. Cross-portal Favorites/Recent details ignore arrays owned by another
playlist, so colliding Xtream ids cannot suppress recovery or contribute a
foreign playback extension, title, poster, category, or recommendation.
Metadata availability and VOD playability are independent. An empty or sparse
`get_vod_info` response keeps the curated fallback detail page, but that page
offers the same Play/Resume, Favorite, and Download actions when the source
resolves. An unresolved source exposes no actions. Playback and download
descriptors also fall back to the catalog title and poster after `info` and
`movie_data`.
## Catch-Up Playback URLs
Xtream-compatible portals differ on archive playback URL shape. IPTVnator
supports these catch-up variants:
1. REST-style `/timeshift/{username}/{password}/{duration}/{start}/{streamId}.ts`
and `.m3u8`.
2. Legacy `/streaming/timeshift.php?username=...&password=...&stream=...&start=...&duration=...`
with optional `extension=ts` or `extension=m3u8`.
Electron probes concrete catch-up variants before caching a playlist-level
choice. The cache key includes the playlist id and the normalized
`allowed_output_formats` advertised by the provider, so a catch-up variant
detected before account capabilities are known cannot force stale MPEG-TS URLs
after the portal later reports HLS-only playback. The probe uses a short range
`GET`, follows only validated redirects, and accepts only `200` or `206` as
playable. MPEG-TS is preferred before HLS when the provider allows it because
some portals return a valid HLS manifest while the first media segment fails in
Chromium/video.js. PWA fallback keeps the REST MPEG-TS URL when no Electron
probe API is available.
Catch-up is offered from the Xtream Live TV tab and from the unified
collection surfaces (per-playlist and global Favorites and Recent). The
`tv_archive` / `tv_archive_duration` columns are carried through the
favorites and recently-viewed DB projections and mapped onto
`UnifiedCollectionItem.tvArchive` / `tvArchiveDuration` so the shared live
tab can gate the timeline's archive window. `tv_archive_duration` is
interpreted as **days** everywhere, matching
`live-stream-layout.controlledArchiveDays` (issue #1138).
While a programme plays from the archive, both entry points draw it on the
player's seek bar as a titled segment; see "Timeline segments" in the
[player controls contract](player-controls-contract.md#timeline-segments).
Programme details in the Live TV and Favorites/Recent EPG timeline/list also
offer **Copy archive URL**. This uses the same resolver and persisted server
timezone without changing playback. See the M3U module's "Copy archive URL"
contract for clipboard feedback and credential/header handling.
### Start time is the panel's clock, not the viewer's
The `{start}` segment (`Y-m-d:H-M`) is read by the panel with `strtotime()`
in ITS OWN timezone — the one it reports as `server_info.timezone` in the
account-info response — never the viewer's local clock (issue #1562). The
timezone is learned by `withPortal.checkPortalStatus()` and normalized by
`resolveXtreamServerTimezone()` (`libs/shared/interfaces/src/lib/xtream-server-timezone.util.ts`):
- a timezone name the runtime's ICU resolves (`Europe/London`) is kept as is;
- otherwise (`UTC+3`, a typo, an unknown alias) the offset is derived from
the clock pair the same response carries — `time_now` read as a naive UTC
wall clock minus `timestamp_now`, snapped to 15 minutes — and stored as
`UTC±HH:MM`. This is a snapshot without DST rules: for such a panel,
programmes on the far side of a DST switch are off by an hour until the
next account-info check refreshes the offset. Xtream Codes reports PHP
timezone identifiers (IANA names), so the snapshot only serves
non-standard servers, where the alternative was the viewer's clock;
- with neither, nothing is stored and the URL falls back to the viewer's
clock, the only remaining guess.
The value is persisted on the playlist row (`Playlist.serverTimezone`)
because the two catch-up entry points read different sources: the Live TV
layout uses the store's `currentPlaylist`, while the Favorites / Recent
resolver (`StreamResolverService.resolveXtreamCatchupUrl`) reads the stored
row through `dbGetAppPlaylist` / IndexedDB. The write goes through
`IXtreamDataSource.rememberServerTimezone` and is atomic against the row's
CURRENT connection in both runtimes — Electron: one conditional UPDATE
(`DB_SET_PLAYLIST_SERVER_TIMEZONE` → `setPlaylistServerTimezone`,
`json_set(payload, '$.serverTimezone', …)` only while `serverUrl`/`username`/
`password` still match the request and the payload does not already carry
the value; a malformed payload is never rewritten); PWA:
`PlaylistsService.transformPlaylistMeta`, whose read and write share one
IndexedDB readwrite cursor transaction. No read precedes the write, because
the database worker interleaves requests and the Xtream edit dialog saves
through `DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue: a
read-modify-write could hand a concurrent upsert's newer payload back to the
past or undo an edit that landed in between. The reverse ordering is covered
on the upsert side: `DB_UPSERT_APP_PLAYLIST(S)` (`playlistConflictUpdate`)
carries the STORED clock into a snapshot that has none while the row still
points at the same connection, so a favorites, recent-items or metadata write
built from a pre-clock snapshot cannot strip it; a snapshot carrying its own
clock, or moving the source, wins as is. The store offers the resolved
value on every check (a transient write failure is retried by the next one),
patches its own state only while the selected playlist is still the panel the
answer came from (`answersFor`: id + connection), and returns the store's
verdict about the current selection when it is not. An update that moves
`serverUrl` (`mergePlaylistMeta`, `DB_UPDATE_PLAYLIST`) drops the clock until
the next account-info check. Electron's `DB_GET_PLAYLIST` projects the
persisted value from the row payload so the store is seeded with it before,
or without, the account-info check. The same timezone lets
`XtreamApiService` read timestamp-less EPG `start`/`end` strings in the
clock the panel wrote them in (`parseXtreamServerLocalDateTime`);
`start_timestamp` still wins whenever it is present. Formatting uses
`hourCycle: 'h23'`, so server midnight renders as `00`, never `24`.
### Downloading completed catch-up programmes
Desktop Xtream Live TV exposes a TS-only download action in programme details.
It reuses the canonical timeshift resolver and original timestamps, preserves
playback headers and does not change playback. See
[Download Manager](download-manager.md#xtream-archive-downloads) for identity,
restart, expiry and transport-completion limits.
## Store composition and catalog windowing
`XtreamStore` is the public facade built with `signalStore()`, composing
`signalStoreFeature()` features for portal, content, selection, search, EPG,
player, favorites, recent and playback positions. Most features live under
`libs/portal/xtream/data-access/src/lib/stores/features/`; favorites and recent
items live directly under the data-access library’s `src/lib/`.
Routed components consume that facade; features delegate persistence/networking
to `IXtreamDataSource`, selected through `provideXtreamDataSource()`. Complete
SQLite capability uses database-first cache reads and API fill; the PWA source
uses API requests and session memory. This does not move screen orchestration
into shared utility projects.
Catalog lazy loading: catalog grids scroll infinitely instead of paging.
`withSelection` keeps a `visibleCount` render window over the in-memory
catalog plus bounded per-selection scroll snapshots for detail/tab
round-trips; the shared `InfiniteScrollDirective`
(`libs/portal/shared/ui`) measures container overflow to auto-fill tall
viewports (terminating on lack of container growth, not on a load count)
and fires `loadMore` near the bottom. The search layout routes its results
container through the same directive (`nearEnd*` inputs). Stalker feeds the
same contract from server-paged appends: portal pages accumulate into one
deduplicated list, `hasMoreContent` derives from accumulated length vs
`total_items`, a failed append keeps loaded pages and offers a tail retry,
and the facade maps page 0 to the skeleton and later pages to the tail
spinner. These catalog/search surfaces use incremental loading instead of
page buttons.
## Forced external launches from detail pages
The "…" menu's MPV/VLC launch follows the shared rules in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages).
The movie page's pin and reset rules are in
[VOD Multi-Source](./vod-multi-source.md#menu-launch-and-reset-follow-the-primary-button).
The series page keeps its launch state at module level in
`serial-details-external-launch.ts`, so it outlives a recreated page:
- The owner is `playlist:series` (`launchOwner()`). It changes when the page
shows another series and is null once the page is gone.
- Forced launches of one owner run on one chain. A later launch waits for the
earlier one to settle, closes the owner's running episode session and then
launches. Each step rechecks the owner, and a launch that resolves after the
page left the owner closes the session it opened.
- The duplicate guard is keyed by page token plus episode. The token
(`pageToken()`) is owner, page instance and visit, so a launch left behind
by an earlier visit of the same series does not swallow a launch from the
reopened page; that launch queues on the owner's chain.
- While a forced launch of the owner is pending (`forcedLaunchPending`), a
start that does not force a player is queued instead of started. One choice
is kept per owner, the latest wins, and it carries the host and `start` of
the page that made it. Once the chain settles, the player the launch opened
is closed first while the owner stays pending. The choice is dropped when
that page no longer shows the owner or the close was not confirmed.
- The pending flag also disables the menu's external-player row and the
season and series watched actions, and counts as active playback for "Reset
progress".
- The launch-position marker and a launch-failure message apply only while
the page token is unchanged.
Regression coverage: `serial-details-external-launch.spec.ts` (chain,
duplicate guard, queued choice), `serial-details-playback.service.spec.ts`
(page token) and, for the external-player and reset rows,
`libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts`.