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>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-07-29 21:53:42 +02:00
1 parent b1f77c678e
commit 063662028a
161 files changed
+17545 -484

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
---
type: feature
area: portals
---
Movies that exist in several of your playlists now show a "Sources" chip on the
detail page. Switch playlist mid-film and playback continues from the same
timecode, pin a preferred source per movie, and when a stream dies the error
screen offers the alternatives instead of a dead end. Optional auto-switching is
off by default.
+16 -1
View File
@@ -280,7 +280,7 @@ This is an Nx monorepo with the following structure:
- **portal/stalker/{data-access,feature}** - StalkerStore and routed Stalker components
- **portal/catalog/feature** - Portal catalog UI
- **portal/downloads/feature** - Download manager UI
- **portal/shared/{data-access,ui,util}** - Cross-portal shared code
- **portal/shared/{data-access,ui,util}** - Cross-portal shared code (incl. the VOD multi-source discovery/resolve/ranking layer in `data-access/src/lib/multi-source/`)
- **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`)
- **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`)
- **shared/logging** - Dependency-free structured redaction for diagnostic logs
@@ -631,6 +631,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- `downloads` - Download manager state
- `appState` - Key-value app state (also tracks one-off data migrations)
- `tmdbMetadata` - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language)
- `vodSourcePins` (`vod_source_pins`) - VOD multi-source per-movie preferred playlist, keyed by a portal-agnostic match key (defined in `vod-source-pins.schema.ts`, re-exported by `schema.ts`)
- **Connection**: `libs/shared/database/src/lib/connection.ts`
- `createTables()` auto-creates tables on init (`CREATE TABLE IF NOT EXISTS`)
- Provides full read-write access for `electron-backend` and a read-only mode
@@ -891,6 +892,20 @@ engine` (restart required) or
- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
**VOD Multi-Source** (alternative sources for a movie):
- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 460px anchored CDK-overlay popover (`libs/ui/components/src/lib/vod-sources/`; not `MatMenu`, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The chip counts alternative **streams**; the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal.
- Scope v1 is **Xtream ↔ Xtream, movies only, Electron only**. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces `content_type:'live'`; both are additive later since `VodSourceCandidate.portalType` already carries all three. In the PWA every entry point is gated off by a bridge `typeof` check and the chip renders nothing.
- **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height — but a known height vetoes the answer on every tier, since cropping only removes lines: a taller frame is a different shape (1440×1080 anamorphic or 1600×900 are not 720p, 960×540 is not 576p) and gets no tag rather than a wrong one carrying `api` provenance. The route's OWN row is never resolved, so it takes its facts from the `get_vod_info` the page already loaded (`providerVodMetadataOf`, shared with the resolver) and picks them up via `refreshRouteFacts()` even when they arrive without changing the movie identity — otherwise `audioDiffersFactually` has nothing on one side and the dub warning cannot fire on a route-to-alternative switch.
- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches an ASCII token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an *exact* match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row — but the two tiers read different forms: the base tier accepts bracketed or trailing (it just stripped a trailing year, the only thing separating "Dune 1984" from "Dune 2021"), while the exact tier reads bracketed ONLY, since reaching it means both titles are the same string and a trailing number is then part of the NAME ("Blade Runner 2049" against a metadata year of 2017 would otherwise vanish once enrichment lands). A non-ASCII token cannot be folded by `LOWER()` (ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), so `caseInsensitiveGlobPattern` folds the case in JS and emits one `[lowerUpper]` class per character — returning `null`, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (`ß`→`SS`). The movie's own year comes from `releaseTagYear` (bracketed or trailing only), never `extractYear`: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password.
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — three key sets (`pinKeysFor`): `lookup` passes every alias most-trusted-first, `write` holds only keys naming exactly one film, and `loaded` records where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. A write stores the decision under **every** key in `write` (`setVodSourcePin(db, pin, retireKeys, aliasKeys)`: one upsert per key plus the leftover retirement, in a single transaction), because a movie's identity grows — recorded only under the enriched `tmdb:` key, a pin is invisible to the next reopen, which starts out with just a title and a year, and stays invisible for good if enrichment is off or never answers. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. The primary button says nothing at all until that row is in, and "is it in" is answered by comparing the loaded pin **id** rather than mere presence, or re-pinning would leave the button wearing the previous copy's timecode. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
- Claims in the present tense (the "Playing from" caption and the source row's `Playing` badge) are gated on `VodDetailsRouteComponent.playbackLive`, never on `isActive` — discovery marks a source active before anything plays and it stays active after the player closes. Inline that means a `timeupdate` has arrived (`inlinePlayback()` is only the request to play); external it means the session is past `launching`. A merely selected row reads `Current`.
- Pins are included in playlist backup as the optional `sourcePins` collection, carried under the playlist they point at; `matchKey` survives untouched and only the playlist id is remapped on restore (older archives simply lack the field).
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only — the toggle is hidden in settings and in the sources menu on MPV, VLC and Embedded MPV, since only the built-in web players raise the playback diagnostic that triggers it (`reportsPlaybackFailures()`); it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally — but SELECTION is not an attempt: `setActiveSource` only selects, `markPlaying` spends the turn, and `runFailover` retires whatever is on screen before picking, so discovery selecting the route row (or a pin selecting an alternative) before anything plays cannot burn a healthy fallback; and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist (through `playlistDisplayLabel`, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state a spoken **language** as fact — `audioLanguage`, never `audio`. The latter holds the codec whenever the fact came from the API, and a codec cannot answer that question: AAC and AC3 routinely carry the same dub while two AC3 tracks can carry different ones, so comparing codecs fired on identical-language re-encodes and stayed silent on real dub changes. Few panels tag a language, so the warning is usually silent — which is the honest state.
- HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup), and carries the playlist's own `userAgent`/`referer`/`origin` (`StreamProbeHeaders`) — a panel that requires them answers 401/403 otherwise and a working source would be shown as dead. No ffprobe — the binary is not bundled.
- See `docs/architecture/vod-multi-source.md`
**Radio Player**:
- Dedicated audio player for channels with `radio="true"` M3U attribute
@@ -0,0 +1,131 @@
import {
addXtreamPortal,
clickCategoryByNameExact,
clickGridListCardByTitle,
closeElectronApp,
expect,
launchElectronApp,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamVodFixture,
getXtreamTitle,
pickDistinctTitles,
} from './portal-mock-fixtures';
/**
* `multisrc1` and `multisrc2` are two distinct credential pairs that share one
* faker seed, so both portals generate an IDENTICAL catalog. That overlap is
* the whole premise of this feature: the same movie in two playlists.
*/
const portalA = { username: 'multisrc1', password: 'multisrc1' };
const portalB = { username: 'multisrc2', password: 'multisrc2' };
test.describe('VOD multi-source', () => {
test('offers the same movie from another playlist and switches to it', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodFixture = await fetchXtreamVodFixture(request, portalA);
const [movieTitle] = pickDistinctTitles(
vodFixture.items,
getXtreamTitle
);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
...portalA,
name: 'Multi Source A',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
// The second portal is what makes an alternative exist at all.
await addXtreamPortal(app.mainWindow, {
...portalB,
name: 'Multi Source B',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(
app.mainWindow,
vodFixture.categoryName
);
await clickGridListCardByTitle(app.mainWindow, movieTitle);
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
// Discovery is lazy, so the chip appears after the detail view.
const chip = app.mainWindow.locator('app-vod-sources-chip button');
await expect(chip).toBeVisible({ timeout: 20000 });
// Exactly one alternative: the same film in the other playlist.
await expect(chip).toContainText('1');
await chip.click();
// Both the playing source and the alternative are listed.
const rows = app.mainWindow.locator('app-vod-source-row');
await expect(rows).toHaveCount(2);
// Portal B was added last and is active, so A is the alternative.
await expect(
rows.filter({ hasText: 'Multi Source A' })
).toHaveCount(1);
// The playing row must name its playlist, never a raw UUID.
await expect(
rows.filter({ hasText: 'Multi Source B' })
).toHaveCount(1);
} finally {
await closeElectronApp(app);
}
});
test('shows no sources chip when only one playlist has the movie', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodFixture = await fetchXtreamVodFixture(request, portalA);
const [movieTitle] = pickDistinctTitles(
vodFixture.items,
getXtreamTitle
);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
...portalA,
name: 'Multi Source A',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(
app.mainWindow,
vodFixture.categoryName
);
await clickGridListCardByTitle(app.mainWindow, movieTitle);
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
// The action row must look exactly as it did before this feature.
await expect(
app.mainWindow.locator('.play-btn').first()
).toBeVisible({ timeout: 20000 });
await expect(
app.mainWindow.locator('app-vod-sources-chip')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});
@@ -50,6 +50,18 @@ const tmdbCacheEntry = {
tmdbId: 603,
payload: '{"id":603}',
};
const vodSourceRequest = {
title: 'The Matrix',
year: 1999,
excludePlaylistId: playlistId,
};
const vodSourceMatchKeys = ['tmdb:603', 'title:the matrix:1999'];
const vodSourcePin = {
matchKey: 'tmdb:603',
playlistId,
contentId: 42,
portalType: 'xtream',
};
export const dbPreloadCases: PreloadInvokeCase[] = [
{
@@ -388,6 +400,53 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
channel: 'DB_MATCH_TITLES',
forwardedArgs: [['The Matrix', 'Inception']],
},
{
method: 'dbFindTitleSources',
args: [vodSourceRequest],
channel: 'DB_FIND_TITLE_SOURCES',
forwardedArgs: [vodSourceRequest],
},
{
method: 'dbGetVodSourcePin',
args: [vodSourceMatchKeys],
channel: 'DB_GET_VOD_SOURCE_PIN',
forwardedArgs: [vodSourceMatchKeys],
},
{
method: 'dbListVodSourcePins',
args: ['playlist-1'],
channel: 'DB_LIST_VOD_SOURCE_PINS',
forwardedArgs: ['playlist-1'],
},
{
method: 'dbClearVodSourcePinsForPlaylist',
args: ['playlist-1'],
channel: 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST',
forwardedArgs: ['playlist-1'],
},
{
method: 'dbSetVodSourcePin',
args: [vodSourcePin, ['title:dune:'], ['title:dune:2021']],
channel: 'DB_SET_VOD_SOURCE_PIN',
// Both key lists ride along, so the write, the extra keys it is also
// stored under, and the retirement are one transaction rather than
// separate calls that can half-apply.
forwardedArgs: [vodSourcePin, ['title:dune:'], ['title:dune:2021']],
},
{
method: 'dbReplaceVodSourcePins',
args: ['playlist-1', [vodSourcePin]],
channel: 'DB_REPLACE_VOD_SOURCE_PINS',
// One statement for the whole playlist: restore cannot afford a clear
// and a write that can half-apply.
forwardedArgs: ['playlist-1', [vodSourcePin]],
},
{
method: 'dbClearVodSourcePin',
args: [vodSourceMatchKeys],
channel: 'DB_CLEAR_VOD_SOURCE_PIN',
forwardedArgs: [vodSourceMatchKeys],
},
];
export const epgPreloadCases: PreloadInvokeCase[] = [
@@ -57,6 +57,8 @@ import type {
Settings,
TmdbCacheEntry,
TmdbCacheMediaType,
StreamProbeHeaders,
VodSourcePin,
XtreamCategory,
} from '@iptvnator/shared/interfaces';
import {
@@ -660,6 +662,11 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId),
xtreamProbeUrl: (url: string, method?: 'GET' | 'HEAD') =>
ipcRenderer.invoke('XTREAM_PROBE_URL', { url, method }),
probeStreamUrl: (
url: string,
method?: 'GET' | 'HEAD',
headers?: StreamProbeHeaders
) => ipcRenderer.invoke('STREAM_PROBE_URL', { url, method, ...headers }),
refreshPlaylist: (payload: PlaylistRefreshPayload) =>
ipcRenderer.invoke('PLAYLIST:REFRESH', payload),
cancelPlaylistRefresh: (operationId: string) =>
@@ -886,6 +893,33 @@ const electronApi: ElectronBridgeApi = {
dbClearTmdbMetadata: () => ipcRenderer.invoke('DB_CLEAR_TMDB_METADATA'),
dbMatchTitles: (titles: string[]) =>
ipcRenderer.invoke('DB_MATCH_TITLES', titles),
// VOD multi-source
dbFindTitleSources: (request: {
title: string;
year?: number | null;
excludePlaylistId?: string | null;
}) => ipcRenderer.invoke('DB_FIND_TITLE_SOURCES', request),
dbGetVodSourcePin: (matchKeys: string[]) =>
ipcRenderer.invoke('DB_GET_VOD_SOURCE_PIN', matchKeys),
dbListVodSourcePins: (playlistId: string) =>
ipcRenderer.invoke('DB_LIST_VOD_SOURCE_PINS', playlistId),
dbClearVodSourcePinsForPlaylist: (playlistId: string) =>
ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', playlistId),
dbSetVodSourcePin: (
pin: VodSourcePin,
retireKeys?: string[],
aliasKeys?: string[]
) =>
ipcRenderer.invoke(
'DB_SET_VOD_SOURCE_PIN',
pin,
retireKeys ?? [],
aliasKeys ?? []
),
dbReplaceVodSourcePins: (playlistId: string, pins: VodSourcePin[]) =>
ipcRenderer.invoke('DB_REPLACE_VOD_SOURCE_PINS', playlistId, pins),
dbClearVodSourcePin: (matchKeys: string[]) =>
ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PIN', matchKeys),
// Playback Positions
dbSavePlaybackPosition: (
playlistId: string,
@@ -109,7 +109,18 @@ export function createDbMock(selectResultsByCall: unknown[][] = []) {
return query;
});
const insertValues = jest.fn().mockResolvedValue(undefined);
// Same `.run()` requirement as the update below: an insert executed inside
// a synchronous transaction callback has to be the synchronous form.
const insertRun = jest.fn();
const insertChain: Record<string, unknown> = {
run: insertRun,
then: (
resolve: (value: unknown) => void,
reject: (reason: unknown) => void
) => Promise.resolve(undefined).then(resolve, reject),
};
insertChain.onConflictDoUpdate = jest.fn().mockReturnValue(insertChain);
const insertValues = jest.fn().mockReturnValue(insertChain);
const insert = jest.fn().mockReturnValue({ values: insertValues });
// Prepared-statement writes MUST use `.run()` (synchronous) rather than
@@ -138,6 +149,8 @@ export function createDbMock(selectResultsByCall: unknown[][] = []) {
.fn()
.mockReturnValue({ execute: deleteExecute, run: deleteRun });
const deleteResult = {
// Inside a transaction the delete must be the synchronous form too.
run: deleteRun,
prepare: deletePrepare,
then: (
resolve: (value: unknown) => void,
@@ -170,6 +183,7 @@ export function createDbMock(selectResultsByCall: unknown[][] = []) {
deleteFn,
deletePrepare,
deleteRun,
insertRun,
deleteWhere,
insert,
insertValues,
@@ -0,0 +1,278 @@
import { findTitleSources } from './title-sources.operations';
import {
compiledQuery,
createDbMock,
duneRow,
} from './title-sources.spec-data';
/**
* What survives the normalized confirmation, and which playlists are in scope.
* Split from the query-shape suite so both stay inside the file-size rule.
*/
describe('title-sources.operations — confirmation and scoping', () => {
describe('match confirmation', () => {
it('maps a confirmed row onto the candidate shape as an exact match', async () => {
const { db } = createDbMock([duneRow]);
await expect(
findTitleSources(db, { title: 'Dune' })
).resolves.toEqual([
{
playlistId: 'playlist-1',
playlistName: 'Portal One',
categoryId: 12,
xtreamId: 501,
title: 'Dune',
posterUrl: 'https://cdn.example.com/dune.jpg',
matchConfidence: 'exact',
year: null,
},
]);
});
it('confirms a year-stripped candidate as a fuzzy match', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]);
const matches = await findTitleSources(db, { title: 'Dune' });
expect(matches).toHaveLength(1);
expect(matches[0].matchConfidence).toBe('fuzzy');
expect(matches[0].year).toBe(1984);
});
it('drops candidates whose normalized title differs', async () => {
const { db } = createDbMock([
{ ...duneRow, title: 'Dune Part Two' },
]);
await expect(
findTitleSources(db, { title: 'Dune' })
).resolves.toEqual([]);
});
it('rejects a remake whose year is written in brackets', async () => {
// "Dune (1984)" normalizes to exactly "dune" — the brackets are
// stripped as tag noise — so without reading the year out first it
// is an EXACT match for the 2021 film, ranked above every fuzzy
// one, and auto-failover would switch to the wrong movie.
const { db } = createDbMock([{ ...duneRow, title: 'Dune (1984)' }]);
await expect(
findTitleSources(db, { title: 'Dune', year: 2021 })
).resolves.toEqual([]);
});
it('matches a bracketed year against the same film', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune (2021)' }]);
const matches = await findTitleSources(db, {
title: 'Dune',
year: 2021,
});
expect(matches).toHaveLength(1);
// Reported too, so the row can say which film it is.
expect(matches[0].year).toBe(2021);
});
it('rejects a base-tier match whose year contradicts the request', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]);
// Same base title, but the user is watching the 2021 film — a
// year-stripped match is only trustworthy when the two sides do
// not actively disagree.
await expect(
findTitleSources(db, { title: 'Dune', year: 2021 })
).resolves.toEqual([]);
});
it('keeps an identical title whose tail is part of its name', async () => {
const { db } = createDbMock([
{ ...duneRow, title: 'Blade Runner 2049' },
]);
// Enrichment supplies the real release year, 2017, while the title
// ends in a four-digit number that is part of the NAME. Reading it
// as a release tag makes the years "disagree" and the genuine copy
// disappears the moment metadata lands — the one point at which the
// user has most reason to expect it.
const matches = await findTitleSources(db, {
title: 'Blade Runner 2049',
year: 2017,
});
expect(matches).toHaveLength(1);
expect(matches[0].matchConfidence).toBe('exact');
});
it('still rejects a bracketed remake of an otherwise identical title', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune (1984)' }]);
// The relaxation above must not reach a BRACKETED year: brackets
// are unambiguously a tag, never part of the name, so a stated
// disagreement still means a different film.
await expect(
findTitleSources(db, { title: 'Dune', year: 2021 })
).resolves.toEqual([]);
});
it('keeps a base-tier match when one side has no known year', async () => {
const { db } = createDbMock([duneRow]);
// Query carries the year tag, the candidate does not: an unknown
// year must never block the match.
const matches = await findTitleSources(db, { title: 'Dune 2021' });
expect(matches).toHaveLength(1);
expect(matches[0].matchConfidence).toBe('fuzzy');
expect(matches[0].year).toBeNull();
});
it('tolerates a one-year drift between the two sides', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune 2021' }]);
const matches = await findTitleSources(db, {
title: 'Dune',
year: 2020,
});
expect(matches).toHaveLength(1);
expect(matches[0].matchConfidence).toBe('fuzzy');
});
});
describe('playlist scoping and duplicates', () => {
it('never returns the playlist the user is already on', async () => {
const { db } = createDbMock([
duneRow,
{
...duneRow,
content_id: 2,
xtream_id: 777,
playlist_id: 'playlist-2',
playlist_name: 'Portal Two',
},
]);
const matches = await findTitleSources(db, {
title: 'Dune',
excludePlaylistId: 'playlist-1',
});
expect(matches).toHaveLength(1);
expect(matches[0].playlistId).toBe('playlist-2');
});
it('excludes the current playlist inside both queries, not after them', async () => {
// Filtering afterwards is not enough: the current playlist often
// lists the film several times, and those rows would spend the row
// budget the alternatives need — the chip then vanishes even
// though other playlists have the movie.
const fts = createDbMock([duneRow]);
await findTitleSources(fts.db, {
title: 'Dune',
excludePlaylistId: 'playlist-1',
});
const ftsQuery = compiledQuery(fts.all);
expect(ftsQuery.sql).toContain('cat.playlist_id <> ?');
expect(ftsQuery.params).toContain('playlist-1');
expect(ftsQuery.sql.indexOf('cat.playlist_id <> ?')).toBeLessThan(
ftsQuery.sql.indexOf('LIMIT')
);
// The short-title scan is a separate statement and needs it too.
const scan = createDbMock([duneRow]);
await findTitleSources(scan.db, {
title: 'It',
excludePlaylistId: 'playlist-1',
});
const scanQuery = compiledQuery(scan.all);
expect(scanQuery.sql).toContain('cat.playlist_id <> ?');
expect(scanQuery.params).toContain('playlist-1');
// The scan takes no window, so the cost it saves here is the rows
// read rather than the rows kept.
expect(scanQuery.sql.indexOf('cat.playlist_id <> ?')).toBeLessThan(
scanQuery.sql.indexOf('ORDER BY')
);
});
it('collapses one playlist’s copies before the FTS limit applies', async () => {
// A playlist listing the film in dozens of categories produces
// identically ranked rows. Collapsing them only in TypeScript
// cannot recover the other playlists the window never reached.
const { db, all } = createDbMock([duneRow]);
await findTitleSources(db, { title: 'Dune' });
const query = compiledQuery(all);
expect(query.sql).toContain(
'GROUP BY cat.playlist_id, c.xtream_id'
);
expect(query.sql.indexOf('GROUP BY')).toBeLessThan(
query.sql.indexOf('LIMIT')
);
});
it('keeps one named copy of the excluded playlist', async () => {
// A pin can point at another copy of the film inside the playlist
// being viewed. Excluding the playlist wholesale would drop that
// row, and the explicit preference would be silently ignored.
const { db, all } = createDbMock([
{ ...duneRow, xtream_id: 777, content_id: 9 },
]);
const matches = await findTitleSources(db, {
title: 'Dune',
excludePlaylistId: 'playlist-1',
keepContentId: 777,
});
const query = compiledQuery(all);
expect(query.sql).toContain('OR c.xtream_id = ?');
expect(query.params).toContain(777);
// And the TypeScript pass must not throw it away either.
expect(matches.map((match) => match.xtreamId)).toEqual([777]);
});
it('leaves the queries unfiltered when no playlist is excluded', async () => {
const { db, all } = createDbMock([duneRow]);
await findTitleSources(db, { title: 'Dune' });
expect(compiledQuery(all).sql).not.toContain('cat.playlist_id <>');
});
it('collapses the same film listed in several categories', async () => {
const { db } = createDbMock([
duneRow,
{ ...duneRow, content_id: 2, category_xtream_id: 13 },
{ ...duneRow, content_id: 3, category_xtream_id: 14 },
]);
const matches = await findTitleSources(db, { title: 'Dune' });
expect(matches).toHaveLength(1);
expect(matches[0].categoryId).toBe(12);
});
it('keeps the same film when it lives in different playlists', async () => {
const { db } = createDbMock([
duneRow,
{
...duneRow,
playlist_id: 'playlist-2',
playlist_name: 'Portal Two',
},
]);
const matches = await findTitleSources(db, { title: 'Dune' });
expect(matches.map((match) => match.playlistId)).toEqual([
'playlist-1',
'playlist-2',
]);
});
});
});
@@ -0,0 +1,174 @@
import { findTitleSources } from './title-sources.operations';
import {
compiledQuery,
createDbMock,
createFailingDbMock,
duneRow,
} from './title-sources.spec-data';
describe('title-sources.operations', () => {
describe('unusable queries', () => {
it('returns nothing for a blank or non-string title', async () => {
const { db, all } = createDbMock([duneRow]);
await expect(findTitleSources(db, { title: '' })).resolves.toEqual(
[]
);
await expect(
findTitleSources(db, { title: ' ' })
).resolves.toEqual([]);
await expect(
findTitleSources(db, {
title: null as unknown as string,
})
).resolves.toEqual([]);
expect(all).not.toHaveBeenCalled();
});
it('still searches when no token survives the trigram minimum', async () => {
// "up" is two chars, so the trigram tokenizer cannot index it. That
// is a real movie title, and it must not silently return nothing —
// the query falls back to a scan instead of being discarded.
const upRow = { ...duneRow, title: 'Up', xtream_id: 77 };
const { db, all } = createDbMock([upRow]);
const result = await findTitleSources(db, { title: 'Up' });
expect(all).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({ xtreamId: 77, matchConfidence: 'exact' })
);
});
it('scans on a word boundary, shortest first, and never truncates', async () => {
// A substring scan for "It" matches "Titanic" and "The Italian
// Job"; capped and sorted by title, those can push the real "It"
// out of the result set and discovery reports no sources at all.
// The scan therefore asks for the token as a WORD and orders by
// title length (a match is the title plus decoration).
const scan = createDbMock([]);
await findTitleSources(scan.db, { title: 'It' });
const scanQuery = compiledQuery(scan.all);
expect(scanQuery.sql).toContain('GLOB ?');
expect(scanQuery.sql).not.toContain('LIKE ?');
// Each letter is a class of its own forms, so the assertion is
// about the SHAPE: a word boundary either side of one class per
// character.
expect(scanQuery.params).toContainEqual(
expect.stringMatching(
/^\*\[\^a-z0-9\](\[[^\]]*i[^\]]*\])(\[[^\]]*t[^\]]*\])\[\^a-z0-9\]\*$/
)
);
expect(scanQuery.sql).toContain('ORDER BY LENGTH(c.title)');
// No window at all: unlike FTS this cannot rank, so a limit would
// silently decide which valid sources the user may see — and the
// GLOB reads every row regardless, so it would not even save work.
expect(scanQuery.sql).not.toContain('LIMIT');
const fts = createDbMock([]);
await findTitleSources(fts.db, { title: 'Dune' });
expect(compiledQuery(fts.all).sql).toContain('LIMIT');
});
it('can still find a short non-ASCII title', async () => {
// SQLite's LOWER() is ASCII-only, so folding "Он" to "он" never
// happened and the film stayed invisible in the Sources chip.
const scan = createDbMock([]);
await findTitleSources(scan.db, { title: 'Он' });
const scanQuery = compiledQuery(scan.all);
// GLOB character classes are NOT ASCII-only — they compare UTF-8
// code points — so the case is folded in JavaScript and handed
// over one class per character. Without this a title stored in
// any case other than the two below is simply never found.
expect(scanQuery.params).toContain('*[Оо][нН]*');
expect(scanQuery.sql).toContain('instr');
// Both the folded and the as-typed form, since the class is built
// from the raw token and cannot cover a diacritic difference.
expect(scanQuery.params).toContain('он');
expect(scanQuery.params).toContain('Он');
});
it('does not treat a diacritic-folded token as ASCII', async () => {
// "Ça" normalizes to "ca", which LOOKS like plain ASCII — but the
// stored title still reads "Ça" and SQLite folds neither the
// cedilla nor the case, so the GLOB branch filtered it out before
// the confirmation could run.
const scan = createDbMock([]);
await findTitleSources(scan.db, { title: 'Ça' });
const scanQuery = compiledQuery(scan.all);
expect(scanQuery.sql).toContain('instr');
expect(scanQuery.params).toContain('Ça');
});
it('pairs raw tokens by meaning, not by position', async () => {
// Normalization drops whole words, so "FR: Ça" becomes just "ca".
// Pairing positionally would hand "ca" the raw token "FR:" and
// send it down the ASCII branch, where it cannot match "Ça".
const scan = createDbMock([]);
await findTitleSources(scan.db, { title: 'FR: Ça' });
const scanQuery = compiledQuery(scan.all);
expect(scanQuery.sql).toContain('instr');
expect(scanQuery.params).toContain('Ça');
});
it('keeps the word boundary for ASCII tokens', async () => {
// The looser substring test must not leak into the ASCII path,
// where it would let "it" match "Titanic".
const scan = createDbMock([]);
await findTitleSources(scan.db, { title: 'It' });
const scanQuery = compiledQuery(scan.all);
expect(scanQuery.sql).toContain('GLOB ?');
expect(scanQuery.sql).not.toContain('instr');
});
it('requires every token of an all-short multiword title', async () => {
// "I Am" reaches the scan too — no token survives the trigram
// minimum. Matching on the first token alone returns every catalog
// row containing the word "i", which the TypeScript pass then
// throws away: a full scan of a large catalog on the single DB
// worker, just to open a detail page.
const scan = createDbMock([]);
await findTitleSources(scan.db, { title: 'I Am' });
const scanQuery = compiledQuery(scan.all);
expect(scanQuery.params).toContainEqual(
expect.stringMatching(
/^\*\[\^a-z0-9\]\[[^\]]*i[^\]]*\]\[\^a-z0-9\]\*$/
)
);
expect(scanQuery.params).toContainEqual(
expect.stringMatching(
/^\*\[\^a-z0-9\]\[[^\]]*a[^\]]*\]\[[^\]]*m[^\]]*\]\[\^a-z0-9\]\*$/
)
);
expect(scanQuery.sql.match(/GLOB \?/g)).toHaveLength(2);
});
it('does not offer a scan hit whose title merely contains the query', async () => {
// The scan is deliberately loose; the two-tier normalized check
// afterwards is what keeps "Up" from matching "Upgrade".
const { db } = createDbMock([
{ ...duneRow, title: 'Upgrade', xtream_id: 78 },
]);
await expect(
findTitleSources(db, { title: 'Up' })
).resolves.toEqual([]);
});
it('returns nothing instead of throwing when the FTS query fails', async () => {
const { db, all } = createFailingDbMock();
await expect(
findTitleSources(db, { title: 'Weird "Title"' })
).resolves.toEqual([]);
expect(all).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,364 @@
import { sql, type SQL } from 'drizzle-orm';
import {
normalizeTitleKeys,
titleYearsCompatible,
type VodSourceCandidateRow,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
caseInsensitiveGlobBody,
caseInsensitiveGlobPattern,
} from './title-token-glob';
/**
* VOD multi-source discovery: find the SAME movie in the user's other
* playlists.
*
* Deliberately not built on `buildTitleMatchIndex()` — that helper keeps one
* match per title key because the Similar rail only needs "does this exist
* somewhere". Here every copy in every playlist is a distinct, selectable
* source, so all of them are returned.
*
* Scope is Xtream-only: Stalker items never reach the `content` table (they
* would need a live authenticated portal search), and M3U playlists are stored
* as a JSON blob whose search path forces `content_type: 'live'`.
*/
const CANDIDATE_LIMIT = 60;
/** A row as it comes back from SQLite, before match confirmation. */
interface TitleSourceRow {
content_id: number;
title: string;
xtream_id: number;
poster_url: string | null;
category_xtream_id: number;
playlist_id: string;
playlist_name: string;
}
export interface FindTitleSourcesRequest {
title: string;
/** Known release year of the movie being viewed, when available. */
year?: number | null;
/** The playlist the user is already on — never returned as an alternative. */
excludePlaylistId?: string | null;
/**
* One stream id inside that playlist to keep anyway.
*
* A pin can point at another copy of the film in the playlist the user is
* currently on. Excluding the playlist wholesale would drop the pinned row
* from the list, and the explicit preference would be silently ignored.
*/
keepContentId?: number | null;
}
/**
* A year written in brackets — "Dune (1984)", the commonest catalog form.
*
* `normalizeTitleKeys` strips bracketed segments wholesale, because they
* usually hold quality and language tags. A year written that way therefore
* survives in neither key, and both Dunes normalize to exactly "dune" — which
* the exact tier accepts without ever consulting the year, so failover could
* switch the user to the other film entirely.
*/
function bracketedYear(title: string): number | null {
const match = title.match(/[([{]\s*(19\d{2}|20\d{2})\s*[)\]}]/);
return match ? Number(match[1]) : null;
}
function buildFtsMatchQuery(normalizedTitle: string): string {
const tokens = normalizedTitle
.split(' ')
// The trigram tokenizer cannot match tokens shorter than 3 chars
.filter((token) => token.length >= 3)
.map((token) => `"${token.replace(/"/g, '""')}"`);
return tokens.join(' AND ');
}
/**
* The playlist the user is already on, removed IN SQL.
*
* Filtering it out afterwards is not enough: a playlist that lists the film in
* a dozen categories would spend the whole row budget on rows that are then
* all discarded, and the alternatives it was supposed to make room for would
* never be read at all.
*/
function excludePlaylistClause(
excludePlaylistId: string | null | undefined,
keepContentId: number | null | undefined
): SQL {
if (!excludePlaylistId) {
return sql``;
}
return typeof keepContentId === 'number'
? sql`AND (cat.playlist_id <> ${excludePlaylistId} OR c.xtream_id = ${keepContentId})`
: sql`AND cat.playlist_id <> ${excludePlaylistId}`;
}
/**
* The trigram tokenizer cannot index tokens shorter than three characters, so
* a title like "Up", "It" or "Us" produces an empty MATCH expression. Those are
* real movies, and silently returning nothing for them means the chip never
* appears. This fallback scans instead — slower, and only reachable for the
* handful of titles FTS structurally cannot serve.
*
* The predicate is a WORD-boundary match, not a substring one: `LIKE '%it%'`
* matches "Titanic" and "The Italian Job", so noise could crowd out the real
* "It" entirely. Padding both sides lets one GLOB pattern cover the start,
* middle and end of the title at once.
*
* And it takes NO row limit, unlike the FTS path. FTS ranks by relevance, so
* a window keeps the best rows; a scan can only order by title, so a window
* silently decides which valid sources the user is allowed to see. It is also
* a false economy — the GLOB cannot use an index, so SQLite reads every row
* either way and a `LIMIT` saves only transfer. What bounds this instead is
* the predicate: reaching here means the movie's whole title is one or two
* characters, and only films carrying that exact word come back. Rows arrive
* shortest-title-first because a match is the title plus decoration
* ("IT (2017) 1080p").
*
* Still a necessary-not-sufficient filter — `normalizeTitleKeys` below is what
* confirms a match.
*/
/**
* One token's presence test.
*
* SQLite's `LOWER()` is ASCII-only — `LOWER('Он')` is `'Он'` — so a Cyrillic or
* Greek token cannot be folded by SQL, and matching "он" against a stored "Он"
* simply failed: the film stayed invisible in the Sources chip.
*
* GLOB character classes are NOT ASCII-only, though; they compare UTF-8 code
* points. So the non-ASCII branch folds the case in JavaScript, where Unicode
* case mapping is real, and hands SQLite one `[lowerUpper]` class per
* character. The two substring tests stay alongside it, because the class is
* built from the RAW token and cannot match a title whose diacritics differ
* ("Ça" vs a stored "Ca"), which the folded-token test does cover.
*
* ASCII tokens keep the word-boundary GLOB, which is what stops "it" matching
* "Titanic". The non-ASCII branch has no word boundary — `[^a-z0-9]` would
* treat any Cyrillic letter as a separator — so it is looser, and the
* normalized confirmation afterwards is what makes that safe: a looser filter
* here costs transfer, never a wrong match.
*/
function tokenPredicate(token: string, rawToken: string): SQL {
// Decided from the RAW token, not the normalized one. Normalization folds
// diacritics, so "Ça" arrives here as "ca" — which looks like plain ASCII
// while the stored title still reads "Ça", and SQLite folds neither the
// cedilla nor the case. Branching on the folded form sent that title down
// the GLOB path and filtered it out before the confirmation ever ran.
//
// Normalized ASCII tokens hold letters and digits only, so nothing here
// is a GLOB metacharacter.
if (/^[\x20-\x7e]*$/.test(rawToken) && /^[a-z0-9]+$/.test(token)) {
// Each letter also carries its accented forms. The token has already
// had its own diacritics folded, so without this an ASCII "ca" cannot
// find a stored "Ça" while "Ça" finds "Ca" through the branch below —
// making discovery depend on which playlist the user happens to have
// open. The word boundary is kept: it is what stops "it" matching
// "Titanic".
const anyForm =
caseInsensitiveGlobBody(token, { foldDiacritics: true }) ?? token;
return sql`' ' || LOWER(c.title) || ' ' GLOB ${`*[^a-z0-9]${anyForm}[^a-z0-9]*`}`;
}
const substring = sql`(instr(LOWER(c.title), ${token}) > 0 OR instr(c.title, ${rawToken}) > 0)`;
const anyCase = caseInsensitiveGlobPattern(rawToken);
// `null` means the token holds a GLOB metacharacter or a case mapping that
// changes length, and the substring tests alone are the honest answer.
return anyCase ? sql`(c.title GLOB ${anyCase} OR ${substring})` : substring;
}
function scanCandidateQuery(
base: string,
rawTitle: string,
excludePlaylist: SQL
) {
// EVERY token has to appear, not just the first. This path is reached
// whenever no token survives FTS's minimum length, which includes
// multiword titles like "I Am" — matching on "i" alone would return most
// of the catalog and hand it all to the TypeScript pass to throw away.
const tokens = base.split(' ').filter(Boolean);
// Paired by NORMALIZED form, not by position: normalization drops whole
// words ("FR: Ça" -> "ca"), so a positional pairing hands "ca" the raw
// token "FR:" and picks the wrong branch for it.
const rawByBase = new Map<string, string>();
for (const raw of rawTitle.split(/\s+/).filter(Boolean)) {
const normalized = normalizeTitleKeys(raw).base;
if (normalized && !rawByBase.has(normalized)) {
rawByBase.set(normalized, raw);
}
}
const wordMatches = sql.join(
tokens.map((token) =>
tokenPredicate(token, rawByBase.get(token) ?? token)
),
sql` AND `
);
return sql`
SELECT
c.id AS content_id,
c.title AS title,
c.xtream_id AS xtream_id,
c.poster_url AS poster_url,
cat.xtream_id AS category_xtream_id,
cat.playlist_id AS playlist_id,
p.name AS playlist_name
FROM content AS c
INNER JOIN categories AS cat ON c.category_id = cat.id
INNER JOIN playlists AS p ON cat.playlist_id = p.id
WHERE c.type = 'movie'
AND cat.hidden = 0
AND p.type = 'xtream'
AND ${wordMatches}
${excludePlaylist}
ORDER BY LENGTH(c.title), c.title
`;
}
/**
* The relevance-ranked path, and the only one still taking a window.
*
* One playlist can list a film in dozens of categories, and those rows are
* identically ranked — enough of them would fill the window before another
* playlist was ever read. The TypeScript pass collapses them afterwards,
* which cannot recover what the query never returned, so the collapse has to
* happen first: one row per `(playlist_id, xtream_id)`, then the limit.
*/
function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) {
return sql`
SELECT
c.id AS content_id,
c.title AS title,
c.xtream_id AS xtream_id,
c.poster_url AS poster_url,
cat.xtream_id AS category_xtream_id,
cat.playlist_id AS playlist_id,
p.name AS playlist_name
FROM content_title_fts
INNER JOIN content AS c ON c.id = content_title_fts.rowid
INNER JOIN categories AS cat ON c.category_id = cat.id
INNER JOIN playlists AS p ON cat.playlist_id = p.id
WHERE content_title_fts MATCH ${matchQuery}
AND c.type = 'movie'
AND cat.hidden = 0
AND p.type = 'xtream'
${excludePlaylist}
GROUP BY cat.playlist_id, c.xtream_id
ORDER BY rank, c.title
LIMIT ${CANDIDATE_LIMIT}
`;
}
export async function findTitleSources(
db: AppDatabase,
request: FindTitleSourcesRequest
): Promise<VodSourceCandidateRow[]> {
const rawTitle = request?.title;
if (typeof rawTitle !== 'string' || rawTitle.trim() === '') {
return [];
}
const wanted = normalizeTitleKeys(rawTitle);
if (!wanted.base) {
return [];
}
// The year the caller knows, falling back to a release tag on the title
// in either of the two forms providers write it.
const wantedYear =
request.year ?? wanted.trailingYear ?? bracketedYear(rawTitle);
// Search on the year-stripped form so a portal that tags the year and one
// that does not still find each other; the year gate below re-tightens it.
const matchQuery = buildFtsMatchQuery(wanted.base);
const excludePlaylist = excludePlaylistClause(
request.excludePlaylistId,
request.keepContentId
);
let rows: TitleSourceRow[];
try {
rows = matchQuery
? ((await db.all(
ftsCandidateQuery(matchQuery, excludePlaylist)
)) as TitleSourceRow[])
: ((await db.all(
scanCandidateQuery(wanted.base, rawTitle, excludePlaylist)
)) as TitleSourceRow[]);
} catch {
// Malformed FTS query for exotic titles — no sources, not a crash
return [];
}
const matches: VodSourceCandidateRow[] = [];
// One playlist can list the same film in several categories; the user
// thinks of that as one source.
const seen = new Set<string>();
for (const row of rows) {
// SQL already excluded these; this keeps the guarantee a property of
// the function rather than of one WHERE clause — minus the one copy
// the caller asked to keep.
if (
request.excludePlaylistId &&
row.playlist_id === request.excludePlaylistId &&
row.xtream_id !== request.keepContentId
) {
continue;
}
const rowKeys = normalizeTitleKeys(row.title);
const rowBracketedYear = bracketedYear(row.title);
const rowYear = rowKeys.trailingYear ?? rowBracketedYear;
// Two films share a title far too often for this to be optional, and
// it applies to BOTH tiers: "Dune (1984)" normalizes to exactly the
// same string as "Dune", so an exact match says nothing about which
// film it is. When each side states a year and they disagree, it is
// not the same movie. An unknown year still never blocks.
const yearsAgree = titleYearsCompatible(rowYear, wantedYear);
// The exact tier reads only the BRACKETED year, though. Reaching it
// means both titles are the same string, so any trailing four digits
// belong to both — and comparing a number that is part of the NAME
// against a release year from metadata rejects the very copy it was
// meant to confirm: "Blade Runner 2049" against a stated 2017 vanishes
// the moment enrichment lands. Brackets are never part of a name, so
// that form still decides.
const exactYearsAgree = titleYearsCompatible(
rowBracketedYear,
wantedYear
);
const exactMatch =
rowKeys.exact !== '' &&
rowKeys.exact === wanted.exact &&
exactYearsAgree;
const baseMatch =
!exactMatch && rowKeys.base === wanted.base && yearsAgree;
if (!exactMatch && !baseMatch) {
continue;
}
const dedupeKey = `${row.playlist_id}:${row.xtream_id}`;
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
matches.push({
playlistId: row.playlist_id,
playlistName: row.playlist_name,
categoryId: row.category_xtream_id,
xtreamId: row.xtream_id,
title: row.title,
posterUrl: row.poster_url,
matchConfidence: exactMatch ? 'exact' : 'fuzzy',
year: rowYear,
});
}
return matches;
}
@@ -0,0 +1,42 @@
import type { SQL } from 'drizzle-orm';
import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
import type { AppDatabase } from '../database.types';
/**
* Shared scaffolding for the title-source suites, so the query-shape tests and
* the match-confirmation tests can live in separate files without each
* carrying its own copy.
*/
export function createDbMock(rows: unknown[] = []) {
const all = jest.fn().mockResolvedValue(rows);
return { db: { all } as unknown as AppDatabase, all };
}
/**
* The statement as SQLite will actually see it.
*
* `better-sqlite3` is built against the Electron ABI and cannot be loaded by
* Jest, so the row-shaping tests below run against a mock that returns rows
* whatever the query says. What rows the DATABASE is asked for is therefore
* only observable here — and it is exactly what the window-crowding fixes
* changed.
*/
export function compiledQuery(all: jest.Mock, call = 0) {
return new SQLiteSyncDialect().sqlToQuery(all.mock.calls[call][0] as SQL);
}
export function createFailingDbMock() {
const all = jest.fn().mockRejectedValue(new Error('fts syntax'));
return { db: { all } as unknown as AppDatabase, all };
}
export const duneRow = {
content_id: 1,
title: 'Dune',
xtream_id: 501,
poster_url: 'https://cdn.example.com/dune.jpg',
category_xtream_id: 12,
playlist_id: 'playlist-1',
playlist_name: 'Portal One',
};
@@ -0,0 +1,169 @@
import { execFileSync } from 'node:child_process';
import { caseInsensitiveGlobPattern } from './title-token-glob';
/**
* Case folding for the tokens SQLite cannot fold.
*
* The patterns are only worth anything if SQLite agrees with them, so the
* behavioural cases below are asserted against a real `sqlite3` rather than
* against a restatement of the builder's own logic.
*/
/** `true` when the running SQLite says `value GLOB pattern`. */
function sqliteGlob(value: string, pattern: string): boolean {
const out = execFileSync(
'sqlite3',
[':memory:', `SELECT ${quote(value)} GLOB ${quote(pattern)};`],
{ encoding: 'utf8' }
);
return out.trim() === '1';
}
function quote(literal: string): string {
return `'${literal.replace(/'/g, "''")}'`;
}
/**
* The suite asserts against the sqlite3 CLI, which is present on macOS and on
* the CI images but is not a declared dependency of this workspace.
*/
function hasSqlite(): boolean {
try {
execFileSync('sqlite3', ['-version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
const describeWithSqlite = hasSqlite() ? describe : describe.skip;
describe('caseInsensitiveGlobPattern', () => {
it('builds a case class for each cased character', () => {
expect(caseInsensitiveGlobPattern('Он')).toBe('*[Оо][нН]*');
});
it('leaves uncased characters alone', () => {
expect(caseInsensitiveGlobPattern('7')).toBe('*7*');
});
it('covers a letter with two lowercase spellings', () => {
// Greek Σ lowercases to σ, but a word-final sigma is written ς and is
// just as much a lowercase of it. Going back down from the uppercase
// form reaches the spelling the character in hand does not name.
expect(caseInsensitiveGlobPattern('ς')).toBe('*[ςΣσ]*');
});
it('reaches the second spelling in one direction only', () => {
// σ → Σ → σ never arrives at ς, so a request spelled with a medial
// sigma does not match a stored final one. Left as is: ς is only ever
// correct at the end of a word, which is exactly where the request's
// own last character sits, so the pair that occurs in real titles is
// the one above. Closing the other direction needs a fold table.
expect(caseInsensitiveGlobPattern('σ')).toBe('*[σΣ]*');
});
it('refuses a token holding a GLOB metacharacter', () => {
// SQLite GLOB has no escape character, so an unescaped `*` here would
// silently become a wildcard and match every row in the table.
expect(caseInsensitiveGlobPattern('о*')).toBeNull();
expect(caseInsensitiveGlobPattern('[о')).toBeNull();
expect(caseInsensitiveGlobPattern('о-н')).toBeNull();
});
it('refuses a case mapping that changes length', () => {
// 'ß'.toUpperCase() is 'SS' — there is no single-character class that
// means the same thing, and guessing one would be a wrong pattern
// rather than an absent one.
expect(caseInsensitiveGlobPattern('ß')).toBeNull();
});
it('refuses an empty token', () => {
expect(caseInsensitiveGlobPattern('')).toBeNull();
});
});
describeWithSqlite('caseInsensitiveGlobPattern against SQLite', () => {
it('matches a Cyrillic title in any case', () => {
const pattern = caseInsensitiveGlobPattern('Он') as string;
// The bug: LOWER() is ASCII-only, so a request for "Он" never found a
// stored "ОН" and the film was missing from the Sources chip.
expect(sqliteGlob('Он', pattern)).toBe(true);
expect(sqliteGlob('ОН', pattern)).toBe(true);
expect(sqliteGlob('он (2017)', pattern)).toBe(true);
});
it('is exactly what SQLite cannot do on its own', () => {
// Guards the premise the whole helper rests on: if a future SQLite
// folded Cyrillic in LOWER(), this indirection would be dead weight.
const out = execFileSync(
'sqlite3',
[':memory:', `SELECT LOWER('Он') = 'он';`],
{ encoding: 'utf8' }
);
expect(out.trim()).toBe('0');
});
it('still refuses a title that does not hold the token', () => {
const pattern = caseInsensitiveGlobPattern('Он') as string;
expect(sqliteGlob('Дюна', pattern)).toBe(false);
});
it('matches a Greek word written with either sigma', () => {
const pattern = caseInsensitiveGlobPattern('ος') as string;
expect(sqliteGlob('ος', pattern)).toBe(true);
expect(sqliteGlob('ΟΣ', pattern)).toBe(true);
// The spelling a class built from the request alone would miss.
expect(sqliteGlob('οσ', pattern)).toBe(true);
});
it('finds an accented title from its folded ASCII token', () => {
// The token has already had its own diacritics folded, so "ca" is what
// a search for "Ça" arrives as. Without the accented forms this
// direction fails while the reverse works, and whether two playlists
// can see each other then depends on which one is open.
const pattern = caseInsensitiveGlobPattern('ca', {
foldDiacritics: true,
}) as string;
expect(sqliteGlob('Ça', pattern)).toBe(true);
expect(sqliteGlob('ÇA', pattern)).toBe(true);
expect(sqliteGlob('ca', pattern)).toBe(true);
expect(sqliteGlob('Ca', pattern)).toBe(true);
});
it('reaches beyond Latin Extended-B for the fold', () => {
// "Bố" (U+1ED1) is Vietnamese and normalizes to "bo" like any other
// accented o. A range that stops at Latin Extended-B looks tidy and
// leaves a whole language filtered out before confirmation.
const pattern = caseInsensitiveGlobPattern('bo', {
foldDiacritics: true,
}) as string;
expect(sqliteGlob('Bố', pattern)).toBe(true);
expect(sqliteGlob('bồ', pattern)).toBe(true);
expect(sqliteGlob('Bo', pattern)).toBe(true);
});
it('does not fold diacritics unless asked', () => {
const pattern = caseInsensitiveGlobPattern('ca') as string;
// The non-ASCII branch pairs the raw token with the folded one and
// relies on that, so widening this by default would be a silent
// behaviour change for every caller.
expect(sqliteGlob('Ça', pattern)).toBe(false);
expect(sqliteGlob('Ca', pattern)).toBe(true);
});
it('matches Greek and accented Latin in any case', () => {
expect(
sqliteGlob('ΟΙ', caseInsensitiveGlobPattern('οι') as string)
).toBe(true);
expect(
sqliteGlob('ÇA', caseInsensitiveGlobPattern('Ça') as string)
).toBe(true);
});
});
@@ -0,0 +1,151 @@
/**
* Case-insensitive GLOB patterns for tokens SQLite cannot fold itself.
*
* `LOWER()` in stock SQLite is ASCII-only — `LOWER('Он')` is `'Он'` — so a
* Cyrillic or Greek token can never be folded in SQL. GLOB character classes,
* however, are NOT ASCII-only: `patternCompare` reads them as UTF-8 code
* points, so `'Он' GLOB '*[Оо][Нн]*'` is true. Folding the case in JavaScript,
* where Unicode case mapping is real, and handing SQLite one class per
* character gets the match SQL cannot compute on its own.
*/
/**
* GLOB's own metacharacters. SQLite has no escape character in GLOB patterns
* (only the `[*]` class form), so a token containing one of these is left to
* the caller's substring fallback rather than escaped by hand.
*/
const GLOB_METACHARACTERS = /[*?[\]^-]/;
/**
* Every accented Latin letter, bucketed by the plain letter it folds to.
*
* Derived rather than tabulated: decomposing a character and dropping its
* combining marks IS the definition of the base letter, so this cannot drift
* out of step with `normalizeTitleKeys`, which folds the same way.
*/
const ACCENTED_BY_BASE = ((): Map<string, string[]> => {
const byBase = new Map<string, string[]>();
// Latin-1 Supplement through Latin Extended-B, PLUS Latin Extended
// Additional — which is where Vietnamese lives (ố is U+1ED1). Stopping at
// Latin Extended-B looks tidy and silently leaves a whole language out,
// while the normalizer folds it perfectly well. The filter below decides
// what actually belongs, so a range only has to be wide enough.
const ranges: ReadonlyArray<[number, number]> = [
[0x00c0, 0x024f],
[0x1e00, 0x1eff],
];
for (const [first, last] of ranges) {
for (let codePoint = first; codePoint <= last; codePoint += 1) {
addAccentedForm(byBase, String.fromCodePoint(codePoint));
}
}
return byBase;
})();
/** Files one character under the plain ASCII letter it decomposes to. */
function addAccentedForm(
byBase: Map<string, string[]>,
character: string
): void {
const base = character.normalize('NFD').replace(/\p{M}/gu, '');
if (base.length !== 1 || !/[a-z]/i.test(base)) {
return;
}
const key = base.toLowerCase();
const forms = byBase.get(key) ?? [];
forms.push(character);
byBase.set(key, forms);
}
/**
* A `*…*` containment pattern matching `token` in any case, or `null` when the
* token cannot be expressed safely.
*
* `null` is returned rather than a best-effort pattern for two reasons: a
* metacharacter would silently turn into a wildcard, and a character whose case
* mapping changes length (`ß` uppercases to `SS`) has no single-character class
* that means the same thing. Both are rare enough that the caller's plain
* substring test is a better answer than a subtly wrong pattern.
*/
export function caseInsensitiveGlobPattern(
token: string,
options: { foldDiacritics?: boolean } = {}
): string | null {
const body = caseInsensitiveGlobBody(token, options);
return body === null ? null : `*${body}*`;
}
/**
* The class sequence alone, for callers that supply their own surroundings —
* the scan's word-boundary form needs `[^a-z0-9]` on each side rather than a
* wildcard.
*/
export function caseInsensitiveGlobBody(
token: string,
options: { foldDiacritics?: boolean } = {}
): string | null {
if (token === '' || GLOB_METACHARACTERS.test(token)) {
return null;
}
let pattern = '';
for (const character of token) {
const upper = character.toUpperCase();
// Going back down from the uppercase form catches letters with more
// than one lowercase spelling: Greek Σ lowercases to σ, but ς is an
// equally valid lowercase of it, and a class built only from the
// character in hand would know just one of the two.
const forms = new Set([
character,
character.toLowerCase(),
upper,
upper.toLowerCase(),
]);
// These are the forms the pattern MUST carry, so one it cannot express
// means there is no honest pattern to build.
if (
[...forms].some(
(form) =>
[...form].length !== 1 || GLOB_METACHARACTERS.test(form)
)
) {
return null;
}
// A normalized token has already had its diacritics folded away, so
// "ca" has to be able to find a stored "Ça" — otherwise discovery runs
// one way only, depending on which playlist happens to be open.
//
// These are additions, never requirements: an accented form whose own
// case mapping is multi-character (ǰ uppercases to two code points)
// is simply left out, rather than costing the whole token its pattern.
if (options.foldDiacritics) {
const accents = ACCENTED_BY_BASE.get(character.toLowerCase()) ?? [];
for (const accented of accents) {
for (const form of [
accented,
accented.toUpperCase(),
accented.toLowerCase(),
]) {
if (
[...form].length === 1 &&
!GLOB_METACHARACTERS.test(form)
) {
forms.add(form);
}
}
}
}
if (forms.size === 1) {
pattern += character;
continue;
}
pattern += `[${[...forms].join('')}]`;
}
return pattern;
}
@@ -0,0 +1,384 @@
import {
createDbMock,
mockDrizzle,
mockDrizzleOrmModule,
resetDrizzleMocks,
} from './operations.test-helpers';
jest.mock('drizzle-orm', () => mockDrizzleOrmModule());
import * as schema from '@iptvnator/shared/database/schema';
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
import {
clearVodSourcePin,
getVodSourcePin,
setVodSourcePin,
clearVodSourcePinsForPlaylist,
replaceVodSourcePinsForPlaylist,
} from './vod-source-pin.operations';
const tmdbRow = {
id: 1,
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream' as const,
updatedAt: '2026-07-20T10:00:00.000Z',
};
const titleRow = {
id: 2,
matchKey: 'title:the matrix:1999',
playlistId: 'playlist-2',
contentId: 902,
portalType: 'xtream' as const,
updatedAt: '2026-07-01T10:00:00.000Z',
};
/**
* Pins upsert, and run inside a transaction — so the chain has to end in a
* synchronous `.run()` rather than resolving, exactly as the driver requires.
*/
function createUpsertDbMock() {
const mock = createDbMock();
const insertRun = jest.fn();
const onConflictDoUpdate = jest.fn().mockReturnValue({ run: insertRun });
mock.insertValues.mockReturnValue({ onConflictDoUpdate });
return { ...mock, onConflictDoUpdate, insertRun };
}
describe('vod-source-pin.operations', () => {
beforeEach(() => {
resetDrizzleMocks();
});
describe('getVodSourcePin', () => {
it('returns null without querying for an empty key list', async () => {
const { db, select } = createDbMock();
await expect(getVodSourcePin(db, [])).resolves.toBeNull();
expect(select).not.toHaveBeenCalled();
});
it('returns null when no pin row exists', async () => {
const { db } = createDbMock([[]]);
await expect(getVodSourcePin(db, ['tmdb:603'])).resolves.toBeNull();
});
it('honours caller key order, not the order SQLite returned', async () => {
// SQLite hands back the older title-keyed row first; the caller
// passes the tmdb key first because it is the more trusted one.
// Reading in row order would let a stale title pin shadow it.
const { db } = createDbMock([[titleRow, tmdbRow]]);
await expect(
getVodSourcePin(db, ['tmdb:603', 'title:the matrix:1999'])
).resolves.toEqual({
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
updatedAt: '2026-07-20T10:00:00.000Z',
});
});
it('falls back to the next key when the first has no row', async () => {
const { db } = createDbMock([[titleRow]]);
const pin = await getVodSourcePin(db, [
'tmdb:603',
'title:the matrix:1999',
]);
expect(pin?.matchKey).toBe('title:the matrix:1999');
});
it('reports a missing timestamp as undefined', async () => {
const { db } = createDbMock([[{ ...tmdbRow, updatedAt: null }]]);
const pin = await getVodSourcePin(db, ['tmdb:603']);
expect(pin?.updatedAt).toBeUndefined();
});
it('drops junk keys and caps the lookup at eight keys', async () => {
const { db } = createDbMock([[]]);
const keys = [
'',
null as unknown as string,
undefined as unknown as string,
42 as unknown as string,
'k1',
'k2',
'k3',
'k4',
'k5',
'k6',
'k7',
'k8',
'k9',
];
await getVodSourcePin(db, keys);
expect(mockDrizzle.inArray).toHaveBeenCalledWith(
schema.vodSourcePins.matchKey,
['k1', 'k2', 'k3', 'k4', 'k5', 'k6', 'k7', 'k8']
);
});
});
describe('setVodSourcePin', () => {
const pin: VodSourcePin = {
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
};
it('upserts on the match key', async () => {
const { db, insert, insertValues, onConflictDoUpdate } =
createUpsertDbMock();
await expect(setVodSourcePin(db, pin)).resolves.toEqual({
success: true,
});
expect(insert).toHaveBeenCalledWith(schema.vodSourcePins);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
updatedAt: expect.any(String),
})
);
expect(onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
target: schema.vodSourcePins.matchKey,
set: expect.objectContaining({
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
updatedAt: expect.any(String),
}),
})
);
});
it('retires the aliases in the same transaction as the write', async () => {
const { db, transaction, insertRun, deleteRun, deleteWhere } =
createUpsertDbMock();
await setVodSourcePin(db, pin, [
'title:the matrix:1999',
'title:the matrix:',
]);
// Two calls could half-apply, and neither half-outcome has an
// honest answer: a surviving alias wins the next lookup, while a
// rolled-back-looking failure leaves the canonical row durable.
expect(transaction).toHaveBeenCalledTimes(1);
expect(insertRun).toHaveBeenCalledTimes(1);
expect(deleteRun).toHaveBeenCalledTimes(1);
expect(deleteWhere).toHaveBeenCalled();
});
it('never retires the key it just wrote', async () => {
const { db, deleteRun } = createUpsertDbMock();
await setVodSourcePin(db, pin, ['tmdb:603']);
expect(deleteRun).not.toHaveBeenCalled();
});
it('stores the pin under every alias it was given', async () => {
const { db, insertValues, insertRun, transaction } =
createUpsertDbMock();
await setVodSourcePin(db, pin, [], ['title:the matrix:1999']);
// A movie's identity grows: the film keyed `tmdb:603` today was
// `title:the matrix:1999` before enrichment, and reopening it
// cold asks for the poorer key first. One row would answer
// nothing until enrichment landed — if it ever did.
expect(transaction).toHaveBeenCalledTimes(1);
expect(insertRun).toHaveBeenCalledTimes(2);
expect(
insertValues.mock.calls.map(([row]) => row.matchKey)
).toEqual(['tmdb:603', 'title:the matrix:1999']);
});
it('writes each key once, however often it is passed', async () => {
const { db, insertRun } = createUpsertDbMock();
await setVodSourcePin(db, pin, [], ['tmdb:603', 'tmdb:603']);
expect(insertRun).toHaveBeenCalledTimes(1);
});
it('never retires an alias it just wrote', async () => {
const { db, deleteRun } = createUpsertDbMock();
// The same key can legitimately appear in both lists — it was an
// alias of the PREVIOUS pin and is a write key of this one. Taking
// the retirement literally would delete the row just written.
await setVodSourcePin(
db,
pin,
['title:the matrix:1999'],
['title:the matrix:1999']
);
expect(deleteRun).not.toHaveBeenCalled();
});
it('refuses a pin with no usable key rather than reporting success', async () => {
const { db, insertRun, transaction } = createUpsertDbMock();
await expect(
setVodSourcePin(db, { ...pin, matchKey: '' })
).resolves.toEqual({ success: false });
expect(transaction).not.toHaveBeenCalled();
expect(insertRun).not.toHaveBeenCalled();
});
it('touches nothing else when there is no alias to retire', async () => {
const { db, deleteRun } = createUpsertDbMock();
await setVodSourcePin(db, pin);
expect(deleteRun).not.toHaveBeenCalled();
});
it('writes an ISO timestamp on both the insert and the update', async () => {
const { db, insertValues, onConflictDoUpdate } =
createUpsertDbMock();
await setVodSourcePin(db, pin);
const inserted = insertValues.mock.calls[0][0] as {
updatedAt: string;
};
const updated = onConflictDoUpdate.mock.calls[0][0] as {
set: { updatedAt: string };
};
expect(new Date(inserted.updatedAt).toISOString()).toBe(
inserted.updatedAt
);
expect(updated.set.updatedAt).toBe(inserted.updatedAt);
});
});
describe('clearVodSourcePin', () => {
it('reports failure without touching the database for no keys', async () => {
const { db, deleteFn } = createDbMock();
await expect(clearVodSourcePin(db, [])).resolves.toEqual({
success: false,
});
expect(deleteFn).not.toHaveBeenCalled();
});
it('deletes every alias of the movie in one statement', async () => {
const { db, deleteFn, deleteWhere } = createDbMock();
await expect(
clearVodSourcePin(db, ['tmdb:603', 'title:the matrix:1999'])
).resolves.toEqual({ success: true });
expect(deleteFn).toHaveBeenCalledWith(schema.vodSourcePins);
expect(deleteWhere).toHaveBeenCalledTimes(1);
expect(mockDrizzle.inArray).toHaveBeenCalledWith(
schema.vodSourcePins.matchKey,
['tmdb:603', 'title:the matrix:1999']
);
});
});
describe('replaceVodSourcePinsForPlaylist', () => {
const pin: VodSourcePin = {
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
};
it('clears and rewrites inside ONE transaction', async () => {
const { db, transaction, deleteRun, insertRun } =
createUpsertDbMock();
await expect(
replaceVodSourcePinsForPlaylist(db, 'playlist-1', [pin, pin])
).resolves.toEqual({ success: true });
// Split into a clear and per-pin writes, a write failing partway
// leaves the old pins gone and the archive half-applied — a state
// belonging to neither, which the user cannot undo.
expect(transaction).toHaveBeenCalledTimes(1);
expect(deleteRun).toHaveBeenCalledTimes(1);
expect(insertRun).toHaveBeenCalledTimes(2);
});
it('clears without writing for an empty set', async () => {
const { db, deleteRun, insertRun } = createUpsertDbMock();
// Present-but-empty means the user had none; the clear still runs.
await replaceVodSourcePinsForPlaylist(db, 'playlist-1', []);
expect(deleteRun).toHaveBeenCalledTimes(1);
expect(insertRun).not.toHaveBeenCalled();
});
it('refuses a blank playlist id rather than clearing everything', async () => {
const { db, transaction } = createUpsertDbMock();
await expect(
replaceVodSourcePinsForPlaylist(db, '', [pin])
).resolves.toEqual({ success: false });
expect(transaction).not.toHaveBeenCalled();
});
it('drops rows that name no film', async () => {
const { db, insertRun } = createUpsertDbMock();
await replaceVodSourcePinsForPlaylist(db, 'playlist-1', [
{ ...pin, matchKey: '' },
{ ...pin, contentId: undefined as unknown as number },
pin,
]);
expect(insertRun).toHaveBeenCalledTimes(1);
});
});
describe('clearVodSourcePinsForPlaylist', () => {
it('deletes by playlist, with no key cap to truncate', async () => {
const { db, deleteFn, deleteWhere } = createDbMock();
await expect(
clearVodSourcePinsForPlaylist(db, 'playlist-1')
).resolves.toEqual({ success: true });
// The keyed clear caps its IN list at MAX_KEYS_PER_LOOKUP, so a
// playlist with more pinned movies than that kept the surplus —
// and still reported success.
expect(deleteFn).toHaveBeenCalledWith(schema.vodSourcePins);
expect(deleteWhere).toHaveBeenCalled();
});
it('refuses a blank playlist id rather than clearing everything', async () => {
const { db, deleteFn } = createDbMock();
await expect(
clearVodSourcePinsForPlaylist(db, '')
).resolves.toEqual({ success: false });
expect(deleteFn).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,272 @@
import { eq, inArray } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
/**
* VOD multi-source pins — the user's per-movie choice of preferred playlist.
*
* Lookups take SEVERAL keys rather than one: a movie pinned before TMDB
* enrichment landed is stored under its title key, and the same movie prefers
* a `tmdb:` key afterwards. Reading both means the id arriving later does not
* orphan an existing pin.
*/
/** Guards against an unbounded IN list if a caller ever passes junk. */
const MAX_KEYS_PER_LOOKUP = 8;
export async function getVodSourcePin(
db: AppDatabase,
matchKeys: string[]
): Promise<VodSourcePin | null> {
const keys = (matchKeys ?? [])
.filter((key) => typeof key === 'string' && key !== '')
.slice(0, MAX_KEYS_PER_LOOKUP);
if (keys.length === 0) {
return null;
}
const rows = await db
.select()
.from(schema.vodSourcePins)
.where(inArray(schema.vodSourcePins.matchKey, keys));
if (rows.length === 0) {
return null;
}
// Callers pass keys most-trusted first (tmdb before title), so honour that
// order rather than whatever SQLite returned.
for (const key of keys) {
const row = rows.find((candidate) => candidate.matchKey === key);
if (row) {
return toPin(row);
}
}
return null;
}
/**
* Every pin pointing AT this playlist.
*
* A pin belongs to a movie but names one playlist, so the playlist it points
* at is the one that owns it for backup purposes — exporting it anywhere else
* would restore a preference for a portal that is not in the archive.
*/
export async function listVodSourcePinsForPlaylist(
db: AppDatabase,
playlistId: string
): Promise<VodSourcePin[]> {
if (typeof playlistId !== 'string' || playlistId === '') {
return [];
}
const rows = await db
.select()
.from(schema.vodSourcePins)
.where(eq(schema.vodSourcePins.playlistId, playlistId));
return rows.map(toPin);
}
/**
* Store the pin under every key that names this film, and retire its old
* aliases, as ONE unit.
*
* `aliasKeys` exist because a movie's identity GROWS: the same film is
* `title:dune:2021` before TMDB enrichment lands and `tmdb:438631` after, and
* a decision recorded only under the richer key is invisible to the next
* reopen that has not been enriched yet. Callers pass every key that names
* exactly this one film — never the ambiguous yearless form, which every
* remake shares.
*
* Split across two calls there is no honest answer to a half-failure: report
* success and a surviving alias silently wins the next lookup; report failure
* and the canonical row is already durable, so a later reopen selects the
* source the UI said was rejected. A transaction removes the question.
*/
export async function setVodSourcePin(
db: AppDatabase,
pin: VodSourcePin,
retireKeys: string[] = [],
aliasKeys: string[] = []
): Promise<{ success: boolean }> {
const updatedAt = new Date().toISOString();
const write = [
...new Set(
[pin.matchKey, ...(aliasKeys ?? [])].filter(
(key) => typeof key === 'string' && key !== ''
)
),
].slice(0, MAX_KEYS_PER_LOOKUP);
// Nothing identifying to store it under. Reporting success here would
// promise a preference that was never written.
if (write.length === 0) {
return { success: false };
}
const retire = (retireKeys ?? [])
.filter(
(key) =>
typeof key === 'string' && key !== '' && !write.includes(key)
)
.slice(0, MAX_KEYS_PER_LOOKUP);
const inserts = write.map((matchKey) =>
db
.insert(schema.vodSourcePins)
.values({
matchKey,
playlistId: pin.playlistId,
contentId: pin.contentId,
portalType: pin.portalType,
updatedAt,
})
.onConflictDoUpdate({
target: schema.vodSourcePins.matchKey,
set: {
playlistId: pin.playlistId,
contentId: pin.contentId,
portalType: pin.portalType,
updatedAt,
},
})
);
await db.transaction(() => {
// `.run()`, not `.execute()`: on better-sqlite3 the latter defers the
// write to a promise that never settles inside this synchronous
// callback, so the statement would be a silent no-op.
for (const insert of inserts) {
insert.run();
}
if (retire.length > 0) {
db.delete(schema.vodSourcePins)
.where(inArray(schema.vodSourcePins.matchKey, retire))
.run();
}
});
return { success: true };
}
export async function clearVodSourcePin(
db: AppDatabase,
matchKeys: string[]
): Promise<{ success: boolean }> {
const keys = (matchKeys ?? [])
.filter((key) => typeof key === 'string' && key !== '')
.slice(0, MAX_KEYS_PER_LOOKUP);
if (keys.length === 0) {
return { success: false };
}
// Clears every alias of the movie, so unpinning is not undone by a stale
// row under the other key form.
await db
.delete(schema.vodSourcePins)
.where(inArray(schema.vodSourcePins.matchKey, keys));
return { success: true };
}
/**
* Drop every pin pointing at one playlist.
*
* NOT expressible through `clearVodSourcePin`: that takes match keys and caps
* them at `MAX_KEYS_PER_LOOKUP` to bound an IN list, so a playlist with more
* pinned movies than the cap would have had the rest silently survive — while
* the call still reported success.
*/
export async function clearVodSourcePinsForPlaylist(
db: AppDatabase,
playlistId: string
): Promise<{ success: boolean }> {
if (typeof playlistId !== 'string' || playlistId === '') {
return { success: false };
}
await db
.delete(schema.vodSourcePins)
.where(eq(schema.vodSourcePins.playlistId, playlistId));
return { success: true };
}
/**
* Make this playlist's pins exactly `pins`, in ONE transaction.
*
* Restore cannot do this as a clear followed by writes. If a write fails
* partway the user's previous pins are already gone and only a prefix of the
* archive has landed — the import reports failure, and the state it leaves
* behind belongs to neither the backup nor what was there before. One
* statement group means the playlist either ends up as the archive describes
* it or exactly as it was.
*/
export async function replaceVodSourcePinsForPlaylist(
db: AppDatabase,
playlistId: string,
pins: VodSourcePin[]
): Promise<{ success: boolean }> {
if (typeof playlistId !== 'string' || playlistId === '') {
return { success: false };
}
const updatedAt = new Date().toISOString();
const rows = (pins ?? [])
.filter(
(pin) =>
typeof pin?.matchKey === 'string' &&
pin.matchKey !== '' &&
Number.isInteger(pin?.contentId)
)
.map((pin) => ({
matchKey: pin.matchKey,
playlistId,
contentId: pin.contentId,
portalType: pin.portalType,
updatedAt: pin.updatedAt ?? updatedAt,
}));
const inserts = rows.map((row) =>
db
.insert(schema.vodSourcePins)
.values(row)
.onConflictDoUpdate({
target: schema.vodSourcePins.matchKey,
set: {
playlistId: row.playlistId,
contentId: row.contentId,
portalType: row.portalType,
updatedAt: row.updatedAt,
},
})
);
await db.transaction(() => {
db.delete(schema.vodSourcePins)
.where(eq(schema.vodSourcePins.playlistId, playlistId))
.run();
for (const insert of inserts) {
insert.run();
}
});
return { success: true };
}
function toPin(row: schema.VodSourcePinRow): VodSourcePin {
return {
matchKey: row.matchKey,
playlistId: row.playlistId,
contentId: row.contentId,
portalType: row.portalType,
updatedAt: row.updatedAt ?? undefined,
};
}
@@ -14,6 +14,7 @@ import './database/playback-position.events';
import './database/playlist.events';
import './database/recently-viewed.events';
import './database/tmdb.events';
import './database/vod-sources.events';
import './database/xtream.events';
export default class DatabaseEvents {
@@ -0,0 +1,48 @@
/**
* VOD multi-source IPC event handlers
* Cross-playlist movie discovery plus the per-movie pinned source.
*/
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
import type { FindTitleSourcesRequest } from '../../database/operations/title-sources.operations';
import { handleWorkerRequest } from './worker-events.utils';
handleWorkerRequest(
'DB_FIND_TITLE_SOURCES',
(request: FindTitleSourcesRequest) => ({ request })
);
handleWorkerRequest('DB_GET_VOD_SOURCE_PIN', (matchKeys: string[]) => ({
matchKeys,
}));
handleWorkerRequest('DB_LIST_VOD_SOURCE_PINS', (playlistId: string) => ({
playlistId,
}));
handleWorkerRequest(
'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST',
(playlistId: string) => ({ playlistId })
);
handleWorkerRequest(
'DB_SET_VOD_SOURCE_PIN',
(
pin: VodSourcePin,
retireKeys: string[] = [],
aliasKeys: string[] = []
) => ({
pin,
retireKeys,
aliasKeys,
})
);
handleWorkerRequest('DB_CLEAR_VOD_SOURCE_PIN', (matchKeys: string[]) => ({
matchKeys,
}));
handleWorkerRequest(
'DB_REPLACE_VOD_SOURCE_PINS',
(playlistId: string, pins: VodSourcePin[] = []) => ({ playlistId, pins })
);
@@ -31,6 +31,18 @@ const tmdbCacheEntry = {
tmdbId: 603,
payload: '{"id":603}',
};
const vodSourceRequest = {
title: 'The Matrix',
year: 1999,
excludePlaylistId: playlistId,
};
const vodSourceMatchKeys = ['tmdb:603', 'title:the matrix:1999'];
const vodSourcePin = {
matchKey: 'tmdb:603',
playlistId,
contentId: 42,
portalType: 'xtream',
};
export const workerIpcContractCases: WorkerIpcContractCase[] = [
{
@@ -338,4 +350,43 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
args: [['The Matrix', 'Inception']],
payload: { titles: ['The Matrix', 'Inception'] },
},
{
operation: 'DB_FIND_TITLE_SOURCES',
args: [vodSourceRequest],
payload: { request: vodSourceRequest },
},
{
operation: 'DB_GET_VOD_SOURCE_PIN',
args: [vodSourceMatchKeys],
payload: { matchKeys: vodSourceMatchKeys },
},
{
operation: 'DB_LIST_VOD_SOURCE_PINS',
args: ['playlist-1'],
payload: { playlistId: 'playlist-1' },
},
{
operation: 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST',
args: ['playlist-1'],
payload: { playlistId: 'playlist-1' },
},
{
operation: 'DB_SET_VOD_SOURCE_PIN',
args: [vodSourcePin, ['title:dune:'], ['title:dune:2021']],
payload: {
pin: vodSourcePin,
retireKeys: ['title:dune:'],
aliasKeys: ['title:dune:2021'],
},
},
{
operation: 'DB_REPLACE_VOD_SOURCE_PINS',
args: ['playlist-1', [vodSourcePin]],
payload: { playlistId: 'playlist-1', pins: [vodSourcePin] },
},
{
operation: 'DB_CLEAR_VOD_SOURCE_PIN',
args: [vodSourceMatchKeys],
payload: { matchKeys: vodSourceMatchKeys },
},
];
@@ -45,6 +45,7 @@ async function importDatabaseEventModules(): Promise<void> {
await import('./playlist.events');
await import('./recently-viewed.events');
await import('./tmdb.events');
await import('./vod-sources.events');
await import('./xtream.events');
}
@@ -0,0 +1,226 @@
const registeredHandlers = new Map<string, (...args: unknown[]) => unknown>();
const axiosMock = Object.assign(jest.fn(), {
isAxiosError: jest.fn(),
});
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(
(channel: string, handler: (...args: unknown[]) => unknown) => {
registeredHandlers.set(channel, handler);
}
),
},
}));
jest.mock('axios', () => ({
__esModule: true,
default: axiosMock,
}));
describe('stream probe', () => {
beforeEach(async () => {
jest.resetModules();
registeredHandlers.clear();
axiosMock.mockReset();
axiosMock.isAxiosError.mockReset();
const { registerStreamProbeHandlers } = await import('./stream-probe');
registerStreamProbeHandlers();
});
it('registers both the generic and the legacy Xtream channel', () => {
expect(registeredHandlers.get('STREAM_PROBE_URL')).toBeDefined();
// The catchup variant probe still calls the old channel.
expect(registeredHandlers.get('XTREAM_PROBE_URL')).toBeDefined();
});
it('defaults to HEAD and reports latency', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
axiosMock.mockImplementation((config: { url?: string }) =>
Promise.resolve({ status: 200, headers: {}, config })
);
const result = (await probeHandler?.(
{},
{ url: 'http://example.com/movie.mkv' }
)) as { status: number; latencyMs?: number };
expect(result.status).toBe(200);
expect(typeof result.latencyMs).toBe('number');
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
const request = axiosMock.mock.calls[0][0] as {
method?: string;
headers?: Record<string, string>;
responseType?: string;
};
expect(request.method).toBe('HEAD');
// No Range header on HEAD — that is only the GET fallback's job.
expect(request.headers?.['Range']).toBeUndefined();
expect(request.responseType).toBeUndefined();
});
it('probes with the playlist headers the stream needs', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
axiosMock.mockImplementation((config: { url?: string }) =>
Promise.resolve({ status: 200, headers: {}, config })
);
await probeHandler?.(
{},
{
url: 'http://example.com/movie.mkv',
userAgent: 'MyPlayer/2.0',
referer: 'http://example.com/',
origin: 'http://example.com',
}
);
// A panel configured to require these answers 401/403 without them,
// and reporting a stream that plays fine as dead is the one thing
// this probe must not do.
const request = axiosMock.mock.calls[0][0] as {
headers?: Record<string, string>;
};
expect(request.headers?.['User-Agent']).toBe('MyPlayer/2.0');
expect(request.headers?.['Referer']).toBe('http://example.com/');
expect(request.headers?.['Origin']).toBe('http://example.com');
});
it('keeps its own client User-Agent when the playlist sets none', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
axiosMock.mockImplementation((config: { url?: string }) =>
Promise.resolve({ status: 200, headers: {}, config })
);
await probeHandler?.(
{},
{ url: 'http://example.com/movie.mkv', userAgent: ' ' }
);
const request = axiosMock.mock.calls[0][0] as {
headers?: Record<string, string>;
};
expect(request.headers?.['User-Agent']).toContain('VLC');
expect(request.headers?.['Referer']).toBeUndefined();
});
it('follows validated redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
const destroyProbeBody = jest.fn();
expect(probeHandler).toBeDefined();
axiosMock
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 302,
headers: { location: '/media.ts' },
config,
})
)
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 206,
headers: {},
data: { destroy: destroyProbeBody },
config,
})
);
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { status: number; url: string };
expect(result.status).toBe(206);
expect(result.url).toBe('http://localhost:3211/media.ts');
expect(axiosMock).toHaveBeenCalledTimes(2);
const firstRequest = axiosMock.mock.calls[0][0] as {
headers?: Record<string, string>;
maxRedirects?: number;
method?: string;
responseType?: string;
};
expect(firstRequest.method).toBe('GET');
expect(firstRequest.headers).toEqual(
expect.objectContaining({ Range: 'bytes=0-4095' })
);
expect(firstRequest.headers?.['User-Agent']).not.toBe(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
);
expect(firstRequest.maxRedirects).toBe(0);
expect(firstRequest.responseType).toBe('stream');
// The ranged GET opens a stream nobody reads — it must be released.
expect(destroyProbeBody).toHaveBeenCalledTimes(1);
});
it('rejects private cross-origin redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
expect(probeHandler).toBeDefined();
axiosMock.mockResolvedValueOnce({
status: 302,
headers: { location: 'http://127.0.0.1/admin' },
config: {
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
},
});
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { error?: string; status: number; url: string };
// A policy rejection carries no latency: it never left the process,
// and callers must be able to tell it apart from a real timing.
expect(result).toEqual({
error: 'URL points to a private or local network address',
status: 0,
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
});
expect(axiosMock).toHaveBeenCalledTimes(1);
});
it('reports a refusal status rather than treating it as unreachable', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
const refusal = Object.assign(new Error('forbidden'), {
response: { status: 403 },
});
axiosMock.mockRejectedValueOnce(refusal);
axiosMock.isAxiosError.mockImplementation(
(error: unknown) => error === refusal
);
const result = (await probeHandler?.(
{},
{ url: 'http://example.com/movie.mkv' }
)) as { status: number; latencyMs?: number };
// 403 answers "is anything there" — it is a fact, not a failure to check.
expect(result.status).toBe(403);
expect(typeof result.latencyMs).toBe('number');
});
it('returns status 0 without latency when no response is obtained', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
axiosMock.mockRejectedValueOnce(new Error('ETIMEDOUT'));
axiosMock.isAxiosError.mockReturnValue(false);
const result = (await probeHandler?.(
{},
{ url: 'http://example.com/movie.mkv' }
)) as { status: number; url: string; latencyMs?: number };
expect(result).toEqual({
status: 0,
url: 'http://example.com/movie.mkv',
});
});
});
@@ -0,0 +1,143 @@
/**
* Stream reachability probe.
*
* A cheap "is this URL alive" check that runs in the main process because the
* renderer cannot do it: a browser request to an arbitrary IPTV host is
* CORS-blocked, and `no-cors` yields an opaque response where 200, 403 and 404
* are indistinguishable. Electron does not escape this either — `webSecurity`
* stays on — so the honest answer has to come from Node.
*
* Originally inline in `xtream.events.ts` for catchup URL checks; extracted so
* VOD multi-source can reuse it, and so that file stays inside its line budget.
*/
import axios, { AxiosRequestConfig } from 'axios';
import { ipcMain } from 'electron';
import { UnsafeUrlError } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to
// challenge generic browser-looking User-Agents while allowlisting known
// IPTV player clients. A VLC-style User-Agent reliably passes those checks.
const PROBE_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18';
const PROBE_TIMEOUT_MS = 10000;
export interface StreamProbePayload {
url: string;
method?: 'GET' | 'HEAD';
/**
* The headers the playlist would actually play this stream with.
*
* A panel that requires its configured User-Agent, Referer or Origin
* answers 401/403 to a request without them — and reporting that as a
* dead source would be a lie about a stream that plays fine. Probing the
* way playback requests is the only honest check.
*/
userAgent?: string | null;
referer?: string | null;
origin?: string | null;
}
export interface StreamProbeResponse {
/**
* The HTTP status, or 0 when no response was obtained at all.
*
* 0 is NOT "offline" — it also covers a timeout and a URL rejected by the
* redirect-safety policy. Callers must render it as "could not check",
* never as a dead source.
*/
status: number;
url: string;
/** Round-trip time; present whenever the request completed either way. */
latencyMs?: number;
error?: string;
}
export async function runStreamProbe(
payload: StreamProbePayload
): Promise<StreamProbeResponse> {
const method = payload.method ?? 'HEAD';
const config: AxiosRequestConfig = {
method,
url: payload.url,
headers: {
// The playlist's own User-Agent wins when it has one: the default
// below is a guess that merely gets past most WAFs, while that
// one is what the server was configured to expect.
'User-Agent': payload.userAgent?.trim() || PROBE_CLIENT_USER_AGENT,
...(payload.referer?.trim()
? { Referer: payload.referer.trim() }
: {}),
...(payload.origin?.trim()
? { Origin: payload.origin.trim() }
: {}),
// Some servers reject a bare HEAD but answer a tiny ranged GET.
...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}),
},
timeout: PROBE_TIMEOUT_MS,
responseType: method === 'GET' ? 'stream' : undefined,
validateStatus: () => true,
};
// Monotonic: a wall-clock change mid-probe must not yield a negative latency.
const startedAt = performance.now();
const elapsed = () => Math.round(performance.now() - startedAt);
try {
const response = await requestWithValidatedRedirects(
payload.url,
config,
{
allowPrivateNetworkRedirects: false,
allowPrivateNetworks: true,
pinAllowedPrivateNetworkHosts: true,
}
);
// A ranged GET opens a stream we never read — release it immediately.
const responseBody = response.data as
| { destroy?: () => void }
| undefined;
responseBody?.destroy?.();
return {
status: response.status,
url: response.config?.url ?? payload.url,
latencyMs: elapsed(),
};
} catch (error) {
// A refusal still answers the question "is anything there".
if (axios.isAxiosError(error) && error.response) {
return {
status: error.response.status,
url: payload.url,
latencyMs: elapsed(),
};
}
if (error instanceof UnsafeUrlError) {
return {
status: 0,
url: payload.url,
error: error.message,
};
}
return {
status: 0,
url: payload.url,
};
}
}
export function registerStreamProbeHandlers(): void {
ipcMain.handle(
'STREAM_PROBE_URL',
async (_event, payload: StreamProbePayload) => runStreamProbe(payload)
);
// Retained for the Xtream catchup variant probe, which predates this
// module and whose renderer call site still uses the old channel.
ipcMain.handle(
'XTREAM_PROBE_URL',
async (_event, payload: StreamProbePayload) => runStreamProbe(payload)
);
}
@@ -136,85 +136,6 @@ describe('XtreamEvents session cancellation', () => {
expect(userAgent).not.toMatch(/^Mozilla\/5\.0 /);
});
it('follows validated redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL');
const destroyProbeBody = jest.fn();
expect(probeHandler).toBeDefined();
axiosMock
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 302,
headers: { location: '/media.ts' },
config,
})
)
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 206,
headers: {},
data: { destroy: destroyProbeBody },
config,
})
);
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { status: number; url: string };
expect(result.status).toBe(206);
expect(result.url).toBe('http://localhost:3211/media.ts');
expect(axiosMock).toHaveBeenCalledTimes(2);
const firstRequest = axiosMock.mock.calls[0][0] as {
headers?: Record<string, string>;
maxRedirects?: number;
method?: string;
responseType?: string;
};
expect(firstRequest.method).toBe('GET');
expect(firstRequest.headers).toEqual(
expect.objectContaining({ Range: 'bytes=0-4095' })
);
expect(firstRequest.headers?.['User-Agent']).not.toBe(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
);
expect(firstRequest.maxRedirects).toBe(0);
expect(firstRequest.responseType).toBe('stream');
expect(destroyProbeBody).toHaveBeenCalledTimes(1);
});
it('rejects private cross-origin redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL');
expect(probeHandler).toBeDefined();
axiosMock.mockResolvedValueOnce({
status: 302,
headers: { location: 'http://127.0.0.1/admin' },
config: {
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
},
});
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { error?: string; status: number; url: string };
expect(result).toEqual({
error: 'URL points to a private or local network address',
status: 0,
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
});
expect(axiosMock).toHaveBeenCalledTimes(1);
});
it('aborts requests that were registered with only a session id', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION);
@@ -13,7 +13,6 @@ import {
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { emitPortalDebugEvent } from './portal-debug.events';
import { UnsafeUrlError } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
import {
createXtreamMainPerformanceCaptureForRequest,
@@ -323,65 +322,3 @@ type ActiveXtreamRequest = {
const activeXtreamRequests = new Map<string, ActiveXtreamRequest>();
ipcMain.handle(
'XTREAM_PROBE_URL',
async (
_event,
payload: {
url: string;
method?: 'GET' | 'HEAD';
}
) => {
const method = payload.method ?? 'HEAD';
const config: AxiosRequestConfig = {
method,
url: payload.url,
headers: {
'User-Agent': XTREAM_CLIENT_USER_AGENT,
...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}),
},
timeout: 10000,
responseType: method === 'GET' ? 'stream' : undefined,
validateStatus: () => true,
};
try {
const response = await requestWithValidatedRedirects(
payload.url,
config,
{
allowPrivateNetworkRedirects: false,
allowPrivateNetworks: true,
pinAllowedPrivateNetworkHosts: true,
}
);
const responseBody = response.data as
{ destroy?: () => void } | undefined;
responseBody?.destroy?.();
return {
status: response.status,
url: response.config?.url ?? payload.url,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
status: error.response.status,
url: payload.url,
};
}
if (error instanceof UnsafeUrlError) {
return {
status: 0,
url: payload.url,
error: error.message,
};
}
return {
status: 0,
url: payload.url,
};
}
}
);
@@ -56,6 +56,13 @@ export const DB_WORKER_OPERATIONS = [
'DB_GET_TMDB_CACHE_STATS',
'DB_CLEAR_TMDB_METADATA',
'DB_MATCH_TITLES',
'DB_FIND_TITLE_SOURCES',
'DB_GET_VOD_SOURCE_PIN',
'DB_LIST_VOD_SOURCE_PINS',
'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST',
'DB_SET_VOD_SOURCE_PIN',
'DB_CLEAR_VOD_SOURCE_PIN',
'DB_REPLACE_VOD_SOURCE_PINS',
] as const;
export type DbWorkerOperation = (typeof DB_WORKER_OPERATIONS)[number];
@@ -4,6 +4,7 @@ import {
} from './database.worker-connection';
import { parentPort } from 'worker_threads';
import type {
VodSourcePin,
XtreamBackupFavoriteItem,
XtreamBackupRecentlyViewedItem,
} from '@iptvnator/shared/interfaces';
@@ -80,12 +81,24 @@ import {
removeRecentItemsBatch,
} from '../database/operations/recently-viewed.operations';
import { matchTitles } from '../database/operations/title-match.operations';
import {
findTitleSources,
type FindTitleSourcesRequest,
} from '../database/operations/title-sources.operations';
import {
clearTmdbMetadata,
getTmdbCacheStats,
getTmdbMetadata,
setTmdbMetadata,
} from '../database/operations/tmdb.operations';
import {
clearVodSourcePin,
getVodSourcePin,
clearVodSourcePinsForPlaylist,
replaceVodSourcePinsForPlaylist,
listVodSourcePinsForPlaylist,
setVodSourcePin,
} from '../database/operations/vod-source-pin.operations';
import {
deleteXtreamContent,
restoreXtreamUserData,
@@ -782,6 +795,59 @@ async function executeRequest(
return matchTitles(db, payload.titles);
}
case 'DB_FIND_TITLE_SOURCES': {
const payload = message.payload as {
request: FindTitleSourcesRequest;
};
return findTitleSources(db, payload.request);
}
case 'DB_GET_VOD_SOURCE_PIN': {
const payload = message.payload as { matchKeys: string[] };
return getVodSourcePin(db, payload.matchKeys);
}
case 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST': {
const payload = message.payload as { playlistId: string };
return clearVodSourcePinsForPlaylist(db, payload.playlistId);
}
case 'DB_LIST_VOD_SOURCE_PINS': {
const payload = message.payload as { playlistId: string };
return listVodSourcePinsForPlaylist(db, payload.playlistId);
}
case 'DB_SET_VOD_SOURCE_PIN': {
const payload = message.payload as {
pin: VodSourcePin;
retireKeys?: string[];
aliasKeys?: string[];
};
return setVodSourcePin(
db,
payload.pin,
payload.retireKeys ?? [],
payload.aliasKeys ?? []
);
}
case 'DB_REPLACE_VOD_SOURCE_PINS': {
const payload = message.payload as {
playlistId: string;
pins: VodSourcePin[];
};
return replaceVodSourcePinsForPlaylist(
db,
payload.playlistId,
payload.pins ?? []
);
}
case 'DB_CLEAR_VOD_SOURCE_PIN': {
const payload = message.payload as { matchKeys: string[] };
return clearVodSourcePin(db, payload.matchKeys);
}
case 'DB_DELETE_ALL_PLAYLISTS': {
const payload = message.payload as { operationId?: string };
+2
View File
@@ -30,6 +30,7 @@ import { registerStaticHeaderShims } from './app/services/request-header-overrid
import { AppUpdateService } from './app/services/app-update.service';
import { databaseWorkerClient } from './app/services/database-worker-client';
import WindowEvents from './app/events/window.events';
import { registerStreamProbeHandlers } from './app/events/stream-probe';
import XtreamEvents from './app/events/xtream.events';
import { environment } from './environments/environment';
import {
@@ -152,6 +153,7 @@ export default class Main {
SettingsEvents.bootstrapSettingsEvents();
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
registerStreamProbeHandlers();
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
@@ -34,6 +34,7 @@ export function createSettingsForm(
webPlayerSharedControls: false,
playerAmbientMode: false,
playerUpNextRail: true,
vodAutoFailover: false,
...(supportsEpg
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
: {}),
@@ -121,6 +122,7 @@ export function createSettingsFromFormValue(
webPlayerSharedControls: value.webPlayerSharedControls ?? false,
playerAmbientMode: value.playerAmbientMode ?? false,
playerUpNextRail: value.playerUpNextRail ?? true,
vodAutoFailover: value.vodAutoFailover ?? false,
streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false,
language: value.language ?? Language.ENGLISH,
@@ -0,0 +1,91 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { StreamFormat, VideoPlayer } from '@iptvnator/shared/interfaces';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
/**
* Who gets offered VOD auto-failover.
*
* Only the built-in web players raise the playback diagnostic that triggers a
* source switch: Embedded MPV has its diagnostics suppressed and MPV/VLC play
* outside the app entirely. Offering the control there would let a user enable
* a feature that can never fire.
*/
describe('SettingsPlaybackSectionComponent — VOD auto-failover', () => {
let fixture: ComponentFixture<SettingsPlaybackSectionComponent>;
const settingRow = (): Element | null =>
fixture.nativeElement.querySelector(
'[data-test-id="vod-auto-failover-setting"]'
);
function render(player: VideoPlayer, supported = true): void {
fixture.componentRef.setInput('form', createForm(player));
fixture.componentRef.setInput('supportsVodMultiSource', supported);
fixture.detectChanges();
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
SettingsPlaybackSectionComponent,
NoopAnimationsModule,
ReactiveFormsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
fixture = TestBed.createComponent(SettingsPlaybackSectionComponent);
fixture.componentRef.setInput('activeSection', 'playback');
fixture.componentRef.setInput('players', []);
fixture.componentRef.setInput('streamFormatEnum', StreamFormat);
});
it.each([
VideoPlayer.Html5Player,
VideoPlayer.VideoJs,
VideoPlayer.ArtPlayer,
])('offers the toggle on %s', (player) => {
render(player);
expect(settingRow()).not.toBeNull();
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])(
'hides the toggle on %s, which never reports a playback failure',
(player) => {
render(player);
expect(settingRow()).toBeNull();
}
);
it('stays hidden in the PWA, where there is nothing to fail over to', () => {
render(VideoPlayer.Html5Player, false);
expect(settingRow()).toBeNull();
});
});
function createForm(player: VideoPlayer): FormGroup {
return new FormGroup({
player: new FormControl(player),
webPlayerSharedControls: new FormControl(false),
playerAmbientMode: new FormControl(false),
playerUpNextRail: new FormControl(true),
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
openStreamOnDoubleClick: new FormControl(false),
showExternalPlaybackBar: new FormControl(true),
embeddedMpvFrameCopy: new FormControl(false),
mpvPlayerPath: new FormControl(''),
mpvPlayerArguments: new FormControl(''),
mpvReuseInstance: new FormControl(false),
vlcPlayerPath: new FormControl(''),
vlcPlayerArguments: new FormControl(''),
vlcReuseInstance: new FormControl(false),
recordingFolder: new FormControl(''),
vodAutoFailover: new FormControl(false),
});
}
@@ -133,6 +133,28 @@
</div>
}
<!-- Only the web players raise a playback diagnostic, so under MPV, VLC
or Embedded MPV nothing would ever trigger a failover. -->
@if (supportsVodMultiSource() && isWebPlayerSelected()) {
<div class="setting-item" data-test-id="vod-auto-failover-setting">
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.VOD_AUTO_FAILOVER' | translate }}
</h4>
<p>
{{ 'SETTINGS.VOD_AUTO_FAILOVER_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
[aria-label]="'SETTINGS.VOD_AUTO_FAILOVER' | translate"
formControlName="vodAutoFailover"
data-test-id="vod-auto-failover-toggle"
></mat-checkbox>
</div>
</div>
}
@if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
<div
class="setting-item"
@@ -394,5 +394,6 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
vlcPlayerArguments: new FormControl(''),
vlcReuseInstance: new FormControl(false),
recordingFolder: new FormControl(''),
vodAutoFailover: new FormControl(false),
});
}
@@ -8,7 +8,11 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core';
import { StreamFormat, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
StreamFormat,
VideoPlayer,
reportsPlaybackFailures,
} from '@iptvnator/shared/interfaces';
import { SettingsPlayerOption } from './settings.models';
@Component({
@@ -51,15 +55,15 @@ export class SettingsPlaybackSectionComponent {
readonly frameCopyActive = input(false);
readonly supportsManagedExternalPlayers = input(false);
readonly supportsExternalPlayerPathSettings = input(false);
/**
* Cross-playlist movie matching is Electron-only, so the auto-failover
* toggle would control nothing in the PWA.
*/
readonly supportsVodMultiSource = input(false);
readonly selectRecordingFolder = output<void>();
isWebPlayerSelected(): boolean {
const player = this.form().value.player;
return (
player === VideoPlayer.VideoJs ||
player === VideoPlayer.Html5Player ||
player === VideoPlayer.ArtPlayer
);
return reportsPlaybackFailures(this.form().value.player);
}
isExternalPlayerSelected(): boolean {
@@ -50,6 +50,7 @@
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[supportsExternalPlayerPathSettings]="supportsExternalPlayerPathSettings"
[supportsVodMultiSource]="supportsVodMultiSource"
[frameCopyAvailable]="embeddedMpv.frameCopyAvailable()"
[frameCopyActive]="embeddedMpv.frameCopyActive()"
(selectRecordingFolder)="selectRecordingFolder()"
@@ -20,6 +20,7 @@ import { Router } from '@angular/router';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
import { BUILD_COMMIT } from '../../environments/build-commit';
import { SettingsAboutSectionComponent } from './settings-about-section.component';
@@ -104,6 +105,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private readonly settingsCtx = inject(SettingsContextService);
private readonly settingsSnackbar = inject(SettingsSnackbarService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService);
private readonly matDialog = inject(MatDialog);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
@@ -129,6 +131,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.runtime.supportsManagedExternalPlayers;
readonly supportsExternalPlayerPathSettings =
this.runtime.supportsExternalPlayerPathSettings;
readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable;
readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
readonly activeSection = this.settingsCtx.activeSection;
@@ -81,6 +81,7 @@ export const DEFAULT_SETTINGS = {
webPlayerSharedControls: false,
playerAmbientMode: false,
playerUpNextRail: true,
vodAutoFailover: false,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق.",
"VOD_AUTO_FAILOVER": "التبديل التلقائي للمصدر عند فشل التشغيل",
"VOD_AUTO_FAILOVER_DESCRIPTION": "عند فشل تشغيل فيلم وتوفّر الفيلم نفسه في قائمة تشغيل أخرى، يتم التبديل إليها تلقائيًا مع الاحتفاظ بموضع التشغيل. الخيار معطّل افتراضيًا، ويُعلَن عن التبديل دائمًا لأن المصدر الآخر قد يحمل دبلجة مختلفة."
},
"THEMES": {
"DARK_THEME": "داكن",
@@ -1001,7 +1003,45 @@
"ITEM": "عنصر",
"ITEMS": "عناصر",
"NO_EPISODES": "لا توجد حلقات متاحة",
"ALL_RADIO": "كل الراديو"
"ALL_RADIO": "كل الراديو",
"MULTI_SOURCE": {
"SOURCES": "المصادر",
"SOURCE": "المصدر",
"SOURCES_FOR": "مصادر “{{title}}”",
"OPEN_SOURCES": "عرض المصادر البديلة",
"MATCHED_BY_TMDB": "تمت المطابقة عبر TMDB",
"MATCHED_BY_TITLE": "تمت المطابقة حسب العنوان",
"MATCH_BY_TITLE": "حسب العنوان",
"PLAYING": "قيد التشغيل",
"CURRENT": "الحالي",
"PLAY_FROM_SOURCE": "التشغيل من هذا المصدر",
"PIN_SOURCE": "اجعله المصدر الرئيسي",
"UNPIN_SOURCE": "إلغاء تثبيت هذا المصدر",
"CHECK": "فحص",
"CHECK_TOOLTIP": "التحقق من التوفر",
"PROBE_OK": "متاح",
"PROBE_FAIL": "غير متاح",
"PROBE_CHECKING": "جارٍ التحقق",
"AUTO_FAILOVER": "التبديل التلقائي عند الخطأ",
"PLAYING_FROM": "التشغيل من:",
"SWITCH_TITLE": "المصدر غير متاح — جارٍ التبديل إلى {{playlist}}",
"SWITCH_DETAIL": "الاستئناف عند {{timecode}}",
"SWITCH_AUDIO_WARNING": "قد تختلف الدبلجة",
"SWITCHED_TO": "تم التبديل إلى {{playlist}}",
"UNDO": "تراجع",
"CHOOSE_OTHER": "اختر مصدرًا آخر",
"TRY_ANOTHER_SOURCE": "جرّب مصدرًا آخر",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} لا توفّر هذا البث. هذا الفيلم موجود في قوائم التشغيل الأخرى لديك:",
"RESUME_KEPT": "سيتم الاحتفاظ بموضع التشغيل · {{timecode}}",
"ALL_SOURCES_TRIED": "تمت تجربة جميع المصادر.",
"ALSO_FOUND_IN_ONE": "— موجود أيضًا في قائمة تشغيل أخرى",
"ALSO_FOUND_IN_OTHER": "— موجود أيضًا في {{count}} من قوائم التشغيل الأخرى",
"CHECK_ALL": "فحص الكل",
"FILTER_PLACEHOLDER": "التصفية حسب قائمة التشغيل",
"NO_MATCHES": "لا توجد قوائم تشغيل تطابق هذا الفلتر",
"COPIES": "{{count}} نسخ في قائمة التشغيل هذه",
"MORE_SOURCES": "+{{count}} أخرى في قائمة المصادر"
}
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق.",
"VOD_AUTO_FAILOVER": "بدل المصدر أوتوماتيكياً عند خطأ فالتشغيل",
"VOD_AUTO_FAILOVER_DESCRIPTION": "ملي شي فيلم ما يتشغلش وكان نفس الفيلم كاين ف قائمة تشغيل أخرى، التطبيق كيبدل ليها أوتوماتيكياً وكيحافظ على التوقيت. معطل افتراضياً؛ التبديل ديما كيتعلن حيت مصدر آخر يمكن يكون فيه دوبلاج مختلف."
},
"THEMES": {
"DARK_THEME": "داكن",
@@ -1001,7 +1003,45 @@
"ITEM": "عنصر",
"ITEMS": "عناصر",
"NO_EPISODES": "ما كاينش حلقات متوفرة",
"ALL_RADIO": "All radio"
"ALL_RADIO": "All radio",
"MULTI_SOURCE": {
"SOURCES": "مصادر",
"SOURCE": "مصدر",
"SOURCES_FOR": "مصادر ديال “{{title}}”",
"OPEN_SOURCES": "وري المصادر البديلة",
"MATCHED_BY_TMDB": "تطابق عن طريق TMDB",
"MATCHED_BY_TITLE": "تطابق عن طريق العنوان",
"MATCH_BY_TITLE": "بالعنوان",
"PLAYING": "كيتشغل",
"CURRENT": "الحالي",
"PLAY_FROM_SOURCE": "شغل من هاد المصدر",
"PIN_SOURCE": "دير هاد المصدر هو الرئيسي",
"UNPIN_SOURCE": "حيد هاد المصدر من الرئيسي",
"CHECK": "تحقق",
"CHECK_TOOLTIP": "تحقق من التوفر",
"PROBE_OK": "متوفر",
"PROBE_FAIL": "ما متوفرش",
"PROBE_CHECKING": "جاري التحقق",
"AUTO_FAILOVER": "بدل أوتوماتيكياً عند الخطأ",
"PLAYING_FROM": "كيتشغل من:",
"SWITCH_TITLE": "المصدر ما متوفرش — كنبدلو ل {{playlist}}",
"SWITCH_DETAIL": "غادي نكملو من {{timecode}}",
"SWITCH_AUDIO_WARNING": "يمكن الدوبلاج يكون مختلف",
"SWITCHED_TO": "تبدل ل {{playlist}}",
"UNDO": "تراجع",
"CHOOSE_OTHER": "اختار واحد آخر",
"TRY_ANOTHER_SOURCE": "جرب مصدر آخر",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} ما كيوفرش هاد البث. هاد الفيلم كاين ف قوائم التشغيل الأخرى ديالك:",
"RESUME_KEPT": "التوقيت غادي يتحفظ · {{timecode}}",
"ALL_SOURCES_TRIED": "جميع المصادر تجربو.",
"ALSO_FOUND_IN_ONE": "— كاين حتى ف قائمة تشغيل أخرى",
"ALSO_FOUND_IN_OTHER": "— كاين حتى ف {{count}} ديال قوائم التشغيل الأخرى",
"CHECK_ALL": "تحقق من الكل",
"FILTER_PLACEHOLDER": "فلتر بقائمة التشغيل",
"NO_MATCHES": "ما كاينش قوائم تشغيل مطابقة لهاد الفلتر",
"COPIES": "{{count}} ديال النسخ ف هاد قائمة التشغيل",
"MORE_SOURCES": "+{{count}} أخرى ف لائحة المصادر"
}
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы.",
"VOD_AUTO_FAILOVER": "Аўтапераключэнне крыніцы пры памылцы прайгравання",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Калі фільм не прайграецца, а той жа фільм ёсць у іншым плэйлісце, аўтаматычна пераключацца на яго, захоўваючы пазіцыю прайгравання. Па змаўчанні выключана; пра пераключэнне заўсёды паведамляецца, бо ў іншай крыніцы можа быць іншая агучка."
},
"THEMES": {
"DARK_THEME": "Цёмная",
@@ -1001,7 +1003,45 @@
"ITEM": "элемент",
"ITEMS": "элементаў",
"NO_EPISODES": "Няма даступных эпізодаў",
"ALL_RADIO": "Усе радыё"
"ALL_RADIO": "Усе радыё",
"MULTI_SOURCE": {
"SOURCES": "Крыніцы",
"SOURCE": "Крыніца",
"SOURCES_FOR": "Крыніцы для «{{title}}»",
"OPEN_SOURCES": "Паказаць альтэрнатыўныя крыніцы",
"MATCHED_BY_TMDB": "супастаўлена па TMDB",
"MATCHED_BY_TITLE": "супастаўлена па назве",
"MATCH_BY_TITLE": "па назве",
"PLAYING": "Прайграецца",
"CURRENT": "Бягучая",
"PLAY_FROM_SOURCE": "Прайграць з гэтай крыніцы",
"PIN_SOURCE": "Зрабіць асноўнай крыніцай",
"UNPIN_SOURCE": "Адмацаваць гэтую крыніцу",
"CHECK": "праверыць",
"CHECK_TOOLTIP": "Праверыць даступнасць",
"PROBE_OK": "даступна",
"PROBE_FAIL": "недаступна",
"PROBE_CHECKING": "праверка",
"AUTO_FAILOVER": "Аўтапераключэнне пры памылцы",
"PLAYING_FROM": "Прайграецца з:",
"SWITCH_TITLE": "Крыніца недаступная — пераключаемся на {{playlist}}",
"SWITCH_DETAIL": "працягваем з {{timecode}}",
"SWITCH_AUDIO_WARNING": "агучка можа адрознівацца",
"SWITCHED_TO": "Пераключана на {{playlist}}",
"UNDO": "Вярнуць",
"CHOOSE_OTHER": "Выбраць іншую",
"TRY_ANOTHER_SOURCE": "Паспрабаваць іншую крыніцу",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} не аддае гэты паток. Гэты фільм ёсць у іншых вашых плэйлістах:",
"RESUME_KEPT": "пазіцыя захаваецца · {{timecode}}",
"ALL_SOURCES_TRIED": "Усе крыніцы ўжо выпрабаваны.",
"ALSO_FOUND_IN_ONE": "— таксама ёсць яшчэ ў адным плэйлісце",
"ALSO_FOUND_IN_OTHER": "— таксама ёсць яшчэ ў {{count}} плэйлістах",
"CHECK_ALL": "праверыць усе",
"FILTER_PLACEHOLDER": "Фільтраваць па плэйлісце",
"NO_MATCHES": "Ніводны плэйліст не адпавядае гэтаму фільтру",
"COPIES": "{{count}} копій у гэтым плэйлісце",
"MORE_SOURCES": "+{{count}} у спісе крыніц"
}
},
"INFORMATION": "Інфармацыя",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam.",
"VOD_AUTO_FAILOVER": "Quelle bei Wiedergabefehler automatisch wechseln",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Wenn sich ein Film nicht abspielen lässt und derselbe Film in einer anderen Playlist vorhanden ist, wird automatisch dorthin gewechselt und die Wiedergabeposition beibehalten. Standardmäßig deaktiviert; der Wechsel wird immer angekündigt, da eine andere Quelle eine andere Synchronfassung enthalten kann."
},
"THEMES": {
"DARK_THEME": "Dunkel",
@@ -1001,7 +1003,45 @@
"ITEM": "Element",
"ITEMS": "Elemente",
"NO_EPISODES": "Keine Episoden verfügbar",
"ALL_RADIO": "Alle Radiosender"
"ALL_RADIO": "Alle Radiosender",
"MULTI_SOURCE": {
"SOURCES": "Quellen",
"SOURCE": "Quelle",
"SOURCES_FOR": "Quellen für „{{title}}“",
"OPEN_SOURCES": "Alternative Quellen anzeigen",
"MATCHED_BY_TMDB": "über TMDB zugeordnet",
"MATCHED_BY_TITLE": "über den Titel zugeordnet",
"MATCH_BY_TITLE": "per Titel",
"PLAYING": "Läuft",
"CURRENT": "Aktuell",
"PLAY_FROM_SOURCE": "Von dieser Quelle abspielen",
"PIN_SOURCE": "Als Hauptquelle festlegen",
"UNPIN_SOURCE": "Hauptquelle aufheben",
"CHECK": "prüfen",
"CHECK_TOOLTIP": "Verfügbarkeit prüfen",
"PROBE_OK": "verfügbar",
"PROBE_FAIL": "nicht verfügbar",
"PROBE_CHECKING": "wird geprüft",
"AUTO_FAILOVER": "Bei Fehler automatisch wechseln",
"PLAYING_FROM": "Wiedergabe von:",
"SWITCH_TITLE": "Quelle nicht verfügbar — Wechsel zu {{playlist}}",
"SWITCH_DETAIL": "wird bei {{timecode}} fortgesetzt",
"SWITCH_AUDIO_WARNING": "die Synchronfassung kann abweichen",
"SWITCHED_TO": "Zu {{playlist}} gewechselt",
"UNDO": "Rückgängig",
"CHOOSE_OTHER": "Andere wählen",
"TRY_ANOTHER_SOURCE": "Andere Quelle versuchen",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} liefert diesen Stream nicht. Dieser Film ist in Ihren anderen Playlists vorhanden:",
"RESUME_KEPT": "Wiedergabeposition wird beibehalten · {{timecode}}",
"ALL_SOURCES_TRIED": "Alle Quellen wurden bereits versucht.",
"ALSO_FOUND_IN_ONE": "— auch in einer weiteren Playlist gefunden",
"ALSO_FOUND_IN_OTHER": "— auch in {{count}} weiteren Playlists gefunden",
"CHECK_ALL": "alle prüfen",
"FILTER_PLACEHOLDER": "Nach Playlist filtern",
"NO_MATCHES": "Keine Playlists entsprechen dem Filter",
"COPIES": "{{count}} Kopien in dieser Playlist",
"MORE_SOURCES": "+{{count}} weitere unter „Quellen“"
}
},
"INFORMATION": "Information",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής.",
"VOD_AUTO_FAILOVER": "Αυτόματη αλλαγή πηγής σε σφάλμα αναπαραγωγής",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Όταν μια ταινία δεν μπορεί να αναπαραχθεί και η ίδια ταινία υπάρχει σε άλλη λίστα αναπαραγωγής, γίνεται αυτόματη μετάβαση σε αυτήν με διατήρηση της θέσης αναπαραγωγής. Απενεργοποιημένο από προεπιλογή· η αλλαγή ανακοινώνεται πάντα, επειδή μια άλλη πηγή μπορεί να έχει διαφορετική μεταγλώττιση."
},
"THEMES": {
"DARK_THEME": "Σκούρο",
@@ -1001,7 +1003,45 @@
"ITEM": "στοιχείο",
"ITEMS": "στοιχεία",
"NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια",
"ALL_RADIO": "Όλα τα ραδιόφωνα"
"ALL_RADIO": "Όλα τα ραδιόφωνα",
"MULTI_SOURCE": {
"SOURCES": "Πηγές",
"SOURCE": "Πηγή",
"SOURCES_FOR": "Πηγές για «{{title}}»",
"OPEN_SOURCES": "Εμφάνιση εναλλακτικών πηγών",
"MATCHED_BY_TMDB": "αντιστοιχίστηκε μέσω TMDB",
"MATCHED_BY_TITLE": "αντιστοιχίστηκε βάσει τίτλου",
"MATCH_BY_TITLE": "βάσει τίτλου",
"PLAYING": "Παίζει",
"CURRENT": "Τρέχουσα",
"PLAY_FROM_SOURCE": "Αναπαραγωγή από αυτήν την πηγή",
"PIN_SOURCE": "Ορισμός ως κύρια πηγή",
"UNPIN_SOURCE": "Κατάργηση κύριας πηγής",
"CHECK": "έλεγχος",
"CHECK_TOOLTIP": "Έλεγχος διαθεσιμότητας",
"PROBE_OK": "διαθέσιμη",
"PROBE_FAIL": "μη διαθέσιμη",
"PROBE_CHECKING": "ελέγχεται",
"AUTO_FAILOVER": "Αυτόματη αλλαγή σε σφάλμα",
"PLAYING_FROM": "Αναπαραγωγή από:",
"SWITCH_TITLE": "Η πηγή δεν είναι διαθέσιμη — μετάβαση σε {{playlist}}",
"SWITCH_DETAIL": "συνέχεια από {{timecode}}",
"SWITCH_AUDIO_WARNING": "η μεταγλώττιση μπορεί να διαφέρει",
"SWITCHED_TO": "Έγινε μετάβαση σε {{playlist}}",
"UNDO": "Αναίρεση",
"CHOOSE_OTHER": "Άλλη πηγή",
"TRY_ANOTHER_SOURCE": "Δοκιμή άλλης πηγής",
"ERROR_HAS_ALTERNATIVES": "Η λίστα {{playlist}} δεν παρέχει αυτήν τη ροή. Αυτή η ταινία υπάρχει στις άλλες λίστες αναπαραγωγής σας:",
"RESUME_KEPT": "η θέση αναπαραγωγής θα διατηρηθεί · {{timecode}}",
"ALL_SOURCES_TRIED": "Δοκιμάστηκαν όλες οι πηγές.",
"ALSO_FOUND_IN_ONE": "— βρέθηκε επίσης σε μία άλλη λίστα αναπαραγωγής",
"ALSO_FOUND_IN_OTHER": "— βρέθηκε επίσης σε {{count}} άλλες λίστες αναπαραγωγής",
"CHECK_ALL": "έλεγχος όλων",
"FILTER_PLACEHOLDER": "Φιλτράρισμα κατά λίστα αναπαραγωγής",
"NO_MATCHES": "Δεν βρέθηκαν αντίστοιχες λίστες αναπαραγωγής",
"COPIES": "{{count}} αντίγραφα σε αυτήν τη λίστα",
"MORE_SOURCES": "+{{count}} ακόμη στη λίστα πηγών"
}
},
"INFORMATION": "Πληροφορίες",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app.",
"VOD_AUTO_FAILOVER": "Auto-switch source on playback error",
"VOD_AUTO_FAILOVER_DESCRIPTION": "When a movie fails to play and the same film exists in another playlist, switch to it automatically and keep the timecode. Off by default; the switch is always announced because another source can carry a different dub."
},
"THEMES": {
"DARK_THEME": "Dark",
@@ -1001,7 +1003,45 @@
"ITEM": "item",
"ITEMS": "items",
"NO_EPISODES": "No episodes available",
"ALL_RADIO": "All radio"
"ALL_RADIO": "All radio",
"MULTI_SOURCE": {
"SOURCES": "Sources",
"SOURCE": "Source",
"SOURCES_FOR": "Sources for “{{title}}”",
"OPEN_SOURCES": "Show alternative sources",
"MATCHED_BY_TMDB": "matched by TMDB",
"MATCHED_BY_TITLE": "matched by title",
"MATCH_BY_TITLE": "by title",
"PLAYING": "Playing",
"CURRENT": "Current",
"PLAY_FROM_SOURCE": "Play from this source",
"PIN_SOURCE": "Make this the main source",
"UNPIN_SOURCE": "Unpin this source",
"CHECK": "check",
"CHECK_TOOLTIP": "Check availability",
"PROBE_OK": "available",
"PROBE_FAIL": "unavailable",
"PROBE_CHECKING": "checking",
"AUTO_FAILOVER": "Auto-switch on error",
"PLAYING_FROM": "Playing from:",
"SWITCH_TITLE": "Source unavailable — switching to {{playlist}}",
"SWITCH_DETAIL": "resuming at {{timecode}}",
"SWITCH_AUDIO_WARNING": "the dub may differ",
"SWITCHED_TO": "Switched to {{playlist}}",
"UNDO": "Undo",
"CHOOSE_OTHER": "Choose another",
"TRY_ANOTHER_SOURCE": "Try another source",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} is not serving this stream. This movie is in your other playlists:",
"RESUME_KEPT": "timecode will be kept · {{timecode}}",
"ALL_SOURCES_TRIED": "Every source has been tried.",
"ALSO_FOUND_IN_ONE": "— also found in 1 other playlist",
"ALSO_FOUND_IN_OTHER": "— also found in {{count}} other playlists",
"CHECK_ALL": "check all",
"FILTER_PLACEHOLDER": "Filter by playlist",
"NO_MATCHES": "No playlists match that filter",
"COPIES": "{{count}} copies in this playlist",
"MORE_SOURCES": "+{{count}} more in the sources list"
}
},
"INFORMATION": "Information",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación.",
"VOD_AUTO_FAILOVER": "Cambiar de fuente automáticamente al fallar la reproducción",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Cuando una película no se puede reproducir y la misma película está en otra lista, cambia a ella automáticamente y conserva la posición de reproducción. Desactivado de forma predeterminada; el cambio siempre se avisa, porque otra fuente puede tener un doblaje distinto."
},
"THEMES": {
"DARK_THEME": "Oscuro",
@@ -1001,7 +1003,45 @@
"ITEM": "elemento",
"ITEMS": "elementos",
"NO_EPISODES": "No hay episodios disponibles",
"ALL_RADIO": "Todas las radios"
"ALL_RADIO": "Todas las radios",
"MULTI_SOURCE": {
"SOURCES": "Fuentes",
"SOURCE": "Fuente",
"SOURCES_FOR": "Fuentes para “{{title}}”",
"OPEN_SOURCES": "Mostrar fuentes alternativas",
"MATCHED_BY_TMDB": "coincidencia por TMDB",
"MATCHED_BY_TITLE": "coincidencia por título",
"MATCH_BY_TITLE": "por título",
"PLAYING": "Reproduciendo",
"CURRENT": "Actual",
"PLAY_FROM_SOURCE": "Reproducir desde esta fuente",
"PIN_SOURCE": "Fijar como fuente principal",
"UNPIN_SOURCE": "Dejar de fijar esta fuente",
"CHECK": "comprobar",
"CHECK_TOOLTIP": "Comprobar disponibilidad",
"PROBE_OK": "disponible",
"PROBE_FAIL": "no disponible",
"PROBE_CHECKING": "comprobando",
"AUTO_FAILOVER": "Cambio automático al fallar",
"PLAYING_FROM": "Reproduciendo desde:",
"SWITCH_TITLE": "Fuente no disponible — cambiando a {{playlist}}",
"SWITCH_DETAIL": "se reanuda en {{timecode}}",
"SWITCH_AUDIO_WARNING": "el doblaje puede ser distinto",
"SWITCHED_TO": "Se cambió a {{playlist}}",
"UNDO": "Deshacer",
"CHOOSE_OTHER": "Elegir otra",
"TRY_ANOTHER_SOURCE": "Probar otra fuente",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} no está ofreciendo esta transmisión. Esta película está en tus otras listas:",
"RESUME_KEPT": "se conservará la posición · {{timecode}}",
"ALL_SOURCES_TRIED": "Se han probado todas las fuentes.",
"ALSO_FOUND_IN_ONE": "— también está en otra lista",
"ALSO_FOUND_IN_OTHER": "— también está en otras {{count}} listas",
"CHECK_ALL": "comprobar todas",
"FILTER_PLACEHOLDER": "Filtrar por lista",
"NO_MATCHES": "Ninguna lista coincide con ese filtro",
"COPIES": "{{count}} copias en esta lista",
"MORE_SOURCES": "+{{count}} más en la lista de fuentes"
}
},
"INFORMATION": "Información",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application.",
"VOD_AUTO_FAILOVER": "Changer de source automatiquement en cas d'erreur de lecture",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Lorsqu'un film ne parvient pas à démarrer et que le même film existe dans une autre liste de lecture, bascule automatiquement vers celle-ci en conservant la position de lecture. Désactivé par défaut ; le changement est toujours annoncé, car une autre source peut proposer un doublage différent."
},
"THEMES": {
"DARK_THEME": "Sombre",
@@ -1001,7 +1003,45 @@
"ITEM": "élément",
"ITEMS": "éléments",
"NO_EPISODES": "Aucun épisode disponible",
"ALL_RADIO": "Toutes les radios"
"ALL_RADIO": "Toutes les radios",
"MULTI_SOURCE": {
"SOURCES": "Sources",
"SOURCE": "Source",
"SOURCES_FOR": "Sources pour « {{title}} »",
"OPEN_SOURCES": "Afficher les autres sources",
"MATCHED_BY_TMDB": "correspondance trouvée via TMDB",
"MATCHED_BY_TITLE": "correspondance trouvée d'après le titre",
"MATCH_BY_TITLE": "par titre",
"PLAYING": "En lecture",
"CURRENT": "Actuelle",
"PLAY_FROM_SOURCE": "Lire depuis cette source",
"PIN_SOURCE": "Définir comme source principale",
"UNPIN_SOURCE": "Ne plus utiliser comme source principale",
"CHECK": "vérifier",
"CHECK_TOOLTIP": "Vérifier la disponibilité",
"PROBE_OK": "disponible",
"PROBE_FAIL": "indisponible",
"PROBE_CHECKING": "vérification",
"AUTO_FAILOVER": "Bascule automatique en cas d'erreur",
"PLAYING_FROM": "Lecture depuis :",
"SWITCH_TITLE": "Source indisponible — bascule vers {{playlist}}",
"SWITCH_DETAIL": "reprise à {{timecode}}",
"SWITCH_AUDIO_WARNING": "le doublage peut être différent",
"SWITCHED_TO": "Basculé vers {{playlist}}",
"UNDO": "Annuler",
"CHOOSE_OTHER": "Choisir une autre source",
"TRY_ANOTHER_SOURCE": "Essayer une autre source",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} ne diffuse pas ce flux. Ce film est présent dans vos autres listes de lecture :",
"RESUME_KEPT": "la position de lecture sera conservée · {{timecode}}",
"ALL_SOURCES_TRIED": "Toutes les sources ont été essayées.",
"ALSO_FOUND_IN_ONE": "— également disponible dans une autre liste de lecture",
"ALSO_FOUND_IN_OTHER": "— également disponible dans {{count}} autres listes de lecture",
"CHECK_ALL": "tout vérifier",
"FILTER_PLACEHOLDER": "Filtrer par liste de lecture",
"NO_MATCHES": "Aucune liste de lecture ne correspond à ce filtre",
"COPIES": "{{count}} copies dans cette liste de lecture",
"MORE_SOURCES": "+{{count}} dans la liste des sources"
}
},
"INFORMATION": "Information",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "Újdonságok",
"EMBEDDED_MPV_FRAME_COPY": "Beágyazott MPV: képkocka-másolásos megjelenítés (kísérleti)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "A videó megjelenítése az alkalmazás rajzfelületén, hogy a menük és párbeszédpanelek fölötte jelenhessenek meg.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Az alkalmazás újraindítása után lép érvénybe."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Az alkalmazás újraindítása után lép érvénybe.",
"VOD_AUTO_FAILOVER": "Automatikus forrásváltás lejátszási hiba esetén",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Ha egy film lejátszása meghiúsul, és ugyanaz a film egy másik lejátszási listában is megtalálható, az alkalmazás automatikusan átvált rá, és megőrzi a lejátszási pozíciót. Alapértelmezés szerint kikapcsolva; a váltásról mindig értesítés jelenik meg, mert egy másik forráson eltérő szinkron lehet."
},
"THEMES": {
"DARK_THEME": "Sötét",
@@ -1001,7 +1003,45 @@
"ITEM": "item",
"ITEMS": "items",
"NO_EPISODES": "Nincs elérhető epizód",
"ALL_RADIO": "Összes rádió"
"ALL_RADIO": "Összes rádió",
"MULTI_SOURCE": {
"SOURCES": "Források",
"SOURCE": "Forrás",
"SOURCES_FOR": "„{{title}}” forrásai",
"OPEN_SOURCES": "Alternatív források megjelenítése",
"MATCHED_BY_TMDB": "TMDB alapján párosítva",
"MATCHED_BY_TITLE": "Cím alapján párosítva",
"MATCH_BY_TITLE": "cím alapján",
"PLAYING": "Lejátszás alatt",
"CURRENT": "Aktuális",
"PLAY_FROM_SOURCE": "Lejátszás erről a forrásról",
"PIN_SOURCE": "Beállítás fő forrásként",
"UNPIN_SOURCE": "Fő forrás beállításának visszavonása",
"CHECK": "ellenőrzés",
"CHECK_TOOLTIP": "Elérhetőség ellenőrzése",
"PROBE_OK": "elérhető",
"PROBE_FAIL": "nem elérhető",
"PROBE_CHECKING": "ellenőrzés…",
"AUTO_FAILOVER": "Automatikus váltás hiba esetén",
"PLAYING_FROM": "Lejátszás innen:",
"SWITCH_TITLE": "A forrás nem érhető el — váltás erre: {{playlist}}",
"SWITCH_DETAIL": "folytatás innen: {{timecode}}",
"SWITCH_AUDIO_WARNING": "a szinkron eltérhet",
"SWITCHED_TO": "Átváltva erre: {{playlist}}",
"UNDO": "Visszavonás",
"CHOOSE_OTHER": "Másik választása",
"TRY_ANOTHER_SOURCE": "Másik forrás kipróbálása",
"ERROR_HAS_ALTERNATIVES": "Ez az adatfolyam nem érhető el innen: {{playlist}}. A film a többi lejátszási listájában is megtalálható:",
"RESUME_KEPT": "a lejátszási pozíció megmarad · {{timecode}}",
"ALL_SOURCES_TRIED": "Az összes forrás kipróbálva.",
"ALSO_FOUND_IN_ONE": "— megtalálható még egy másik lejátszási listában",
"ALSO_FOUND_IN_OTHER": "— megtalálható még {{count}} másik lejátszási listában",
"CHECK_ALL": "összes ellenőrzése",
"FILTER_PLACEHOLDER": "Szűrés lejátszási lista alapján",
"NO_MATCHES": "Nincs a szűrőnek megfelelő lejátszási lista",
"COPIES": "{{count}} másolat ebben a listában",
"MORE_SOURCES": "+{{count}} további a forráslistában"
}
},
"INFORMATION": "Információk",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app.",
"VOD_AUTO_FAILOVER": "Cambia sorgente automaticamente in caso di errore di riproduzione",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando la riproduzione di un film non riesce e lo stesso film è presente in un'altra playlist, l'app passa automaticamente a quella sorgente mantenendo la posizione di riproduzione. Disattivo per impostazione predefinita; il cambio viene sempre segnalato perché un'altra sorgente può avere un doppiaggio diverso."
},
"THEMES": {
"DARK_THEME": "Scuro",
@@ -1001,7 +1003,45 @@
"ITEM": "elemento",
"ITEMS": "elementi",
"NO_EPISODES": "Nessun episodio disponibile",
"ALL_RADIO": "Tutte le radio"
"ALL_RADIO": "Tutte le radio",
"MULTI_SOURCE": {
"SOURCES": "Sorgenti",
"SOURCE": "Sorgente",
"SOURCES_FOR": "Sorgenti per “{{title}}”",
"OPEN_SOURCES": "Mostra sorgenti alternative",
"MATCHED_BY_TMDB": "abbinato tramite TMDB",
"MATCHED_BY_TITLE": "abbinato per titolo",
"MATCH_BY_TITLE": "per titolo",
"PLAYING": "In riproduzione",
"CURRENT": "Attuale",
"PLAY_FROM_SOURCE": "Riproduci da questa sorgente",
"PIN_SOURCE": "Imposta come sorgente principale",
"UNPIN_SOURCE": "Rimuovi come sorgente principale",
"CHECK": "verifica",
"CHECK_TOOLTIP": "Verifica disponibilità",
"PROBE_OK": "disponibile",
"PROBE_FAIL": "non disponibile",
"PROBE_CHECKING": "verifica in corso",
"AUTO_FAILOVER": "Cambio automatico in caso di errore",
"PLAYING_FROM": "In riproduzione da:",
"SWITCH_TITLE": "Sorgente non disponibile — passaggio a {{playlist}}",
"SWITCH_DETAIL": "ripresa da {{timecode}}",
"SWITCH_AUDIO_WARNING": "il doppiaggio potrebbe essere diverso",
"SWITCHED_TO": "Passato a {{playlist}}",
"UNDO": "Annulla",
"CHOOSE_OTHER": "Scegli un'altra sorgente",
"TRY_ANOTHER_SOURCE": "Prova un'altra sorgente",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} non fornisce questo stream. Questo film è presente nelle tue altre playlist:",
"RESUME_KEPT": "la posizione verrà mantenuta · {{timecode}}",
"ALL_SOURCES_TRIED": "Sono state provate tutte le sorgenti.",
"ALSO_FOUND_IN_ONE": "— presente anche in un'altra playlist",
"ALSO_FOUND_IN_OTHER": "— presente anche in altre {{count}} playlist",
"CHECK_ALL": "verifica tutte",
"FILTER_PLACEHOLDER": "Filtra per playlist",
"NO_MATCHES": "Nessuna playlist corrisponde al filtro",
"COPIES": "{{count}} copie in questa playlist",
"MORE_SOURCES": "+{{count}} nell'elenco sorgenti"
}
},
"INFORMATION": "Informazioni",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。"
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。",
"VOD_AUTO_FAILOVER": "再生エラー時にソースを自動切り替え",
"VOD_AUTO_FAILOVER_DESCRIPTION": "映画の再生に失敗したとき、同じ作品が別のプレイリストにもある場合は自動的にそちらへ切り替え、再生位置を引き継ぎます。標準では無効です。別のソースでは吹き替えが異なる場合があるため、切り替え時は必ず通知されます。"
},
"THEMES": {
"DARK_THEME": "ダーク",
@@ -1001,7 +1003,45 @@
"ITEM": "件",
"ITEMS": "件",
"NO_EPISODES": "利用可能なエピソードはありません",
"ALL_RADIO": "すべてのラジオ"
"ALL_RADIO": "すべてのラジオ",
"MULTI_SOURCE": {
"SOURCES": "ソース",
"SOURCE": "ソース",
"SOURCES_FOR": "「{{title}}」のソース",
"OPEN_SOURCES": "他のソースを表示",
"MATCHED_BY_TMDB": "TMDB で一致",
"MATCHED_BY_TITLE": "タイトルで一致",
"MATCH_BY_TITLE": "タイトル一致",
"PLAYING": "再生中",
"CURRENT": "現在",
"PLAY_FROM_SOURCE": "このソースから再生",
"PIN_SOURCE": "メインのソースに設定",
"UNPIN_SOURCE": "メインのソース設定を解除",
"CHECK": "確認",
"CHECK_TOOLTIP": "利用可能か確認",
"PROBE_OK": "利用可能",
"PROBE_FAIL": "利用不可",
"PROBE_CHECKING": "確認中",
"AUTO_FAILOVER": "エラー時に自動切り替え",
"PLAYING_FROM": "再生元:",
"SWITCH_TITLE": "ソースを利用できません — {{playlist}} に切り替えます",
"SWITCH_DETAIL": "{{timecode}} から再開します",
"SWITCH_AUDIO_WARNING": "吹き替えが異なる場合があります",
"SWITCHED_TO": "{{playlist}} に切り替えました",
"UNDO": "元に戻す",
"CHOOSE_OTHER": "別のソースを選択",
"TRY_ANOTHER_SOURCE": "別のソースを試す",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} はこのストリームを配信していません。この映画は他のプレイリストにもあります:",
"RESUME_KEPT": "再生位置は引き継がれます · {{timecode}}",
"ALL_SOURCES_TRIED": "すべてのソースを試しました。",
"ALSO_FOUND_IN_ONE": "— 他のプレイリストにもあります",
"ALSO_FOUND_IN_OTHER": "— 他の {{count}} 件のプレイリストにもあります",
"CHECK_ALL": "すべて確認",
"FILTER_PLACEHOLDER": "プレイリストでフィルター",
"NO_MATCHES": "このフィルターに一致するプレイリストはありません",
"COPIES": "このプレイリスト内に{{count}}件",
"MORE_SOURCES": "他 {{count}} 件はソース一覧から"
}
},
"INFORMATION": "情報",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다.",
"VOD_AUTO_FAILOVER": "재생 오류 시 소스 자동 전환",
"VOD_AUTO_FAILOVER_DESCRIPTION": "영화 재생에 실패했을 때 같은 작품이 다른 재생목록에 있으면 재생 위치를 유지한 채 자동으로 전환합니다. 기본값은 꺼짐이며, 다른 소스는 더빙이 다를 수 있으므로 전환 시 항상 알려 드립니다."
},
"THEMES": {
"DARK_THEME": "어둡게",
@@ -1001,7 +1003,45 @@
"ITEM": "개",
"ITEMS": "개",
"NO_EPISODES": "사용 가능한 에피소드가 없습니다",
"ALL_RADIO": "모든 라디오"
"ALL_RADIO": "모든 라디오",
"MULTI_SOURCE": {
"SOURCES": "소스",
"SOURCE": "소스",
"SOURCES_FOR": "“{{title}}”의 소스",
"OPEN_SOURCES": "대체 소스 표시",
"MATCHED_BY_TMDB": "TMDB로 매칭됨",
"MATCHED_BY_TITLE": "제목으로 매칭됨",
"MATCH_BY_TITLE": "제목 기준",
"PLAYING": "재생 중",
"CURRENT": "현재",
"PLAY_FROM_SOURCE": "이 소스에서 재생",
"PIN_SOURCE": "기본 소스로 지정",
"UNPIN_SOURCE": "기본 소스 지정 해제",
"CHECK": "확인",
"CHECK_TOOLTIP": "사용 가능 여부 확인",
"PROBE_OK": "사용 가능",
"PROBE_FAIL": "사용 불가",
"PROBE_CHECKING": "확인 중",
"AUTO_FAILOVER": "오류 시 자동 전환",
"PLAYING_FROM": "재생 중인 소스:",
"SWITCH_TITLE": "소스를 사용할 수 없습니다 — {{playlist}}(으)로 전환합니다",
"SWITCH_DETAIL": "{{timecode}}부터 이어서 재생합니다",
"SWITCH_AUDIO_WARNING": "더빙이 다를 수 있습니다",
"SWITCHED_TO": "{{playlist}}(으)로 전환했습니다",
"UNDO": "실행 취소",
"CHOOSE_OTHER": "다른 소스 선택",
"TRY_ANOTHER_SOURCE": "다른 소스로 시도",
"ERROR_HAS_ALTERNATIVES": "{{playlist}}에서 이 스트림을 제공하지 않습니다. 이 영화는 다른 재생목록에도 있습니다:",
"RESUME_KEPT": "재생 위치가 유지됩니다 · {{timecode}}",
"ALL_SOURCES_TRIED": "모든 소스를 시도했습니다.",
"ALSO_FOUND_IN_ONE": "— 다른 재생목록 1개에도 있음",
"ALSO_FOUND_IN_OTHER": "— 다른 재생목록 {{count}}개에도 있음",
"CHECK_ALL": "모두 확인",
"FILTER_PLACEHOLDER": "재생목록으로 필터",
"NO_MATCHES": "이 필터에 해당하는 재생목록이 없습니다",
"COPIES": "이 재생목록에 소스 {{count}}개",
"MORE_SOURCES": "소스 목록에 {{count}}개 더 있음"
}
},
"INFORMATION": "정보",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app.",
"VOD_AUTO_FAILOVER": "Automatisch van bron wisselen bij een afspeelfout",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Als een film niet kan worden afgespeeld en dezelfde film in een andere afspeellijst staat, wordt daar automatisch naar overgeschakeld met behoud van de afspeelpositie. Standaard uitgeschakeld; de wissel wordt altijd gemeld, omdat een andere bron een andere nasynchronisatie kan hebben."
},
"THEMES": {
"DARK_THEME": "Donker",
@@ -1001,7 +1003,45 @@
"ITEM": "item",
"ITEMS": "items",
"NO_EPISODES": "Geen afleveringen beschikbaar",
"ALL_RADIO": "Alle radio"
"ALL_RADIO": "Alle radio",
"MULTI_SOURCE": {
"SOURCES": "Bronnen",
"SOURCE": "Bron",
"SOURCES_FOR": "Bronnen voor “{{title}}”",
"OPEN_SOURCES": "Alternatieve bronnen tonen",
"MATCHED_BY_TMDB": "gekoppeld via TMDB",
"MATCHED_BY_TITLE": "gekoppeld op titel",
"MATCH_BY_TITLE": "op titel",
"PLAYING": "Speelt nu",
"CURRENT": "Huidige",
"PLAY_FROM_SOURCE": "Afspelen vanaf deze bron",
"PIN_SOURCE": "Instellen als hoofdbron",
"UNPIN_SOURCE": "Niet meer als hoofdbron gebruiken",
"CHECK": "controleren",
"CHECK_TOOLTIP": "Beschikbaarheid controleren",
"PROBE_OK": "beschikbaar",
"PROBE_FAIL": "niet beschikbaar",
"PROBE_CHECKING": "wordt gecontroleerd",
"AUTO_FAILOVER": "Automatisch wisselen bij fout",
"PLAYING_FROM": "Speelt af vanaf:",
"SWITCH_TITLE": "Bron niet beschikbaar — overschakelen naar {{playlist}}",
"SWITCH_DETAIL": "wordt hervat op {{timecode}}",
"SWITCH_AUDIO_WARNING": "de nasynchronisatie kan afwijken",
"SWITCHED_TO": "Overgeschakeld naar {{playlist}}",
"UNDO": "Ongedaan maken",
"CHOOSE_OTHER": "Andere bron kiezen",
"TRY_ANOTHER_SOURCE": "Andere bron proberen",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} levert deze stream niet. Deze film staat wel in je andere afspeellijsten:",
"RESUME_KEPT": "afspeelpositie blijft behouden · {{timecode}}",
"ALL_SOURCES_TRIED": "Alle bronnen zijn geprobeerd.",
"ALSO_FOUND_IN_ONE": "— ook gevonden in één andere afspeellijst",
"ALSO_FOUND_IN_OTHER": "— ook gevonden in {{count}} andere afspeellijsten",
"CHECK_ALL": "alles controleren",
"FILTER_PLACEHOLDER": "Filter op afspeellijst",
"NO_MATCHES": "Geen afspeellijsten komen overeen met dit filter",
"COPIES": "{{count}} kopieën in deze afspeellijst",
"MORE_SOURCES": "+{{count}} meer in de bronnenlijst"
}
},
"INFORMATION": "Informatie",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji.",
"VOD_AUTO_FAILOVER": "Automatyczna zmiana źródła przy błędzie odtwarzania",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Gdy filmu nie da się odtworzyć, a ten sam tytuł jest dostępny na innej liście odtwarzania, przełącz się na nią automatycznie i zachowaj pozycję odtwarzania. Domyślnie wyłączone; zmiana jest zawsze sygnalizowana, ponieważ inne źródło może mieć inną wersję językową."
},
"THEMES": {
"DARK_THEME": "Ciemny",
@@ -1001,7 +1003,45 @@
"ITEM": "pozycja",
"ITEMS": "pozycji",
"NO_EPISODES": "Brak dostępnych odcinków",
"ALL_RADIO": "Wszystkie radia"
"ALL_RADIO": "Wszystkie radia",
"MULTI_SOURCE": {
"SOURCES": "Źródła",
"SOURCE": "Źródło",
"SOURCES_FOR": "Źródła dla „{{title}}”",
"OPEN_SOURCES": "Pokaż alternatywne źródła",
"MATCHED_BY_TMDB": "dopasowano na podstawie TMDB",
"MATCHED_BY_TITLE": "dopasowano na podstawie tytułu",
"MATCH_BY_TITLE": "wg tytułu",
"PLAYING": "Odtwarzane",
"CURRENT": "Bieżące",
"PLAY_FROM_SOURCE": "Odtwórz z tego źródła",
"PIN_SOURCE": "Ustaw jako główne źródło",
"UNPIN_SOURCE": "Przestań używać jako główne źródło",
"CHECK": "sprawdź",
"CHECK_TOOLTIP": "Sprawdź dostępność",
"PROBE_OK": "dostępne",
"PROBE_FAIL": "niedostępne",
"PROBE_CHECKING": "sprawdzanie",
"AUTO_FAILOVER": "Automatyczna zmiana przy błędzie",
"PLAYING_FROM": "Odtwarzanie z:",
"SWITCH_TITLE": "Źródło niedostępne — przełączanie na {{playlist}}",
"SWITCH_DETAIL": "wznawianie od {{timecode}}",
"SWITCH_AUDIO_WARNING": "wersja językowa może się różnić",
"SWITCHED_TO": "Przełączono na {{playlist}}",
"UNDO": "Cofnij",
"CHOOSE_OTHER": "Wybierz inne",
"TRY_ANOTHER_SOURCE": "Spróbuj innego źródła",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} nie udostępnia tego strumienia. Ten film jest na Twoich innych listach odtwarzania:",
"RESUME_KEPT": "pozycja odtwarzania zostanie zachowana · {{timecode}}",
"ALL_SOURCES_TRIED": "Wypróbowano już wszystkie źródła.",
"ALSO_FOUND_IN_ONE": "— znaleziono także na jednej innej liście odtwarzania",
"ALSO_FOUND_IN_OTHER": "— znaleziono także na {{count}} innych listach odtwarzania",
"CHECK_ALL": "sprawdź wszystkie",
"FILTER_PLACEHOLDER": "Filtruj według listy odtwarzania",
"NO_MATCHES": "Nie znaleziono pasujących list odtwarzania",
"COPIES": "{{count}} kopii na tej liście",
"MORE_SOURCES": "+{{count}} na liście źródeł"
}
},
"INFORMATION": "Informacje",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação.",
"VOD_AUTO_FAILOVER": "Trocar de fonte automaticamente ao falhar a reprodução",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando um filme não consegue ser reproduzido e o mesmo filme existe em outra playlist, troca para ela automaticamente e mantém a posição de reprodução. Desativado por padrão; a troca é sempre avisada, porque outra fonte pode ter uma dublagem diferente."
},
"THEMES": {
"DARK_THEME": "Escuro",
@@ -1001,7 +1003,45 @@
"ITEM": "item",
"ITEMS": "itens",
"NO_EPISODES": "Nenhum episódio disponível",
"ALL_RADIO": "Todas as rádios"
"ALL_RADIO": "Todas as rádios",
"MULTI_SOURCE": {
"SOURCES": "Fontes",
"SOURCE": "Fonte",
"SOURCES_FOR": "Fontes para “{{title}}”",
"OPEN_SOURCES": "Mostrar fontes alternativas",
"MATCHED_BY_TMDB": "correspondência por TMDB",
"MATCHED_BY_TITLE": "correspondência por título",
"MATCH_BY_TITLE": "por título",
"PLAYING": "Reproduzindo",
"CURRENT": "Atual",
"PLAY_FROM_SOURCE": "Reproduzir desta fonte",
"PIN_SOURCE": "Definir como fonte principal",
"UNPIN_SOURCE": "Remover como fonte principal",
"CHECK": "verificar",
"CHECK_TOOLTIP": "Verificar disponibilidade",
"PROBE_OK": "disponível",
"PROBE_FAIL": "indisponível",
"PROBE_CHECKING": "verificando",
"AUTO_FAILOVER": "Troca automática em caso de erro",
"PLAYING_FROM": "Reproduzindo de:",
"SWITCH_TITLE": "Fonte indisponível — trocando para {{playlist}}",
"SWITCH_DETAIL": "retomando em {{timecode}}",
"SWITCH_AUDIO_WARNING": "a dublagem pode ser diferente",
"SWITCHED_TO": "Trocado para {{playlist}}",
"UNDO": "Desfazer",
"CHOOSE_OTHER": "Escolher outra",
"TRY_ANOTHER_SOURCE": "Tentar outra fonte",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} não está fornecendo este stream. Este filme está nas suas outras playlists:",
"RESUME_KEPT": "a posição será mantida · {{timecode}}",
"ALL_SOURCES_TRIED": "Todas as fontes já foram testadas.",
"ALSO_FOUND_IN_ONE": "— também encontrado em outra playlist",
"ALSO_FOUND_IN_OTHER": "— também encontrado em outras {{count}} playlists",
"CHECK_ALL": "verificar todas",
"FILTER_PLACEHOLDER": "Filtrar por playlist",
"NO_MATCHES": "Nenhuma playlist corresponde a este filtro",
"COPIES": "{{count}} cópias nesta playlist",
"MORE_SOURCES": "+{{count}} na lista de fontes"
}
},
"INFORMATION": "Informação",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "Что нового",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения.",
"VOD_AUTO_FAILOVER": "Авто-переключение источника при ошибке воспроизведения",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Если фильм не воспроизводится, а тот же фильм есть в другом плейлисте, автоматически переключаться на него, сохраняя таймкод. По умолчанию выключено; о переключении всегда сообщается, так как в другом источнике может быть другая озвучка."
},
"THEMES": {
"DARK_THEME": "Тёмная",
@@ -1001,7 +1003,45 @@
"ITEM": "элемент",
"ITEMS": "элементов",
"NO_EPISODES": "Нет доступных эпизодов",
"ALL_RADIO": "Все радио"
"ALL_RADIO": "Все радио",
"MULTI_SOURCE": {
"SOURCES": "Источники",
"SOURCE": "Источник",
"SOURCES_FOR": "Источники для «{{title}}»",
"OPEN_SOURCES": "Показать альтернативные источники",
"MATCHED_BY_TMDB": "сопоставлено по TMDB",
"MATCHED_BY_TITLE": "сопоставлено по названию",
"MATCH_BY_TITLE": "по названию",
"PLAYING": "Играет",
"CURRENT": "Текущий",
"PLAY_FROM_SOURCE": "Воспроизвести из этого источника",
"PIN_SOURCE": "Сделать основным источником",
"UNPIN_SOURCE": "Отменить основной источник",
"CHECK": "проверить",
"CHECK_TOOLTIP": "Проверить доступность",
"PROBE_OK": "доступен",
"PROBE_FAIL": "недоступен",
"PROBE_CHECKING": "проверка",
"AUTO_FAILOVER": "Авто-переключение при ошибке",
"PLAYING_FROM": "Играет с:",
"SWITCH_TITLE": "Источник недоступен — переключаемся на {{playlist}}",
"SWITCH_DETAIL": "продолжаем с {{timecode}}",
"SWITCH_AUDIO_WARNING": "озвучка может отличаться",
"SWITCHED_TO": "Переключено на {{playlist}}",
"UNDO": "Отменить",
"CHOOSE_OTHER": "Выбрать другой",
"TRY_ANOTHER_SOURCE": "Попробовать другой источник",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} не отдаёт этот поток. Этот фильм есть в других ваших плейлистах:",
"RESUME_KEPT": "таймкод сохранится · {{timecode}}",
"ALL_SOURCES_TRIED": "Испробованы все источники.",
"ALSO_FOUND_IN_ONE": "— найден ещё в 1 плейлисте",
"ALSO_FOUND_IN_OTHER": "— найден ещё в {{count}} плейлистах",
"CHECK_ALL": "проверить всё",
"FILTER_PLACEHOLDER": "Фильтровать по плейлисту",
"NO_MATCHES": "Ни один плейлист не соответствует фильтру",
"COPIES": "Копий в этом плейлисте: {{count}}",
"MORE_SOURCES": "+{{count}} в списке источников"
}
},
"INFORMATION": "Инфо",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur.",
"VOD_AUTO_FAILOVER": "Oynatma hatasında kaynağı otomatik değiştir",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Bir film oynatılamadığında ve aynı film başka bir oynatma listesinde de varsa, otomatik olarak o listeye geçilir ve oynatma konumu korunur. Varsayılan olarak kapalıdır; başka bir kaynakta dublaj farklı olabileceği için geçiş her zaman bildirilir."
},
"THEMES": {
"DARK_THEME": "Koyu",
@@ -1001,7 +1003,45 @@
"ITEM": "öğe",
"ITEMS": "öğe",
"NO_EPISODES": "Bölüm mevcut değil",
"ALL_RADIO": "Tüm radyolar"
"ALL_RADIO": "Tüm radyolar",
"MULTI_SOURCE": {
"SOURCES": "Kaynaklar",
"SOURCE": "Kaynak",
"SOURCES_FOR": "“{{title}}” için kaynaklar",
"OPEN_SOURCES": "Alternatif kaynakları göster",
"MATCHED_BY_TMDB": "TMDB ile eşleştirildi",
"MATCHED_BY_TITLE": "Başlığa göre eşleştirildi",
"MATCH_BY_TITLE": "başlığa göre",
"PLAYING": "Oynatılıyor",
"CURRENT": "Geçerli",
"PLAY_FROM_SOURCE": "Bu kaynaktan oynat",
"PIN_SOURCE": "Bunu ana kaynak yap",
"UNPIN_SOURCE": "Bu kaynağın sabitlemesini kaldır",
"CHECK": "kontrol et",
"CHECK_TOOLTIP": "Kullanılabilirliği kontrol et",
"PROBE_OK": "kullanılabilir",
"PROBE_FAIL": "kullanılamıyor",
"PROBE_CHECKING": "kontrol ediliyor",
"AUTO_FAILOVER": "Hatada otomatik geçiş",
"PLAYING_FROM": "Şuradan oynatılıyor:",
"SWITCH_TITLE": "Kaynak kullanılamıyor — {{playlist}} kaynağına geçiliyor",
"SWITCH_DETAIL": "{{timecode}} konumundan devam ediliyor",
"SWITCH_AUDIO_WARNING": "dublaj farklı olabilir",
"SWITCHED_TO": "{{playlist}} kaynağına geçildi",
"UNDO": "Geri al",
"CHOOSE_OTHER": "Başkasını seç",
"TRY_ANOTHER_SOURCE": "Başka bir kaynak dene",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} bu yayını sunmuyor. Bu film diğer oynatma listelerinizde de var:",
"RESUME_KEPT": "oynatma konumu korunacak · {{timecode}}",
"ALL_SOURCES_TRIED": "Tüm kaynaklar denendi.",
"ALSO_FOUND_IN_ONE": "— bir oynatma listesinde daha bulundu",
"ALSO_FOUND_IN_OTHER": "— {{count}} oynatma listesinde daha bulundu",
"CHECK_ALL": "tümünü kontrol et",
"FILTER_PLACEHOLDER": "Oynatma listesine göre filtrele",
"NO_MATCHES": "Bu filtreyle eşleşen oynatma listesi yok",
"COPIES": "Bu oynatma listesinde {{count}} kopya",
"MORE_SOURCES": "Kaynak listesinde +{{count}} daha"
}
},
"INFORMATION": "Bilgi",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。"
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。",
"VOD_AUTO_FAILOVER": "播放出错时自动切换播放源",
"VOD_AUTO_FAILOVER_DESCRIPTION": "当某部电影无法播放,而其他播放列表中也有同一部影片时,自动切换过去并保留播放进度。默认关闭;由于其他播放源的配音可能不同,每次切换都会给出提示。"
},
"THEMES": {
"DARK_THEME": "深色",
@@ -1001,7 +1003,45 @@
"ITEM": "项",
"ITEMS": "项",
"NO_EPISODES": "没有可用的剧集",
"ALL_RADIO": "所有广播"
"ALL_RADIO": "所有广播",
"MULTI_SOURCE": {
"SOURCES": "播放源",
"SOURCE": "播放源",
"SOURCES_FOR": "“{{title}}”的播放源",
"OPEN_SOURCES": "显示其他播放源",
"MATCHED_BY_TMDB": "通过 TMDB 匹配",
"MATCHED_BY_TITLE": "通过标题匹配",
"MATCH_BY_TITLE": "标题匹配",
"PLAYING": "正在播放",
"CURRENT": "当前",
"PLAY_FROM_SOURCE": "从此播放源播放",
"PIN_SOURCE": "设为主播放源",
"UNPIN_SOURCE": "取消主播放源",
"CHECK": "检测",
"CHECK_TOOLTIP": "检测可用性",
"PROBE_OK": "可用",
"PROBE_FAIL": "不可用",
"PROBE_CHECKING": "检测中",
"AUTO_FAILOVER": "出错时自动切换",
"PLAYING_FROM": "播放来源:",
"SWITCH_TITLE": "播放源不可用 — 正在切换到 {{playlist}}",
"SWITCH_DETAIL": "从 {{timecode}} 继续播放",
"SWITCH_AUDIO_WARNING": "配音可能不同",
"SWITCHED_TO": "已切换到 {{playlist}}",
"UNDO": "撤销",
"CHOOSE_OTHER": "选择其他",
"TRY_ANOTHER_SOURCE": "尝试其他播放源",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} 未提供此视频流。这部电影也在您的其他播放列表中:",
"RESUME_KEPT": "将保留播放进度 · {{timecode}}",
"ALL_SOURCES_TRIED": "所有播放源均已尝试过。",
"ALSO_FOUND_IN_ONE": "— 另有 1 个播放列表提供此影片",
"ALSO_FOUND_IN_OTHER": "— 另有 {{count}} 个播放列表提供此影片",
"CHECK_ALL": "全部检测",
"FILTER_PLACEHOLDER": "按播放列表筛选",
"NO_MATCHES": "未找到匹配的播放列表",
"COPIES": "此播放列表中有 {{count}} 个版本",
"MORE_SOURCES": "其余 {{count}} 个在播放源列表中"
}
},
"INFORMATION": "信息",
"DOWNLOADS": {
+42 -2
View File
@@ -451,7 +451,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。"
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。",
"VOD_AUTO_FAILOVER": "播放發生錯誤時自動切換來源",
"VOD_AUTO_FAILOVER_DESCRIPTION": "當電影無法播放,且其他播放清單中也有同一部影片時,會自動切換過去並保留播放進度。預設為關閉;由於其他來源可能使用不同的配音,每次切換都會提示您。"
},
"THEMES": {
"DARK_THEME": "深色",
@@ -1001,7 +1003,45 @@
"ITEM": "項",
"ITEMS": "項",
"NO_EPISODES": "沒有可用的集數",
"ALL_RADIO": "所有廣播"
"ALL_RADIO": "所有廣播",
"MULTI_SOURCE": {
"SOURCES": "播放來源",
"SOURCE": "播放來源",
"SOURCES_FOR": "「{{title}}」的播放來源",
"OPEN_SOURCES": "顯示其他播放來源",
"MATCHED_BY_TMDB": "依 TMDB 比對",
"MATCHED_BY_TITLE": "依標題比對",
"MATCH_BY_TITLE": "依標題",
"PLAYING": "播放中",
"CURRENT": "目前",
"PLAY_FROM_SOURCE": "從此來源播放",
"PIN_SOURCE": "設為主要來源",
"UNPIN_SOURCE": "取消設為主要來源",
"CHECK": "檢查",
"CHECK_TOOLTIP": "檢查是否可用",
"PROBE_OK": "可用",
"PROBE_FAIL": "無法使用",
"PROBE_CHECKING": "檢查中",
"AUTO_FAILOVER": "發生錯誤時自動切換",
"PLAYING_FROM": "播放來源:",
"SWITCH_TITLE": "來源無法使用 — 正在切換至 {{playlist}}",
"SWITCH_DETAIL": "從 {{timecode}} 繼續播放",
"SWITCH_AUDIO_WARNING": "配音可能不同",
"SWITCHED_TO": "已切換至 {{playlist}}",
"UNDO": "復原",
"CHOOSE_OTHER": "選擇其他來源",
"TRY_ANOTHER_SOURCE": "嘗試其他播放來源",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} 未提供此串流。您的其他播放清單中有這部電影:",
"RESUME_KEPT": "將保留播放進度 · {{timecode}}",
"ALL_SOURCES_TRIED": "已嘗試所有播放來源。",
"ALSO_FOUND_IN_ONE": "— 另有 1 個播放清單也有此片",
"ALSO_FOUND_IN_OTHER": "— 另有 {{count}} 個播放清單也有此片",
"CHECK_ALL": "全部檢查",
"FILTER_PLACEHOLDER": "依播放清單篩選",
"NO_MATCHES": "沒有符合篩選條件的播放清單",
"COPIES": "此播放清單中的 {{count}} 個版本",
"MORE_SOURCES": "播放來源清單中另有 {{count}} 個"
}
},
"INFORMATION": "資訊",
"DOWNLOADS": {
+6
View File
@@ -115,11 +115,17 @@ media. Barriers and delays are coordination tools, not timing inputs.
| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active |
| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active |
| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active |
| `multisrc1` | `multisrc1` | multi-source portal A | 1 | 2 | 1 | 5 | active |
| `multisrc2` | `multisrc2` | multi-source portal B | 1 | 2 | 1 | 5 | active |
| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired |
| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled |
Any other credential pair is auto-generated using a hash of `username:password` as the faker seed (6 categories, 30 items each, active account).
`multisrc1` and `multisrc2` deliberately share one faker seed, so both portals
generate an identical catalog. That overlap is what the VOD multi-source E2E
needs — the same movie present in two different playlists.
---
## API Endpoints
@@ -125,6 +125,31 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
expiryDate: '2099-12-31',
vodDetailsFixture: 'empty-metadata',
},
// Two distinct credential pairs sharing one seed, so both portals generate
// an IDENTICAL catalog. That overlap is what VOD multi-source discovery
// needs: the same movie present in two different playlists.
'multisrc1:multisrc1': {
name: 'multi-source-a',
description: 'Multi-source portal A — shares its catalog with portal B',
seed: 4242,
categoryCount: { live: 1, vod: 2, series: 1 },
itemsPerCategory: 5,
seasonsPerSeries: 1,
episodesPerSeason: 2,
accountStatus: 'Active',
expiryDate: '2099-12-31',
},
'multisrc2:multisrc2': {
name: 'multi-source-b',
description: 'Multi-source portal B — shares its catalog with portal A',
seed: 4242,
categoryCount: { live: 1, vod: 2, series: 1 },
itemsPerCategory: 5,
seasonsPerSeries: 1,
episodesPerSeason: 2,
accountStatus: 'Active',
expiryDate: '2099-12-31',
},
'marketing:marketing': {
name: 'marketing-demo',
description:
+19 -8
View File
@@ -51,15 +51,26 @@ These tokens are the base for interactive emphasis:
- `--app-selection-border`
- `--app-selection-glow`
Use Material surface tokens for neutral surfaces:
Use the app's own surface tokens for neutral surfaces (defined for both themes
in `apps/web/src/m3-theme.scss`):
- `--mat-sys-surface`
- `--mat-sys-surface-container-low`
- `--mat-sys-surface-container`
- `--mat-sys-surface-container-high`
- `--mat-sys-outline-variant`
- `--mat-sys-on-surface`
- `--mat-sys-on-surface-variant`
- `--app-shell-bg` / `--app-rail-bg` / `--app-header-bg` / `--app-content-bg`
- `--app-widget-bg` / `--app-widget-header-bg` — panels and popovers
- `--app-card-hover-bg` — raised or hovered rows
- `--app-widget-border` / `--app-rail-border` — hairlines
- `--app-on-surface` — primary text
- `--app-eyebrow-color` — secondary/muted text
**Do not reach for `--mat-sys-*` surface tokens.** Angular Material's system
tokens are *not* emitted globally in this app — only `--mat-sys-on-surface`
exists. `var(--mat-sys-surface-container-high)` and friends resolve to nothing,
which usually goes unnoticed because the surrounding Material component
supplies its own default and masks it. Outside a Material component — a CDK
overlay, a projected panel — the same reference silently produces a fully
transparent, unreadable surface.
If a `--mat-sys-*` token is genuinely needed, always give it a real fallback:
`var(--mat-sys-on-primary, #fff)`.
Do not hardcode unrelated accent colors for selected state when these tokens already exist.
@@ -162,12 +162,34 @@ worker IPC boundary in the snake_case wire shape declared by
`XCategoryFromDb`/`XtreamCategoryFromDb`; the category operations project
their Drizzle rows explicitly to keep that contract true.
Clearing the playlist's existing pins goes through a dedicated
delete-by-playlist operation, not the keyed clear: that one caps its key list
to bound an IN clause, so a playlist with more pinned movies than the cap kept
the surplus while still reporting success. A failure now fails the entry
rather than leaving the union of old and archived pins.
`sourcePins` (VOD multi-source) is the one **optional** collection, and the
normalizer preserves that: an absent field stays absent rather than becoming
`[]`, because restore treats a PRESENT collection as authoritative and clears
the playlist's existing pins before applying it. Materializing an empty array
would turn "this archive predates pins" into "this archive says there are
none": archives
written before multi-source existed simply do not have it, so its absence is
age rather than damage and only a wrong type is rejected. A pin is carried
under the playlist it points AT — exporting it anywhere else would restore a
preference for a portal the archive never contained. Its `matchKey` identifies
the film rather than the portal, so it survives untouched; only the playlist id
is remapped to the imported copy. Pins whose match key or content id is
unusable are dropped, since writing one would occupy the unique key of a film
it does not describe.
Electron restore behavior:
1. Category import reads pending hidden-category state while saving categories.
2. After content import, favorites/recent state is restored by typed
`{ contentType, xtreamId }` matching.
3. Playback positions are cleared and re-applied from backup state.
4. VOD source pins are re-applied against the IMPORTED playlist id.
For existing Xtream playlists with a fully populated offline cache, backup
import applies the restore immediately. Otherwise the typed restore payload is
+1
View File
@@ -109,6 +109,7 @@ and JSON summary output. CI uploads the merged Tier A report to Codecov with the
| --------------------- | ----------------------------------------------- |
| Web app browser flows | `pnpm nx run web-e2e:e2e -- --project=chromium` |
| Electron flows | `pnpm nx run electron-backend-e2e:e2e` |
| VOD multi-source | `pnpm nx run electron-backend-e2e:e2e-ci--src/vod-multi-source.e2e.ts` |
Use atomized E2E targets when available, for example
`pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`.
+660
View File
@@ -0,0 +1,660 @@
# VOD Multi-Source
Finds the same movie in the user's other imported playlists, lets them switch
which one it streams from without losing the timecode, and turns the
playback-error screen into a recovery point.
The point is not choice for its own sake — it is rescuing a viewing when the
current source is dead, serves an unsupported codec, or buffers badly.
## Scope (v1)
| | |
|---|---|
| Source types | **Xtream ↔ Xtream only** |
| Content | Movies only — series are not offered a source chip |
| Environment | **Electron only** — the chip renders nothing in the PWA |
| Auto-failover | Opt-in, **off by default** (`Settings.vodAutoFailover`); offered only on the built-in web players |
| Pin scope | Per movie (a global portal priority is out of scope) |
| Stream probe | HEAD → reachable + latency, sent with the playlist's own playback headers. **No codec probing** |
Stalker never reaches the `content` table (it would need a live authenticated
`get_ordered_list&search=` per portal), and M3U playlists are stored as a JSON
blob whose search path forces `content_type: 'live'`. Both are additive later
without changing the contracts — `VodSourceCandidate.portalType` already carries
`'xtream' | 'stalker' | 'm3u'` and discovery sits behind a service interface.
A probe answer is cached per *request*, not per URL: two playlists can share a
stream URL and require different headers, and one of them answering 403 says
nothing about the other.
`ffprobe`/`ffmpeg` are not dependencies of this app and are not bundled, so
`provenance: 'probe'` means reachability and latency only. There is deliberately
no feature flag for codec probing: it would gate a code path with no binary
behind it.
## The honesty rule
This is the part to preserve if anything here is refactored.
Every metadata value carries **where it came from**:
| provenance | produced by | rendered as |
|---|---|---|
| `api` | `get_vod_info` — container, codec, audio, dimensions | plain tag |
| `parsed` | regex over the title/filename | tag prefixed `~`, warn colour |
| `probe` | HEAD → reachable + latency (retried as a ranged GET when the server answers 405/501, since plenty serve media over GET while refusing HEAD) | `ok` / `fail` status tag |
| *absent* | — | **no tag at all** + a `check` chip |
Three rules follow, and each is enforced in code rather than by convention:
1. **A guess never ranks.** `factualOnly()`
(`libs/portal/shared/data-access/.../vod-source-metadata.util.ts`) is the only
sanctioned accessor for ranking and failover decisions, and `parsed` values
are structurally unreachable through it. A filename claiming 4K cannot
outrank a source that was actually reached.
2. **Unknown is not "unavailable".** `VodSourceProbeStatus` separates `fail`
(contacted and refused) from `unknown` (timed out, blocked by the redirect
policy, or no probe capability). A probe returning HTTP status `0` maps to
`unknown`; the UI keeps offering a check and never shows the source as dead.
3. **Empty beats wrong.** Quality is derived from pixel *width*, because
letterboxed masters are cropped vertically — a 2.39:1 1080p film is 1920×800,
and 800 alone is indistinguishable from a 1280×800 encode. With no width, a
height is trusted only within 5% of a standard frame height; otherwise no tag
is emitted. Below the HD widths the ranges stop working: 960×540 and 1024×576
are two formats inside one 900–1199 range, 800×600 and 800×450 are neither
480p nor each other, and 720 wide is NTSC 480p or PAL 576p depending only on
the height. Those formats are therefore matched rather than bucketed, and an
unrecognised shape returns nothing rather than a label that would be
published as an `api` fact.
A known height has to be consistent with the width **on every tier**,
ranges included. Cropping only ever *removes* lines, so a shorter frame is a
letterboxed master of that format, while a taller one is a different shape:
640×480 against 640×360 below, and 1440×1080 (anamorphic 1080) or 1600×900
against the 720p range above. All of those get no tag. Bucketing HD widths is
otherwise sound — the standard widths really are far apart — but only once
the height is allowed to veto the answer.
Provenance is per-field and changes over time: at discovery a row has only
`parsed` tags, because the `content` table stores no container, codec or audio.
Facts arrive when the source is resolved.
## Architecture
```
VOD details page (Xtream)
└── VodMultiSourceHostService component-provided, one per open movie
├── VodMultiSourceController session state + failover ranking
├── VodSourceDiscoveryService ──► DB_FIND_TITLE_SOURCES (trigram FTS)
├── VodSourceResolverService ──► foreign playlist creds → get_vod_info
│ → constructVodUrl
├── VodSourcePinService ──► DB_GET/SET/CLEAR_VOD_SOURCE_PIN
└── StreamProbeService ──► STREAM_PROBE_URL (main process)
```
`VodMultiSourceHostService` is **component-provided, never root-provided**: the
controller's "each source is tried at most once" set must die with the movie, or
a later film inherits a poisoned failover history.
`VodMultiSourceController` is a plain class, not a service, for the same reason —
and so its ranking logic is testable without TestBed.
### Ownership
| Concern | Location |
|---|---|
| DTOs, match key | `libs/shared/interfaces/src/lib/vod-source*.ts` |
| Pin table | `libs/shared/database/src/lib/vod-source-pins.schema.ts` |
| Discovery SQL | `apps/electron-backend/.../operations/title-sources.operations.ts` |
| Pin CRUD | `apps/electron-backend/.../operations/vod-source-pin.operations.ts` |
| Probe handler | `apps/electron-backend/src/app/events/stream-probe.ts` |
| Discovery / resolve / rank | `libs/portal/shared/data-access/src/lib/multi-source/` |
| Probe + pin clients | `libs/services/src/lib/{stream-probe,vod-source-pin}.service.ts` |
| Row / popover / chip | `libs/ui/components/src/lib/vod-sources/` |
| Page wiring | `libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-*.ts` |
### One picker, two places, two counts
The chip on the action row and the chip in the inline player's now-playing bar
are the same component, so both are handed the same `matchKind` and the same
`vodAutoFailover` value and both write the setting back. A copy that rendered
the toggle off while it was on — and did nothing when flipped — would be worse
than not offering it.
The two numbers around it are deliberately different, because their sentences
are: the chip says "Sources N" and counts alternative **streams**, while the
caption says "also found in N other playlists" and counts distinct
**playlists** (`alternativePlaylistCount`). The popover groups a portal's three
copies of a film under that one portal, so counting rows there would contradict
the list the caption invites the user to open.
## Identity
Provider ids cannot key a pin — the same film has a different `stream_id` in
every portal, which is the problem being solved.
`buildVodSourceMatchKey()` produces `tmdb:{id}` when a usable TMDB id exists,
otherwise `title:{normalizedBase}:{year}` via the shared `normalizeTitleKeys`.
Enrichment adds BOTH identifying fields late, so the same movie can already be
pinned under any poorer form of itself:
| pinned when | stored under |
|---|---|
| after enrichment | `tmdb:{id}` |
| before the TMDB id | `title:{base}:{year}` |
| before the year too | `title:{base}:` |
`buildVodSourceMatchKeyCandidates` returns all three, most-trusted first.
Reading every alias is what keeps a late TMDB id from orphaning an earlier pin.
Three key sets, because reading, writing and deleting are different questions
(`pinKeysFor` builds all three so they cannot disagree):
| set | contents | why |
|---|---|---|
| `lookup` | every alias above | a pin may sit under any poorer form of the movie |
| `write` | `tmdb:` + `title:{base}:{year}` | keys that name exactly ONE film |
| `loaded` | the key the pin on screen came from | the only ambiguous row this session may retire |
A write stores the decision under **every** key in `write`, and clears whatever
stale row is left over. Each half rules out the other's shortcut:
- Writing only the top key leaves the movie unfindable under its own poorer
identity. A pin set while `tmdb:438631` was known is invisible to the next
reopen, which starts out with nothing but a title and a year and asks for
`title:dune:2021` — so the preference is ignored until enrichment lands, and
permanently if enrichment is off or never answers. Storing it under both keys
makes it readable at every stage of the same film's identity, and overwriting
the poorer key is also what stops it from still naming the source the user
just replaced.
- Spreading it across *every* alias is not the fix either: the yearless form is
shared by every remake, so a known-year pin stored there would answer for a
different film — pin Dune (2021), open Dune (1984) before its year arrives,
and it starts the 2021 source. That form is deliberately absent from `write`
for exactly this reason; it stays readable and unwritten.
The renderer passes `write[0]` as the pin's own `matchKey` and the rest as
`aliasKeys`; `setVodSourcePin` upserts one row per key and retires the leftovers
inside the same transaction, so no key list can half-apply.
Known limit, inherent to addressing rows by key alone: a write can only touch
keys the renderer can *name*. Re-pin a film during a pre-enrichment window and
the `tmdb:{id}` row from an earlier, enriched session is not among them, so it
survives pointing at the old source — and outranks the title key once the id
arrives again. Nothing can enumerate it from the page's side; closing it needs a
reverse index from film to key. The window is small in practice (TMDB responses
are cached in `tmdb_metadata`, so a revisit usually resolves the id at once) and
does not exist at all with enrichment off.
A write stores the new key **before** retiring the old rows. The other order
destroys the stored preference and can then fail to replace it, leaving nothing
persisted while the row still shows the old pin; lookups are most-trusted-first,
so a leftover alias never outranks the key just written.
The pin a rediscovery reads is applied **immediately**, not after its source
lookup returns: holding that snapshot across the await lets it overwrite a pin
the user makes in the meantime, leaving the row and the primary Play naming a
source the database no longer holds. Applying it first makes the later write
simply win.
For the same reason a write or an unpin never *deletes* the yearless alias on
spec: that row may hold another remake's preference. The single exception is
the row this session actually read, because the user is acting on the pin they
can see — and leaving that one would make an unpin come back.
The row only changes once the write lands. A pin the database refused is worse
than no pin at all — the icon promises the preference will be there next time,
and it will not be — so `togglePinnedSource` reports "nothing happened" and the
controller is left exactly as it was.
### Rediscovery vs. a new session
Two keys, deliberately, because the host has two different questions to answer
when enrichment lands:
- `vodMultiSourceMovieKey` covers title, year and TMDB id. Enrichment changing
any of them **re-runs discovery** — that is how a yearless search gets its
year and a `tmdb:`-keyed pin becomes findable.
- `vodMultiSourceSessionKey` is `playlistId:contentId` — the film itself. It is
what decides whether that rerun is a *refresh* or a *new session*.
A refresh keeps the controller: the source the user switched to stays active
(with the facts its resolve produced, rather than the catalog's guesses), the
tried set stays burned, the live position stays, and a switch already in flight
still commits. Rebuilding there would take the film off the source it is
actually streaming, and hand failover a clean slate for sources it has already
spent. Only a different film resets — including the tried set, which is what
makes failover terminate.
A rerun can also legitimately *drop* the playing row: the year the enrichment
supplies makes the year gate reject a copy the yearless search had admitted
("Dune" 1984 while watching the 2021 film). Off the list is right — it is not
the same film. Off the screen is not, so `applyDiscoveredSources` keeps it as a
row and leaves it active; a caption naming a playlist that is not streaming
anything would be a lie about the one thing this feature exists to state.
## What the queries are allowed to miss
A source that exists but is never read is indistinguishable, to the user, from
one that does not exist — the chip simply does not appear. So:
- **The current playlist is excluded in SQL**, not afterwards. It routinely
lists a film in several categories, and those rows would otherwise spend the
FTS window before a single other playlist was read.
- **Duplicates collapse in SQL too**, for the same reason. One playlist can
list a film in dozens of categories, and those rows rank identically, so
`GROUP BY cat.playlist_id, c.xtream_id` runs before the limit. Collapsing
them only in TypeScript afterwards cannot recover the playlists the window
never reached.
- **Short titles scan on a word boundary, and take no window at all.** The
trigram tokenizer cannot index tokens under three characters, so "Up", "It"
or "Us" produce an empty `MATCH` and fall back to a scan. A substring scan
matches "Titanic" and "The Italian Job" for "It", so the scan asks for the
token as a word
(`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) and orders by
title length (a match is the title plus decoration: "IT (2017) 1080p").
The FTS path keeps its 60-row window because it ranks by relevance — the best
rows are the ones it keeps. A scan cannot rank, so a window there would
silently decide which valid sources the user may see, and it would not even
buy anything: the GLOB cannot use an index, so SQLite reads every row either
way and a `LIMIT` saves transfer, not work. What bounds the scan instead is
its predicate: reaching it means NO token cleared the trigram minimum — a
one-or-two-character title like "It", but also an all-short multiword one
like "I Am" — and EVERY token must then appear as a word. Matching on the
first token alone would return most of a large catalog for the confirmation
pass to discard, which on the single database worker is real blocking.
Like the `LIKE` it replaces it compares ASCII-lowercased text, so a non-ASCII
short title is no better and no worse served than before.
Both remain necessary-not-sufficient filters: the two-tier normalized
confirmation still runs afterwards, so the looser query never admits "Upgrade"
for "Up".
One row inside the excluded playlist is kept when the caller names it
(`keepContentId`): a pin can point at another copy of the film in the playlist
being viewed, and dropping that row would leave the preference pointing at
nothing. The host therefore reads the pin BEFORE discovery. When that pin is
the route's *own* row, the kept copy and `currentSourceRow()` are the same
stream, so `applyDiscoveredSources` drops the duplicate rather than listing it
twice.
### Same title, different film
The year gate applies to **both** match tiers, not just the year-stripped one.
`normalizeTitleKeys` strips bracketed segments wholesale — they usually hold
quality and language tags — so "Dune (1984)" and "Dune" normalize to the same
string and the exact tier would accept the remake without ever consulting a
year, ranked *above* every fuzzy match. Discovery reads a bracketed year out of
the raw title first, and when both sides state a year and they disagree, it is
not the same movie. An unknown year still never blocks.
The two tiers read different years, though. The **base** tier accepts either
form, bracketed or trailing, because it has just stripped a trailing year and
that year is the only thing standing between "Dune 1984" and "Dune 2021". The
**exact** tier reads the bracketed form ONLY: reaching it means both titles are
the same string, so a trailing four digits belong to both, and weighing a number
that is part of the NAME against a release year out of metadata rejects the very
copy it was meant to confirm — "Blade Runner 2049" against a stated 2017
disappears the moment enrichment lands. Brackets are never part of a name, so
"Dune (1984)" is still rejected against 2021 on the exact tier.
Which is why the movie's OWN year is read with `releaseTagYear`, not
`extractYear`. The latter takes a year from anywhere in the title, which is the
right answer where a year is only a search hint that scoring will confirm — and
the wrong one here, where the year is part of an identity. "2001: A Space
Odyssey" is not a 2001 film: calling it one makes every genuine 1968 copy fail
the gate above, so the movie has no alternatives at all, and it moves the pin
key the moment enrichment supplies the real year. Only bracketed and trailing
forms count. The trailing form stays ambiguous on purpose ("Blade Runner 2049"
is a title, not a tag) — that is what the two-tier exact/base match is for.
## Why resolution is lazy
The `content` table stores no `container_extension`, and
`XtreamUrlService.constructVodUrl` returns `''` without one. A discovered source
is therefore **not playable from the database** — every alternative costs one
live `get_vod_info` against a foreign playlist's credentials, which can fail
offline, on an expired account, or behind Cloudflare.
So: rows render instantly from the FTS match with `parsed` provenance, and a URL
is resolved only when the user clicks play, pins, or checks. Containers are
memoised per `(playlistId, streamId)` for the session, and a container learned
earlier is reused if the portal later goes unreachable. Resolution is never
fanned out on page load.
## Switching without losing the timecode
Every playback host renders `@if (inlinePlayback(); as playback)`, so re-`set()`ing
that signal with a new `{streamUrl, startTime}` swaps the source **in place** —
the player component, `WebPlayerView` and the engine all survive, and
`WebPlayerViewComponent`'s effect rebuilds the source and clears the diagnostic.
Three details make the position survive:
- The carried position is the **live** one. `handleInlineTimeUpdate` reports to
`VodMultiSourceHostService.reportPosition()` *before* the 15-second
persistence throttle, so a switch does not rewind by up to 15 seconds.
External players have no `timeupdate` at all, so for them the polled
`playback_positions` value IS the live one and is reported directly —
seeding it would freeze the resume point where playback started.
- It is a single `.set()`, never `null` then set — a null in between would
destroy the player subtree and lose the engine.
- `playback_positions` is keyed `(playlistId, contentXtreamId, contentType)`, so
a switch changes the key. The resolved playback carries the **new** source's
`contentInfo`, and that source's row takes over.
The position also has to exist *before* anything plays. Nothing reports a live
one until the first `timeupdate`, so a pinned source started straight off the
Resume button would resolve at zero and restart the film. The controller is
therefore seeded from the persisted position (`seedResumeSeconds`), one-way:
once a live position exists it wins, because the stored one lags it by up to
the save throttle and applying it would visibly rewind.
A resuming engine can emit a `timeupdate` at ~0 before it finishes seeking.
`VodDetailsPlaybackService` guards this with a one-shot `resumeSettled` latch —
a filter would have broken deliberate seek-backwards. The latch also releases
when the target is out of reach: switching a two-hour position into a
90-minute cut means the engine can never report it, and waiting would suppress
every save for the rest of the session. `handleInlineTimeUpdate`
returns that verdict and the route feeds multi-source the `startTime` it asked
for until the engine gets there, so a switch or a failure during the initial
seek does not resolve the next source at zero and restart the film. One latch
serves both, because two would eventually disagree.
## Failover
Only fires when `Settings.vodAutoFailover` is on, and it first awaits a
discovery still in flight — a stream can fail faster than SQLite answers, and
concluding "nowhere to go" against an empty controller would strand the user on
the error screen with alternatives landing a moment later. `stillOwnsScreen()`
does that wait and then re-checks the session, because the user can navigate
during it and the controller afterwards may belong to a different film; the
pinned-Play path takes the same wait, or a persisted pin would lose to worker
latency.
Ranking (`pickFailoverTarget`):
1. never tried this session — a **hard filter**, not a preference
2. probed reachable; probed failing is penalised
3. not known to have failed recently
4. richer **factual** metadata (via `factualOnly`)
5. exact title match over fuzzy
Termination is structural: `triedSourceIds` only ever grows within a session, so
an N-source movie fails over at most N−1 times and then shows the honest error
screen. Returning to an earlier source by hand does not clear the set.
Selection is not an attempt. `setActiveSource` only selects; `markPlaying` also
spends the source's turn, and only the three places that really start playback
call it — a switch, the route's own Play/Resume, and restoring the playing row
after a rediscovery. Discovery selects the route's row the moment the page
opens, and a pin or the picker can select an alternative before anything plays;
counting those would let a later failure skip a healthy fallback, or call the
options exhausted with one untouched. `runFailover` then retires whatever is on
screen before picking, so the source that just failed is spent however it got
there — one hole per start path would be an infinite ping-pong between two
sources.
A source that cannot even be resolved is marked tried without becoming active,
and failover **continues to the next candidate** rather than giving up —
production calls `failover()` only once, on the original failure, so stopping at
a dead top-ranked source would strand every healthy one below it. `switchTo`
therefore reports which of two things happened: `unresolvable` (marked tried,
keep going) or `superseded` (something newer owns the screen, stop). Without
that distinction the loop would spin on a superseded target forever, because
only the first outcome marks the candidate tried.
**A pin is not decoration.** The primary Play action starts from the pinned
source when one is set, and the pin outranks every other signal in the ranking
above. Loading a pin that only decorated its row would mean "make this the main
source" survived a restart as an icon and nothing else.
The switch is **always announced** — another source can carry a different dub or
cut. The toast offers Undo, and adds a dub warning when
`audioDiffersFactually()` is true, which requires **both** sides to state an
audio track as fact. Two guesses, or a guess against a fact, stay silent.
Web engines only (HTML5/hls.js, Video.js, ArtPlayer). Embedded MPV suppresses
shared diagnostics and owns its own error block; external MPV/VLC are
fire-and-forget with no error channel back.
## External players and an alternative source
A switch goes through the same inline-vs-external fork a normal Play takes, so
with MPV or VLC configured the alternative opens in the external player — and
that session carries the OTHER playlist's ids. `matchedExternalPlayback` would
disown it: the primary button never became Stop, stopping found no session, and
another click opened a second player. The page therefore claims a session that
matches either the route's own stream or the alternative multi-source says is
active (`VodDetailsPlaybackBindings.activeSource`).
`ownsContent()` answers that question once, for both consumers: the session
matcher AND the playback-position bridge. They cannot be allowed to disagree —
a page that shows a Stop button for a session whose progress it discards keeps
the resume point at wherever playback began, so a switch an hour later rewinds
the whole session.
The caption itself appears only while a player is actually running — inline or
a matched external session, and not while a playback diagnostic is up.
Discovery marks a source active as the page opens, so gating on that alone
would have the page claim "Playing from …" before Play was pressed, after the
player was closed, and over the error screen for a stream that would not
play.
Whichever source ends up playing, the "playing" badge follows it: starting the
route's own stream (Play, Resume, Restart, or the fallback after a pin does not
apply) hands the badge back to the route row, or the picker and caption go on
naming an alternative that is no longer running. That hand-back also
invalidates a switch still resolving — the user chose the route stream, and an
older resolution arriving afterwards would replace what they just asked for. And a source started through
the picker or a pin is recorded in Recently Viewed exactly as an ordinary Play
is — it is the same film, watched.
Stop then has to win over the pin. The primary action consults the pin first —
that is what makes "make this the main source" decide where playback starts —
but when a session is already running the same button reads Stop, and doing
anything other than stopping would launch a second player while the first kept
going.
## PWA
Discovery, foreign-playlist reads, the pin table and the probe are all
main-process. A browser HEAD to an arbitrary IPTV host is CORS-blocked, and
`no-cors` yields an opaque response where 200, 403 and 404 are
indistinguishable — so the PWA cannot answer these questions honestly rather
than merely lacking a convenience.
Every entry point is gated on a bridge `typeof` check (`isAvailable`), matching
`CatalogTitleMatchService`. In the PWA the chip renders nothing and the
auto-failover setting is hidden.
## Resuming a pinned copy
Playback positions are keyed by (playlist, stream), so a pinned alternative
carries its own. `playPinned` looks that position up and applies it — and
applies **zero** when the lookup comes back empty, because the controller is
still holding the ROUTE copy's position at that moment. Carrying it across
would drop the user into the middle of a film they never started here, and the
first save would write that timecode under the pinned source's key.
When no lookup function is supplied at all, nothing is applied: "never watched"
was never established, so there is nothing to correct.
## What the primary button describes
Two position signals, not one. `vodPlaybackPosition` is the LAST position
seen — whichever copy produced it — and feeds the progress bar and the switch
handoff. `routePlaybackPosition` is the route copy's own row, and everything
that acts on the route's stream reads that: Resume, its label, its timecode.
Collapsing them lets an alternative's progress resume the route copy at a
timecode nobody reached in it. Route reuse (the Similar rail) clears
both, so the button cannot describe the previous movie while the new lookup is
still in flight, and every route start — including the primary button's
fall-through past an unresolvable pin — goes through the route's own wrappers,
which replace whatever timecode an alternative left in the controller.
While the pinned copy is the one playing, its live position wins over the row
that was stored before this session started.
A pin means the button plays a copy the page did not load a position for. A
watched-through copy resolves to zero rather than its stored seconds, through
the same `isResumablePosition` rule the label uses — otherwise the button reads
Play and then seeks back to where the film ended. Restart follows the pin for
the same reason: with Resume honouring a pinned copy, a Restart that started
the route copy would silently switch the user's playlist.
`createPrimaryActionPosition` therefore looks that copy's row up and lets it
govern the label, the timecode and the Restart affordance — including when the
lookup comes back empty, because "never watched" is an answer: the button must
read Play, not `Resume 42:18` on a stream that starts at zero. A pin on the
route's own row changes nothing; the loaded position already IS that copy's.
## Provider codec metadata
`info.video` / `info.audio` come back in two shapes: the declared string array
(`['H.264']`, which the mock server and many panels send) and the ffprobe
object carrying `codec_name`/`width`/`height`. `readStreamInfo` accepts both —
reading only the object silently lost the codec on every array response.
That codec is a **display** fact and nothing more. The "dub may differ" warning
reads `audioLanguage`, which comes from the track's language tag
(`info.audio.tags.language`, or `language` on panels that hoist it) and never
from `audio`. A codec cannot answer the question the warning asks: AAC and AC3
routinely carry the same dub, and two AC3 tracks can carry different ones, so
comparing codecs fired on every identical-language re-encode and stayed silent
on the dub changes it exists for. Wrong in both directions is worse than
absent, because a warning people learn to ignore is not a warning. Few panels
tag a language at all, so in practice it is usually silent — which is the
honest state, and the same one the rest of this feature takes when it does not
know.
Switching sources through `startResolvedPlayback` closes the external session
it LAUNCHED first — tracked separately from the controller's active source,
which a switch has already moved to the destination by then. It REPLACES what is playing — with MPV or VLC and instance
reuse off, the backend would otherwise spawn a second detached player, leaving
both sources running and Stop owning only the newer one.
## Short titles and Unicode
The title index folds diacritics. `content_title_fts` is created with
`tokenize='trigram remove_diacritics 1'`, because matching compares NORMALIZED
titles ("Amélie" → "amelie") against an index built from the raw one — without
folding, every accented title was invisible to the FTS path, and two identical
`Amélie` entries produced no candidates at all. The tokenizer is fixed at
CREATE time, so existing databases are recreated and rebuilt once behind
`migration:content-title-fts-remove-diacritics:v1`.
`remove_diacritics` needs SQLite 3.45+. The migration probes support on a temp
table first and does nothing when the runtime rejects it, leaving the working
index in place — and does NOT record itself as done, so a later app version
shipping a newer SQLite upgrades then. (better-sqlite3 currently bundles 3.53,
so the fallback is defensive rather than a path anyone is on.)
The completed marker is not taken as proof. `createTables` declares this table
too, with the plain tokenizer, and `CREATE TABLE IF NOT EXISTS` would recreate
it unfolded if it ever went missing after the marker was written — leaving two
sources of truth for one tokenizer. The migration therefore reads the live
table's own DDL out of `sqlite_master` and rebuilds unless it really folds. A
degraded index is otherwise invisible: discovery simply stops finding "Pokémon"
for "pokemon", with nothing to indicate it should have.
**Case folding for non-ASCII, on the FTS tier, is still not possible.**
`LOWER()` and the trigram tokenizer both fold ASCII only, so a Cyrillic title
stored with different capitalisation in two playlists ("ОН" vs "Он") cannot be
folded by the indexed path. Closing that needs a stored normalized-title
column, which is deliberately out of scope here.
The **scan** tier does fold it, because GLOB character classes are *not*
ASCII-only — `patternCompare` reads them as UTF-8 code points, and
`'Он' GLOB '*[Оо][Нн]*'` is true. `caseInsensitiveGlobPattern` therefore folds
the case in JavaScript, where Unicode case mapping is real, and hands SQLite one
class per character. Each class also carries the uppercase form's OWN lowercase,
which is what covers 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. That reach is one-way — `σ → Σ → σ` never arrives at `ς` — and
left so deliberately, because `ς` is only correct at the end of a word, exactly
where the request's own last character sits; closing the other direction needs a
fold table.
It returns `null` — leaving the substring tests as the whole answer — for a
token holding a GLOB metacharacter (SQLite GLOB has no escape character, so an
unescaped `*` would become a wildcard matching every row) or a case mapping that
changes length (`ß` uppercases to `SS`, `İ` lowercases to two code points).
Neither has a single-character class that means the same thing, and a wrong
pattern is worse than an absent one.
The ASCII/Unicode branch is decided from the RAW token, not the normalized one:
normalization folds diacritics, so "Ça" arrives as "ca" and looks like plain
ASCII while the stored title still reads "Ça". ASCII tokens keep the
word-boundary GLOB (what stops "it" matching "Titanic"). The non-ASCII branch
keeps both substring tests alongside the folded class, because the class is
built from the raw token and cannot match across a diacritic difference, which
the folded-token test does. It has no word boundary — `[^a-z0-9]` would treat
every Cyrillic letter as a separator — so it is looser, and the normalized
confirmation afterwards is what makes looser safe.
## Claims about the present
`isActive` means "the source a switch or Play would use" — selection, not
playback. `pinnedSourceAwaitingPlay` therefore takes `playbackLive` as well:
a pinned row stays selected after its player is closed, and skipping the pin
on selection alone would send the next Play to the route copy. Discovery sets it the
moment the page opens, and it survives closing the player — so it cannot, on
its own, back a statement in the present tense.
`VodDetailsRouteComponent.playbackLive` is that statement. 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, while
a timeupdate is the engine reporting frames. External, it needs the session
past `launching`. Every path that mounts a stream clears the latch first —
Play, Restart, and a source switch, which puts a DIFFERENT stream into the
same host and so cannot inherit the old one's evidence.
Two things read it, and they must agree: the "Playing from" caption, and the
source row's badge — which reads `Current` when a source is merely selected and
`Playing` once one really is.
## Where the route's code lives
`VodDetailsRouteComponent` is a thin host. The multi-source concerns it grew
live in `VodDetailsMultiSourceUiService` (component-provided): the
`playbackLive` evidence, the caption, the primary button's position, the
source actions, and the failover toast. The "Similar" rail and offline
downloads sit in their own component-provided services beside it.
## Backup
`playPinnedSource` reports `played` / `superseded` / `unavailable`, and only
`unavailable` may fall through to the route source. Collapsing the first two
meant a double-click on Play — where the second attempt supersedes the first —
had the losing attempt conclude "no usable pin" and start the route stream over
the playback the winning one had just begun. Same distinction as `runFailover`'s,
for the same reason.
A pin is written under every key naming its film, and its stale aliases retired,
in ONE transaction (`setVodSourcePin(db, pin, retireKeys, aliasKeys)`). Split in
two there is no honest outcome for a half-failure: a surviving alias is read
before the canonical key on the next open, so reporting success starts the
source the user just replaced — while reporting failure leaves the canonical row
durable and the UI showing a pin that is no longer the stored one. A call with
no usable key reports failure rather than claiming a preference it never wrote.
Pins ride along with playlist backup, under the playlist they point at, as the
optional `sourcePins` collection. See
`docs/architecture/playlist-backup-restore.md` for the remapping and
sanitizing rules — the short version is that `matchKey` names the film and
survives as-is, while the playlist id becomes the imported copy's.
## Which engines can fail over
Only the built-in web players (HTML5, Video.js, ArtPlayer) raise the playback
diagnostic that reaches `onPlaybackFailed()`. `WebPlayerViewComponent`
suppresses it for Embedded MPV, and external MPV/VLC never mount that component
at all — a stream that dies there is invisible to the app.
So the toggle is hidden, not merely inert, on those engines: in
`Settings > Playback` (`reportsPlaybackFailures()` gating the row) and in the
sources menu (`autoFailoverSupported`). Leaving it visible would let a user
switch on a feature that can never fire. The stored preference is untouched by
the change — switching back to a web player restores whatever was set.
@@ -1 +1,2 @@
export * from './lib/collection';
export * from './lib/multi-source';
@@ -0,0 +1,4 @@
export * from './vod-multi-source.controller';
export * from './vod-source-discovery.service';
export * from './vod-source-metadata.util';
export * from './vod-source-resolver.service';
@@ -0,0 +1,281 @@
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import { VodMultiSourceController } from './vod-multi-source.controller';
function source(
id: string,
overrides: Partial<VodSourceCandidate> = {}
): VodSourceCandidate {
return {
id,
playlistId: id,
playlistName: id.toUpperCase(),
portalType: 'xtream',
contentId: 1,
rawTitle: 'Dune',
matchConfidence: 'exact',
...overrides,
};
}
function controllerWith(...ids: string[]) {
const controller = new VodMultiSourceController();
controller.setSources(ids.map((id) => source(id)), 'title-year');
return controller;
}
describe('VodMultiSourceController', () => {
describe('alternatives and the chip', () => {
it('reports no alternatives when the only source is the active one', () => {
const controller = controllerWith('a');
controller.setActiveSource('a');
expect(controller.hasAlternatives()).toBe(false);
expect(controller.alternativeCount()).toBe(0);
});
it('excludes the active source from the alternatives', () => {
const controller = controllerWith('a', 'b', 'c');
controller.setActiveSource('a');
expect(controller.alternatives().map((s) => s.id)).toEqual([
'b',
'c',
]);
});
});
describe('failover cannot loop', () => {
it('never returns a source that was already tried', () => {
const controller = controllerWith('a', 'b', 'c');
controller.markPlaying('a');
const first = controller.pickFailoverTarget();
expect(first).not.toBeNull();
controller.markPlaying(first!.id);
const second = controller.pickFailoverTarget();
expect(second!.id).not.toBe(first!.id);
expect(second!.id).not.toBe('a');
});
it('exhausts after each source has been tried exactly once', () => {
const controller = controllerWith('a', 'b', 'c');
controller.markPlaying('a');
const visited = ['a'];
for (let hop = 0; hop < 5; hop++) {
const next = controller.pickFailoverTarget();
if (!next) {
break;
}
expect(visited).not.toContain(next.id);
visited.push(next.id);
controller.markPlaying(next.id);
}
// Three sources, three visits, then an honest dead end.
expect(visited).toEqual(['a', 'b', 'c']);
expect(controller.pickFailoverTarget()).toBeNull();
expect(controller.isExhausted()).toBe(true);
});
it('stays exhausted even if the user returns to an earlier source', () => {
const controller = controllerWith('a', 'b');
controller.markPlaying('a');
controller.markPlaying('b');
controller.markPlaying('a');
// Returning by hand is allowed; automatic retrying is not.
expect(controller.pickFailoverTarget()).toBeNull();
});
describe('selection is not an attempt', () => {
it('keeps the route source available after discovery selects it', () => {
// Discovery marks the route's row active the moment the page
// opens. Spending its turn there means a failure on some other
// source skips a healthy fallback.
const controller = controllerWith('a', 'b');
controller.setActiveSource('a');
controller.markPlaying('b');
expect(controller.pickFailoverTarget()?.id).toBe('a');
expect(controller.isExhausted()).toBe(false);
});
it('still exhausts once every source has actually played', () => {
const controller = controllerWith('a', 'b');
controller.setActiveSource('a');
controller.markPlaying('b');
controller.markPlaying('a');
expect(controller.isExhausted()).toBe(true);
});
});
});
describe('failover ranking', () => {
it('prefers a probed-reachable source', () => {
const controller = controllerWith('a', 'b', 'c');
controller.setActiveSource('a');
controller.setProbe('c', { status: 'ok' });
expect(controller.pickFailoverTarget()!.id).toBe('c');
});
it('avoids a source probed as failing', () => {
const controller = controllerWith('a', 'b', 'c');
controller.setActiveSource('a');
controller.setProbe('b', { status: 'fail' });
expect(controller.pickFailoverTarget()!.id).toBe('c');
});
it('treats an unknown probe as neutral, not as a failure', () => {
const controller = new VodMultiSourceController();
controller.setSources(
[source('a'), source('b'), source('c')],
'title-year'
);
controller.setActiveSource('a');
controller.setProbe('b', { status: 'unknown' });
controller.setProbe('c', { status: 'fail' });
// 'unknown' means we could not check — it must not be ranked
// below a source we positively know is broken.
expect(controller.pickFailoverTarget()!.id).toBe('b');
});
it('ignores guessed metadata when ranking', () => {
const controller = new VodMultiSourceController();
controller.setSources(
[
source('a'),
// Rich-looking, but every value is a filename guess.
source('guessy', {
quality: { value: '2160p', provenance: 'parsed' },
codec: { value: 'HEVC', provenance: 'parsed' },
audio: { value: 'Дубляж', provenance: 'parsed' },
container: { value: 'mkv', provenance: 'parsed' },
}),
// One verified fact beats four guesses.
source('factual', {
container: { value: 'mp4', provenance: 'api' },
}),
],
'title-year'
);
controller.setActiveSource('a');
expect(controller.pickFailoverTarget()!.id).toBe('factual');
});
it('prefers an exact match over a fuzzy one, all else equal', () => {
const controller = new VodMultiSourceController();
controller.setSources(
[
source('a'),
source('fuzzy', { matchConfidence: 'fuzzy' }),
source('exact', { matchConfidence: 'exact' }),
],
'title-year'
);
controller.setActiveSource('a');
expect(controller.pickFailoverTarget()!.id).toBe('exact');
});
it('deprioritizes a source known to have failed before', () => {
const controller = new VodMultiSourceController();
controller.setSources(
[
source('a'),
source('stale', {
lastFailedAt: '2026-07-01T00:00:00.000Z',
}),
source('fresh'),
],
'title-year'
);
controller.setActiveSource('a');
expect(controller.pickFailoverTarget()!.id).toBe('fresh');
});
});
describe('resume position', () => {
it('carries the last reported position', () => {
const controller = controllerWith('a');
controller.setResumeSeconds(2538);
expect(controller.getResumeSeconds()).toBe(2538);
});
it('ignores nonsensical positions rather than losing the good one', () => {
const controller = controllerWith('a');
controller.setResumeSeconds(2538);
controller.setResumeSeconds(NaN);
controller.setResumeSeconds(-5);
controller.setResumeSeconds(Infinity);
expect(controller.getResumeSeconds()).toBe(2538);
});
it('seeds from the stored position before playback reports one', () => {
// Switching straight off the Resume button happens before any
// timeupdate, so without this the movie restarts.
const controller = controllerWith('a');
controller.seedResumeSeconds(2538);
expect(controller.getResumeSeconds()).toBe(2538);
});
it('never lets the stored position override the live one', () => {
const controller = controllerWith('a');
controller.setResumeSeconds(2560);
// The persisted value lags the live one by up to the save
// throttle; applying it would visibly rewind the switch.
controller.seedResumeSeconds(2538);
expect(controller.getResumeSeconds()).toBe(2560);
});
});
describe('source state', () => {
it('marks the pinned and active sources', () => {
const controller = controllerWith('a', 'b');
controller.setActiveSource('a');
controller.setPinnedSource('b');
const [first, second] = controller.sources();
expect(first.isActive).toBe(true);
expect(first.isPinned).toBe(false);
expect(second.isActive).toBe(false);
expect(second.isPinned).toBe(true);
});
it('merges upgraded metadata without reordering the list', () => {
const controller = controllerWith('a', 'b');
controller.updateSource(
source('b', {
container: { value: 'mp4', provenance: 'api' },
})
);
expect(controller.sources().map((s) => s.id)).toEqual(['a', 'b']);
expect(controller.sources()[1].container).toEqual({
value: 'mp4',
provenance: 'api',
});
});
it('defaults every source to an idle probe', () => {
const controller = controllerWith('a');
expect(controller.sources()[0].probe).toEqual({ status: 'idle' });
});
});
});
@@ -0,0 +1,217 @@
import { computed, signal } from '@angular/core';
import type {
VodSourceCandidate,
VodSourceDescriptor,
VodSourceMatchKind,
VodSourceProbeResult,
} from '@iptvnator/shared/interfaces';
import { factualOnly } from './vod-source-metadata.util';
/**
* Per-movie multi-source session state.
*
* A plain class rather than a service: one instance belongs to one open movie,
* mirroring `StalkerVodPlaybackController`. Holding this in a root-provided
* service would leak `triedSourceIds` between films and quietly break the
* anti-loop guarantee.
*
* It owns state and decisions only — no I/O. Discovery, resolution, probing
* and persistence are injected by the host, which keeps this testable without
* TestBed.
*/
export class VodMultiSourceController {
private readonly _sources = signal<VodSourceCandidate[]>([]);
private readonly _activeSourceId = signal<string | null>(null);
private readonly _pinnedSourceId = signal<string | null>(null);
private readonly _matchKind = signal<VodSourceMatchKind>('title-year');
private readonly _probes = signal<Record<string, VodSourceProbeResult>>({});
/**
* Sources already attempted in this session.
*
* The anti-loop guarantee: failover only ever considers sources absent
* from this set, so a three-source movie can fail over at most twice and
* then honestly gives up. Never cleared during a session — only a fresh
* movie (a new controller) resets it.
*/
private readonly tried = new Set<string>();
/** Live playback position, carried across a source switch. */
private resumeSeconds = 0;
readonly matchKind = this._matchKind.asReadonly();
readonly activeSourceId = this._activeSourceId.asReadonly();
/** Every known source, decorated with live session state. */
readonly sources = computed<VodSourceDescriptor[]>(() => {
const activeId = this._activeSourceId();
const pinnedId = this._pinnedSourceId();
const probes = this._probes();
return this._sources().map((candidate) => ({
...candidate,
isActive: candidate.id === activeId,
isPinned: candidate.id === pinnedId,
isTried: this.tried.has(candidate.id),
probe: probes[candidate.id] ?? { status: 'idle' },
}));
});
/** Sources other than the one playing — what the popover offers. */
readonly alternatives = computed(() =>
this.sources().filter((source) => !source.isActive)
);
/** The chip only appears when there is genuinely somewhere else to go. */
readonly hasAlternatives = computed(() => this.alternatives().length > 0);
readonly alternativeCount = computed(() => this.alternatives().length);
setSources(sources: VodSourceCandidate[], matchKind: VodSourceMatchKind) {
this._sources.set(sources);
this._matchKind.set(matchKind);
}
/**
* Merge an updated candidate (e.g. after resolution upgraded its metadata
* from `parsed` to `api`) without disturbing list order or identity.
*/
updateSource(updated: VodSourceCandidate) {
this._sources.update((sources) =>
sources.map((source) =>
source.id === updated.id ? updated : source
)
);
}
/**
* Select a source WITHOUT spending its failover turn.
*
* Discovery selects the route's row the moment the page opens, and a pin
* or the picker can select an alternative before anything plays. None of
* those is an attempt: counting them means a later failure finds the
* route source "tried" and skips a perfectly healthy fallback — or calls
* the options exhausted while one is untouched.
*/
setActiveSource(sourceId: string | null) {
this._activeSourceId.set(sourceId);
}
/** Select a source AND spend its turn — playback is actually starting. */
markPlaying(sourceId: string) {
this.markTried(sourceId);
this.setActiveSource(sourceId);
}
/**
* Record an attempt that never became active — e.g. a source whose URL
* could not be resolved at all. Without this, failover would keep
* re-picking a source that already proved unusable and spin.
*/
markTried(sourceId: string) {
this.tried.add(sourceId);
// `tried` is not a signal (it is per-session bookkeeping, not state
// the view derives from directly), so nudge the derived list.
this._sources.update((sources) => [...sources]);
}
setPinnedSource(sourceId: string | null) {
this._pinnedSourceId.set(sourceId);
}
setProbe(sourceId: string, result: VodSourceProbeResult) {
this._probes.update((probes) => ({ ...probes, [sourceId]: result }));
}
/** Position updates arrive continuously from the player. */
setResumeSeconds(seconds: number) {
if (Number.isFinite(seconds) && seconds >= 0) {
this.resumeSeconds = seconds;
}
}
/**
* Seed from the PERSISTED position, so a switch made before playback ever
* started still resumes.
*
* Nothing reports a live position until the player emits its first
* timeupdate, so until then this is zero — and "Resume" through a pinned
* source would silently restart the film. Deliberately one-way: once a
* live position exists it wins, because the stored one lags it by up to
* the persistence throttle and applying it would visibly rewind.
*/
seedResumeSeconds(seconds: number) {
if (this.resumeSeconds <= 0) {
this.setResumeSeconds(seconds);
}
}
getResumeSeconds(): number {
return this.resumeSeconds;
}
findSource(sourceId: string): VodSourceCandidate | null {
return (
this._sources().find((source) => source.id === sourceId) ?? null
);
}
/**
* The best source to fail over to, or null when the options are exhausted.
*
* Ranking deliberately ignores guessed metadata — a filename claiming 4K
* must not outrank a source we actually reached. Order:
* 1. never tried this session (a hard filter, not a preference)
* 2. pinned by the user for this movie
* 3. probed reachable
* 4. not known to have failed recently
* 5. richer FACTUAL metadata
* 6. exact title match over fuzzy
*/
pickFailoverTarget(): VodSourceDescriptor | null {
const untried = this.sources().filter(
(source) => !source.isActive && !source.isTried
);
if (untried.length === 0) {
return null;
}
return [...untried].sort((a, b) => score(b) - score(a))[0];
}
/** True once every source has been attempted — time for an honest error. */
isExhausted(): boolean {
return this.pickFailoverTarget() === null;
}
}
function score(source: VodSourceDescriptor): number {
let value = 0;
// An explicit user choice outranks anything we inferred about the others.
if (source.isPinned) {
value += 1000;
}
if (source.probe.status === 'ok') {
value += 100;
}
if (source.probe.status === 'fail') {
value -= 100;
}
if (source.lastFailedAt) {
value -= 50;
}
if (source.matchConfidence === 'exact') {
value += 10;
}
// Only verified metadata counts toward "richer". A parsed guess adds
// nothing here by construction — factualOnly() cannot return one.
const facts = factualOnly(source);
value += [facts.quality, facts.codec, facts.container, facts.audio].filter(
Boolean
).length;
return value;
}
@@ -0,0 +1,70 @@
import { VodSourceDiscoveryService } from './vod-source-discovery.service';
/**
* Discovery talks to a foreign playlist, and Xtream carries the account in the
* URL — so anything it fails on has to go through the redacting logger before
* it reaches the console.
*/
describe('VodSourceDiscoveryService — failure logging', () => {
const CREDENTIAL_URL =
'http://portal.example.com:8080/player_api.php' +
'?username=alice&password=hunter2&action=get_vod_info';
let warnSpy: jest.SpyInstance;
let service: VodSourceDiscoveryService;
beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {
/* captured */
});
service = new VodSourceDiscoveryService();
});
afterEach(() => {
warnSpy.mockRestore();
delete (window as { electron?: unknown }).electron;
});
it('never lets a portal error carry credentials to the console', async () => {
(window as { electron?: unknown }).electron = {
dbFindTitleSources: jest
.fn()
.mockRejectedValue(
new Error(`Request to ${CREDENTIAL_URL} failed`)
),
};
await expect(
service.discover({
title: 'Dune',
currentPlaylistId: 'playlist-1',
})
).resolves.toEqual({ sources: [], matchKind: 'title-year' });
expect(warnSpy).toHaveBeenCalled();
const logged = loggedText();
expect(logged).not.toContain('hunter2');
expect(logged).not.toContain('alice');
// Still useful: the failure is reported, only the account is not.
expect(logged).toContain('VOD source discovery failed');
});
/**
* What a console would actually show.
*
* `JSON.stringify` on an Error yields `{}` — its message and stack are
* non-enumerable — so stringifying the call list would hide a raw error's
* credentials and quietly pass whatever this asserts.
*/
function loggedText(): string {
return warnSpy.mock.calls
.flat()
.map((arg) => {
if (arg instanceof Error) {
return `${arg.message}\n${arg.stack ?? ''}`;
}
return typeof arg === 'string' ? arg : JSON.stringify(arg);
})
.join('\n');
}
});
@@ -0,0 +1,103 @@
import { Injectable } from '@angular/core';
import type {
VodSourceCandidate,
VodSourceCandidateRow,
VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { parseTitleMetadata } from './vod-source-metadata.util';
/**
* Finds the same movie in the user's other playlists.
*
* Electron-only, and deliberately lazy: it runs when a detail page opens, not
* as a background index. Discovery returns rows the `content` table can prove
* — title, ids, poster — and NOTHING about container, codec or audio, because
* those columns do not exist. Everything a row shows at this stage is either a
* hard id or a guess parsed off the title and marked as one.
*/
export interface VodSourceDiscoveryRequest {
title: string;
year?: number | null;
/** The playlist being viewed; never offered back as an alternative. */
currentPlaylistId: string;
/**
* One stream id inside it to keep anyway — a pin can point at another copy
* of the film in the playlist the user is currently on, and excluding it
* would silently ignore an explicit preference.
*/
keepContentId?: number | null;
}
export interface VodSourceDiscoveryResult {
sources: VodSourceCandidate[];
/**
* How the set was matched. v1 matches on normalized title + year — a TMDB
* id is not stored in the `content` table, so it cannot drive the SQL.
*/
matchKind: VodSourceMatchKind;
}
@Injectable({ providedIn: 'root' })
export class VodSourceDiscoveryService {
private readonly logger = createLogger('VodSourceDiscovery');
get isAvailable(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.electron?.dbFindTitleSources === 'function'
);
}
async discover(
request: VodSourceDiscoveryRequest
): Promise<VodSourceDiscoveryResult> {
const empty: VodSourceDiscoveryResult = {
sources: [],
matchKind: 'title-year',
};
if (!this.isAvailable || !request.title?.trim()) {
return empty;
}
try {
const rows = await window.electron.dbFindTitleSources({
title: request.title,
year: request.year ?? null,
excludePlaylistId: request.currentPlaylistId,
keepContentId: request.keepContentId ?? null,
});
return {
sources: (rows ?? []).map(toCandidate),
matchKind: 'title-year',
};
} catch (error) {
this.logger.warn('VOD source discovery failed:', error);
return empty;
}
}
}
function toCandidate(row: VodSourceCandidateRow): VodSourceCandidate {
// Everything inferable at this point comes from the title string, so it is
// all `provenance: 'parsed'`. Facts arrive later, from `get_vod_info`.
const parsed = parseTitleMetadata(row.title);
return {
id: `${row.playlistId}:xtream:${row.xtreamId}`,
playlistId: row.playlistId,
playlistName: row.playlistName,
portalType: 'xtream',
contentId: row.xtreamId,
rawTitle: row.title,
matchConfidence: row.matchConfidence,
year: row.year,
posterUrl: row.posterUrl,
quality: parsed.quality,
codec: parsed.codec,
audio: parsed.audio,
};
}
@@ -0,0 +1,373 @@
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import {
applyApiMetadata,
audioDiffersFactually,
factualOnly,
parseTitleMetadata,
} from './vod-source-metadata.util';
function candidate(
overrides: Partial<VodSourceCandidate> = {}
): VodSourceCandidate {
return {
id: 'p1:xtream:1',
playlistId: 'p1',
playlistName: 'SharkTV',
portalType: 'xtream',
contentId: 1,
rawTitle: 'Dune',
matchConfidence: 'exact',
...overrides,
};
}
describe('parseTitleMetadata', () => {
it('returns nothing for an empty title rather than inventing defaults', () => {
expect(parseTitleMetadata('')).toEqual({});
expect(parseTitleMetadata(null)).toEqual({});
expect(parseTitleMetadata(undefined)).toEqual({});
});
it('leaves fields undefined when the title says nothing about them', () => {
const parsed = parseTitleMetadata('Dune Part Two');
expect(parsed.quality).toBeUndefined();
expect(parsed.codec).toBeUndefined();
expect(parsed.audio).toBeUndefined();
expect(parsed.hdr).toBeUndefined();
});
it('marks every inferred value as a guess', () => {
const parsed = parseTitleMetadata('Dune 2160p HEVC Дубляж HDR');
expect(parsed.quality).toEqual({
value: '2160p',
provenance: 'parsed',
});
expect(parsed.codec).toEqual({ value: 'HEVC', provenance: 'parsed' });
expect(parsed.audio).toEqual({ value: 'Дубляж', provenance: 'parsed' });
expect(parsed.hdr).toEqual({ value: 'HDR', provenance: 'parsed' });
});
it.each([
['Dune 4K', '2160p'],
['Dune UHD', '2160p'],
['Dune 1080i', '1080p'],
['Dune 720P', '720p'],
])('normalizes quality tag %s', (title, expected) => {
expect(parseTitleMetadata(title).quality?.value).toBe(expected);
});
it.each([
['Dune x265', 'HEVC'],
['Dune H.265', 'HEVC'],
['Dune x264', 'H.264'],
['Dune H264', 'H.264'],
['Dune AV1', 'AV1'],
])('normalizes codec tag %s', (title, expected) => {
expect(parseTitleMetadata(title).codec?.value).toBe(expected);
});
it('prefers the more specific quality when several could match', () => {
// "4K" and "1080p" both appear; the higher, more specific tag wins.
expect(parseTitleMetadata('Dune 4K remux from 1080p').quality?.value)
.toBe('2160p');
});
it('does not match quality tags embedded in unrelated words', () => {
expect(parseTitleMetadata('Blade Runner 2049').quality).toBeUndefined();
});
});
describe('applyApiMetadata', () => {
it('overrides a guess with a provider-stated fact', () => {
const withGuess = candidate({
container: { value: 'mkv', provenance: 'parsed' },
});
const result = applyApiMetadata(withGuess, {
containerExtension: 'mp4',
});
expect(result.container).toEqual({ value: 'mp4', provenance: 'api' });
});
it('keeps an existing guess when the provider states nothing', () => {
const withGuess = candidate({
quality: { value: '1080p', provenance: 'parsed' },
});
const result = applyApiMetadata(withGuess, {
containerExtension: null,
height: null,
});
// Erasing a marked guess in favour of nothing helps no one.
expect(result.quality).toEqual({
value: '1080p',
provenance: 'parsed',
});
});
it('derives quality from a standard frame height as a fact', () => {
expect(applyApiMetadata(candidate(), { height: 2160 }).quality).toEqual({
value: '2160p',
provenance: 'api',
});
});
it('prefers width, which cropping does not distort', () => {
// A 2.39:1 1080p master is 1920x800 — the width still says 1080p.
expect(
applyApiMetadata(candidate(), { width: 1920, height: 800 }).quality
).toEqual({ value: '1080p', provenance: 'api' });
});
it('does not call every small stream 480p', () => {
// The old rule labelled ANY width under 900 as 480p, published with
// `api` provenance — so a 640x360 stream stated 480p as a fact.
expect(
applyApiMetadata(candidate(), { width: 640, height: 360 }).quality
).toEqual({ value: '360p', provenance: 'api' });
expect(
applyApiMetadata(candidate(), { width: 854, height: 480 }).quality
).toEqual({ value: '480p', provenance: 'api' });
});
it('reads the height when one width covers two formats', () => {
// 720 wide is NTSC 480p or PAL 576p; only the height separates them,
// and without one there is no honest answer to give.
expect(
applyApiMetadata(candidate(), { width: 720, height: 576 }).quality
).toEqual({ value: '576p', provenance: 'api' });
expect(
applyApiMetadata(candidate(), { width: 720, height: 480 }).quality
).toEqual({ value: '480p', provenance: 'api' });
expect(
applyApiMetadata(candidate(), { width: 720 }).quality
).toBeUndefined();
});
it('tells 540p apart from 576p instead of bucketing both', () => {
// 960x540 and 1024x576 are two standard formats inside what used to be
// one 900-1199 range, so every 540p encode was published as "576p"
// with `api` provenance — a measurement its own pixels contradict.
expect(
applyApiMetadata(candidate(), { width: 960, height: 540 }).quality
).toEqual({ value: '540p', provenance: 'api' });
expect(
applyApiMetadata(candidate(), { width: 1024, height: 576 }).quality
).toEqual({ value: '576p', provenance: 'api' });
// Letterboxing only ever removes lines, so the width still names it.
expect(
applyApiMetadata(candidate(), { width: 1024, height: 432 }).quality
).toEqual({ value: '576p', provenance: 'api' });
});
it.each([
['eng', 'en'],
['en', 'en'],
['en-US', 'en'],
// Both ISO 639-2 spellings of German have to land together.
['ger', 'de'],
['deu', 'de'],
// No two-letter code exists for these, so three is canonical.
['fil', 'fil'],
])('canonicalizes the language tag %s', (raw, expected) => {
expect(
applyApiMetadata(candidate(), { audioLanguage: raw }).audioLanguage
).toEqual({ value: expected, provenance: 'api' });
});
it.each([
// ffprobe's marker for "we do not know".
['und'],
['Russian'],
['en_US'],
[''],
])('states no language for %s', (raw) => {
expect(
applyApiMetadata(candidate(), { audioLanguage: raw }).audioLanguage
).toBeUndefined();
});
it('refuses a wide frame taller than the format its width names', () => {
// 1440x1080 is anamorphic 1080 and 1600x900 is 900p. Both fall in the
// 1200-1699 band, so both were published as "720p" — with `api`
// provenance, which is the one that means the provider said so.
expect(
applyApiMetadata(candidate(), { width: 1440, height: 1080 }).quality
).toBeUndefined();
expect(
applyApiMetadata(candidate(), { width: 1600, height: 900 }).quality
).toBeUndefined();
});
it('still names a letterboxed wide master by its width', () => {
// Cropping only removes lines, so a short frame is this format.
expect(
applyApiMetadata(candidate(), { width: 1920, height: 800 }).quality
).toEqual({ value: '1080p', provenance: 'api' });
expect(
applyApiMetadata(candidate(), { width: 1920, height: 1080 }).quality
).toEqual({ value: '1080p', provenance: 'api' });
expect(
applyApiMetadata(candidate(), { width: 3840, height: 2160 }).quality
).toEqual({ value: '2160p', provenance: 'api' });
});
it('gives no quality for a width between the known formats', () => {
// 1100 wide is no standard shape. The old range answered "576p" for
// it; an absent tag and a check chip is the honest reply.
expect(
applyApiMetadata(candidate(), { width: 1100, height: 620 }).quality
).toBeUndefined();
});
it('rejects a height the width cannot account for', () => {
// 640x480 is 4:3 VGA, not a letterboxed 360p — the width alone would
// have called it 360p and published that as a measurement.
expect(
applyApiMetadata(candidate(), { width: 640, height: 480 }).quality
).toBeUndefined();
});
it('still trusts the width when the frame is cropped', () => {
// Cropping only removes lines, so a short frame at 854 wide is a
// letterboxed 480p master and the width still names it.
expect(
applyApiMetadata(candidate(), { width: 854, height: 360 }).quality
).toEqual({ value: '480p', provenance: 'api' });
});
it('emits nothing for a shape that is not a known format', () => {
// 800 wide is neither 854x480 nor anything else in the table, and
// 800x600 is certainly not 480 lines high.
expect(
applyApiMetadata(candidate(), { width: 800, height: 600 }).quality
).toBeUndefined();
expect(
applyApiMetadata(candidate(), { width: 800, height: 450 }).quality
).toBeUndefined();
expect(
applyApiMetadata(candidate(), { width: 320, height: 240 }).quality
).toBeUndefined();
});
it('emits no quality for an ambiguous cropped height', () => {
// 800 lines alone could be a cropped 1080p or a 1280x800 encode.
// Guessing here would publish a wrong value labelled as a fact.
expect(applyApiMetadata(candidate(), { height: 800 }).quality)
.toBeUndefined();
});
it('ignores a nonsensical dimension instead of emitting a bogus fact', () => {
expect(applyApiMetadata(candidate(), { height: 0 }).quality)
.toBeUndefined();
expect(applyApiMetadata(candidate(), { height: -5 }).quality)
.toBeUndefined();
expect(applyApiMetadata(candidate(), { height: NaN }).quality)
.toBeUndefined();
expect(applyApiMetadata(candidate(), { width: 0, height: 1080 }).quality)
.toEqual({ value: '1080p', provenance: 'api' });
});
it('normalizes provider codec names', () => {
expect(applyApiMetadata(candidate(), { videoCodec: 'hevc' }).codec)
.toEqual({ value: 'HEVC', provenance: 'api' });
expect(applyApiMetadata(candidate(), { videoCodec: 'h264' }).codec)
.toEqual({ value: 'H.264', provenance: 'api' });
});
it('does not mutate the input candidate', () => {
const original = candidate();
applyApiMetadata(original, { containerExtension: 'mp4' });
expect(original.container).toBeUndefined();
});
});
describe('factualOnly', () => {
it('hides guessed values from ranking decisions', () => {
const mixed = candidate({
quality: { value: '2160p', provenance: 'parsed' },
container: { value: 'mp4', provenance: 'api' },
});
const facts = factualOnly(mixed);
// The whole point: a filename claiming 4K must not outrank a source
// we actually verified.
expect(facts.quality).toBeUndefined();
expect(facts.container).toEqual({ value: 'mp4', provenance: 'api' });
});
it('keeps probe-sourced values', () => {
const probed = candidate({
codec: { value: 'HEVC', provenance: 'probe' },
});
expect(factualOnly(probed).codec).toEqual({
value: 'HEVC',
provenance: 'probe',
});
});
});
describe('audioDiffersFactually', () => {
it('warns only when both sides are known facts that differ', () => {
const from = candidate({
audioLanguage: { value: 'rus', provenance: 'api' },
});
const to = candidate({
audioLanguage: { value: 'eng', provenance: 'api' },
});
expect(audioDiffersFactually(from, to)).toBe(true);
});
it('stays silent when either side is only a guess', () => {
const from = candidate({
audioLanguage: { value: 'rus', provenance: 'api' },
});
const guessed = candidate({
audioLanguage: { value: 'eng', provenance: 'parsed' },
});
expect(audioDiffersFactually(from, guessed)).toBe(false);
});
it('stays silent when either side is unknown', () => {
const known = candidate({
audioLanguage: { value: 'rus', provenance: 'api' },
});
expect(audioDiffersFactually(known, candidate())).toBe(false);
expect(audioDiffersFactually(candidate(), known)).toBe(false);
expect(audioDiffersFactually(null, known)).toBe(false);
});
it('stays silent when two panels spell one language differently', () => {
// ffprobe says `eng`, a panel hoists `en`, and both mean English. A
// literal comparison turns that into a dub change on every switch
// between those two portals.
const from = candidate(
applyApiMetadata(candidate(), { audioLanguage: 'eng' })
);
const to = candidate(
applyApiMetadata(candidate(), { audioLanguage: 'en-US' })
);
expect(audioDiffersFactually(from, to)).toBe(false);
});
it('stays silent when both sides state the same track', () => {
const from = candidate({
audioLanguage: { value: 'rus', provenance: 'api' },
});
const to = candidate({
audioLanguage: { value: 'rus', provenance: 'api' },
});
expect(audioDiffersFactually(from, to)).toBe(false);
});
});
@@ -0,0 +1,410 @@
import type {
VodSourceCandidate,
VodSourceField,
} from '@iptvnator/shared/interfaces';
/**
* Metadata extraction for VOD multi-source rows.
*
* The governing rule: a value inferred from a filename is a GUESS and must
* never be presented, sorted, or acted on as though the provider had stated
* it. Everything here returns `provenance: 'parsed'`; facts come from
* `applyApiMetadata` below, and reachability from the probe.
*
* Empty beats wrong — when a pattern does not match, the field stays
* `undefined` and the row simply shows no tag, plus a "check" affordance.
*/
/**
* Unicode-aware word boundary.
*
* JavaScript's `\b` is ASCII-only, so `/\bдубляж\b/` never matches — which
* would silently drop the dub tag on exactly the Cyrillic catalogues where it
* matters most. Lookarounds over `\p{L}\p{N}` give a real boundary.
*/
function bounded(source: string): RegExp {
return new RegExp(`(?<![\\p{L}\\p{N}])(?:${source})(?![\\p{L}\\p{N}])`, 'iu');
}
/** Release-quality tags, most specific first so "4K" wins over "1080p". */
const QUALITY_PATTERNS: ReadonlyArray<[RegExp, string]> = [
[bounded('2160p|4k|uhd'), '2160p'],
[bounded('1440p'), '1440p'],
[bounded('1080[pi]'), '1080p'],
[bounded('720[pi]'), '720p'],
[bounded('576[pi]'), '576p'],
[bounded('480[pi]'), '480p'],
];
const CODEC_PATTERNS: ReadonlyArray<[RegExp, string]> = [
[bounded('hevc|h\\.?265|x265'), 'HEVC'],
[bounded('avc|h\\.?264|x264'), 'H.264'],
[bounded('av1'), 'AV1'],
[bounded('mpeg-?2'), 'MPEG-2'],
];
/**
* Dub/voice-over markers. The single most valuable field to a user and the
* least likely to be stated by any provider, so it is worth guessing — but
* only ever as a guess.
*/
const AUDIO_PATTERNS: ReadonlyArray<[RegExp, string]> = [
[bounded('дубляж|dub'), 'Дубляж'],
[bounded('mvo'), 'MVO'],
[bounded('dvo'), 'DVO'],
[bounded('avo'), 'AVO'],
[bounded('субтитры|subs|sub'), 'Sub'],
[bounded('eng|english'), 'Eng'],
[bounded('rus|russian|рус'), 'Rus'],
[bounded('ukr|ukrainian|укр'), 'Ukr'],
];
const HDR_PATTERNS: ReadonlyArray<[RegExp, string]> = [
[bounded('dolby\\s?vision|dv'), 'Dolby Vision'],
[bounded('hdr10\\+'), 'HDR10+'],
[bounded('hdr'), 'HDR'],
];
function matchFirst(
raw: string,
patterns: ReadonlyArray<[RegExp, string]>
): VodSourceField | undefined {
for (const [pattern, value] of patterns) {
if (pattern.test(raw)) {
return { value, provenance: 'parsed' };
}
}
return undefined;
}
export interface ParsedTitleMetadata {
quality?: VodSourceField;
codec?: VodSourceField;
audio?: VodSourceField;
hdr?: VodSourceField;
}
/**
* Infer what the title string is willing to admit. Everything returned is a
* guess, tagged as such.
*/
export function parseTitleMetadata(
rawTitle: string | null | undefined
): ParsedTitleMetadata {
if (!rawTitle) {
return {};
}
return {
quality: matchFirst(rawTitle, QUALITY_PATTERNS),
codec: matchFirst(rawTitle, CODEC_PATTERNS),
audio: matchFirst(rawTitle, AUDIO_PATTERNS),
hdr: matchFirst(rawTitle, HDR_PATTERNS),
};
}
/** Metadata a provider actually stated, from `get_vod_info`. */
export interface ProviderVodMetadata {
containerExtension?: string | null;
videoCodec?: string | null;
audioCodec?: string | null;
/** The audio track's spoken language, when the provider tags one. */
audioLanguage?: string | null;
/** Pixel width from the provider's own video stream info. */
width?: number | null;
/** Pixel height from the provider's own video stream info. */
height?: number | null;
}
/**
* Overlay provider-stated facts on top of parsed guesses.
*
* A fact always wins: once the portal says the container is `mkv`, the guess
* is discarded rather than shown alongside it. A missing fact never erases an
* existing guess — that would trade a marked guess for nothing.
*/
export function applyApiMetadata(
candidate: VodSourceCandidate,
api: ProviderVodMetadata
): VodSourceCandidate {
const next: VodSourceCandidate = { ...candidate };
const container = cleanString(api.containerExtension);
if (container) {
next.container = { value: container, provenance: 'api' };
}
const codec = normalizeCodecName(api.videoCodec);
if (codec) {
next.codec = { value: codec, provenance: 'api' };
}
const audio = normalizeCodecName(api.audioCodec);
if (audio) {
next.audio = { value: audio, provenance: 'api' };
}
const language = canonicalLanguage(api.audioLanguage);
if (language) {
next.audioLanguage = { value: language, provenance: 'api' };
}
const quality = qualityFromDimensions(api.width, api.height);
if (quality) {
next.quality = { value: quality, provenance: 'api' };
}
return next;
}
/**
* The only sanctioned way to read metadata for ranking or automatic choices.
*
* Guessed values are structurally unreachable from here, which is what stops
* "~2160p parsed off a filename" from outranking a source we actually
* verified. Presentation code reads the fields directly; decision code must
* come through this.
*/
export function factualOnly(
candidate: VodSourceCandidate
): Pick<VodSourceCandidate, 'quality' | 'codec' | 'container' | 'audio'> {
const keep = (field?: VodSourceField) =>
field && field.provenance !== 'parsed' ? field : undefined;
return {
quality: keep(candidate.quality),
codec: keep(candidate.codec),
container: keep(candidate.container),
audio: keep(candidate.audio),
};
}
/**
* True when both sides name a spoken LANGUAGE as fact and those differ.
*
* Reads `audioLanguage`, never `audio`. The latter holds a codec whenever the
* fact came from the API, and a codec cannot answer this question: AAC and AC3
* routinely carry the same dub, while two AC3 tracks can carry different ones.
* Warning on a codec change would fire on identical-language re-encodes and
* stay silent on the actual dub changes it exists for — and a warning that is
* wrong in both directions is worse than no warning, because people learn to
* ignore it.
*
* Deliberately conservative beyond that: two guesses, or a guess against a
* fact, are not enough. It stays silent when we do not know, which — since few
* panels tag a language at all — is the common case.
*/
export function audioDiffersFactually(
from: VodSourceCandidate | null | undefined,
to: VodSourceCandidate | null | undefined
): boolean {
const a = from && keepFactual(from.audioLanguage)?.value;
const b = to && keepFactual(to.audioLanguage)?.value;
return Boolean(a && b && a !== b);
}
function keepFactual(field?: VodSourceField): VodSourceField | undefined {
return field && field.provenance !== 'parsed' ? field : undefined;
}
/**
* A language tag reduced to the one form two panels can be compared on.
*
* The same spoken language is written several ways in the wild — ffprobe emits
* ISO 639-2 (`eng`, and `ger` or `deu` for German), panels hoist 639-1 (`en`),
* and either may carry a region (`en-US`). Comparing those literally makes a
* dub "change" out of two spellings of English.
*
* `Intl.Locale` does the canonicalizing: 639-2 collapses to 639-1 where one
* exists, both German forms land on `de`, and regions drop away. `und` —
* ffprobe's marker for undetermined — canonicalizes to nothing, which is
* exactly right: it means the provider does not know either.
*
* Anything that survives with more than three characters is not a language
* code (`Russian` parses as the subtag `russian`), so the comparison is
* declined rather than guessed at.
*/
function canonicalLanguage(raw: string | null | undefined): string | null {
const value = cleanString(raw);
if (!value || !LocaleCtor) {
return null;
}
try {
const language = new LocaleCtor(value).language;
return language && language.length <= 3 ? language : null;
} catch {
// Not a well-formed tag at all; saying nothing beats comparing junk.
return null;
}
}
/**
* `Intl.Locale` is ES2020 and this workspace compiles against the es2018 lib,
* so it is reached through a narrow shim rather than by moving the target.
*
* Absent — which no supported runtime actually is — the comparison is declined
* rather than half-canonicalized, since `eng` measured against `en` by a
* partial fold is exactly the false warning this function exists to prevent.
*/
const LocaleCtor = (
Intl as unknown as {
Locale?: new (tag: string) => { language?: string };
}
).Locale;
function cleanString(raw: string | null | undefined): string | null {
const trimmed = typeof raw === 'string' ? raw.trim() : '';
return trimmed === '' ? null : trimmed;
}
/** Map ffmpeg-style codec names onto the labels users recognize. */
function normalizeCodecName(raw: string | null | undefined): string | null {
const value = cleanString(raw);
if (!value) {
return null;
}
const lower = value.toLowerCase();
if (lower === 'hevc' || lower === 'h265') {
return 'HEVC';
}
if (lower === 'h264' || lower === 'avc') {
return 'H.264';
}
return value;
}
/** Standard frame heights, for recognizing an uncropped master. */
const STANDARD_HEIGHTS: ReadonlyArray<[number, string]> = [
[2160, '2160p'],
[1440, '1440p'],
[1080, '1080p'],
[720, '720p'],
[576, '576p'],
[480, '480p'],
];
function isPositiveNumber(value: number | null | undefined): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
/**
* Derive the quality label from real pixel dimensions.
*
* Width is authoritative because letterboxed masters are cropped vertically:
* a 2.39:1 "1080p" film is 1920×800, and 800 alone is indistinguishable from
* a 1280×800 720p-class encode. Bucketing that by height would emit "720p"
* labelled as a FACT — precisely the kind of confident wrongness this feature
* exists to avoid.
*
* With no width, a height is only trusted when it is (near) a standard frame
* height. Anything else returns null, so the row shows no tag and offers a
* check instead. Empty beats wrong.
*/
/**
* The sub-HD formats, matched rather than bucketed.
*
* Bucketing only survives above 1200, where the standard widths are far apart.
* Below that they are not: 960×540 and 1024×576 are two different formats
* inside what used to be one 900–1199 range, so every 540p encode was published
* as "576p" with `api` provenance — a measurement the pixels contradict. Lower
* still, 800×600 and 800×450 are neither 480p nor each other, and 720 is NTSC
* 480p or PAL 576p depending only on the height.
*
* Anything that is not one of these shapes gets no tag at all, because a guess
* here would be read as a measurement.
*/
/**
* HD and up, as `[minimum width, frame height, label]`.
*
* Ranges are honest here — the standard widths really are far apart — but the
* height still has to agree, for the same reason it does below: a frame TALLER
* than the format is a different shape, not a crop of it.
*/
const WIDE_FORMATS: ReadonlyArray<[number, number, string]> = [
[3400, 2160, '2160p'],
[2400, 1440, '1440p'],
[1700, 1080, '1080p'],
[1200, 720, '720p'],
];
const KNOWN_FORMATS: ReadonlyArray<[number, number, string]> = [
[1024, 576, '576p'],
[960, 540, '540p'],
[854, 480, '480p'],
[720, 576, '576p'],
[720, 480, '480p'],
[640, 360, '360p'],
];
function within5Percent(value: number, reference: number): boolean {
return Math.abs(value - reference) / reference <= 0.05;
}
function knownFormatQuality(
width: number,
height: number | null | undefined
): string | null {
const byWidth = KNOWN_FORMATS.filter(([reference]) =>
within5Percent(width, reference)
);
if (byWidth.length === 0) {
return null;
}
if (byWidth.length > 1) {
// One width, two formats (720): only the height separates them.
if (!isPositiveNumber(height)) {
return null;
}
const exact = byWidth.find(([, reference]) =>
within5Percent(height, reference)
);
return exact ? exact[2] : null;
}
// A single candidate still has to survive the height, when one is known.
// Cropping only ever REMOVES lines, so a shorter frame is a letterboxed
// master of this format and the width still names it — but a TALLER one
// (640x480 against 640x360) is a different shape entirely, and labelling
// it would state a measurement the numbers contradict.
const [, formatHeight, label] = byWidth[0];
if (isPositiveNumber(height) && height > formatHeight * 1.05) {
return null;
}
return label;
}
function qualityFromDimensions(
width: number | null | undefined,
height: number | null | undefined
): string | null {
if (isPositiveNumber(width)) {
const wide = WIDE_FORMATS.find(([minWidth]) => width >= minWidth);
if (wide) {
const [, formatHeight, label] = wide;
// The same rule the matched formats use. Cropping only ever
// REMOVES lines, so a shorter frame is a letterboxed master of this
// format and the width still names it — but a TALLER one is a
// different shape: 1440x1080 is anamorphic 1080 and 1600x900 is
// 900p, and calling either "720p" states a measurement its own
// pixels contradict, under the provenance that means the provider
// said so.
return isPositiveNumber(height) && height > formatHeight * 1.05
? null
: label;
}
return knownFormatQuality(width, height);
}
if (!isPositiveNumber(height)) {
return null;
}
const standard = STANDARD_HEIGHTS.find(
([reference]) => Math.abs(height - reference) / reference <= 0.05
);
return standard ? standard[1] : null;
}
@@ -0,0 +1,66 @@
import { readStreamInfo } from './vod-source-resolver.service';
/**
* Xtream panels disagree about the shape of `info.video` / `info.audio`.
*
* The declared shape is a string array and that is what the mock server and
* many panels send; others send the ffprobe object. Reading only one of them
* loses the provider's codec on every response of the other kind.
*
* What the array shape carries is a CODEC and nothing else — no language — so
* it can never feed the "dub may differ" warning. That is the honest outcome
* rather than a gap: a codec cannot tell one dub from another.
*/
describe('readStreamInfo', () => {
it('reads the declared array shape', () => {
expect(readStreamInfo(['H.264'])).toEqual({ codec_name: 'H.264' });
expect(readStreamInfo(['AAC'])).toEqual({ codec_name: 'AAC' });
});
it('reads the ffprobe object shape, dimensions included', () => {
expect(
readStreamInfo({ codec_name: 'hevc', width: 3840, height: 2160 })
).toEqual({ codec_name: 'hevc', width: 3840, height: 2160 });
});
it('skips blank entries rather than stating an empty codec', () => {
expect(readStreamInfo(['', ' ', 'H.264'])).toEqual({
codec_name: 'H.264',
});
});
it('trims, since the value is published as a fact', () => {
expect(readStreamInfo([' H.264 '])).toEqual({ codec_name: 'H.264' });
});
it('finds no language in the declared array shape', () => {
// The shape the repo's own mock sends. It states a codec, so the dub
// warning stays silent on every source that arrives this way — which
// is most of them, and is correct: AAC says nothing about which dub
// this is.
const audio = readStreamInfo(['AAC']);
expect(audio?.language).toBeUndefined();
expect(audio?.tags?.language).toBeUndefined();
});
it('reads the language out of ffprobe tags when a panel sends them', () => {
expect(
readStreamInfo({ codec_name: 'ac3', tags: { language: 'rus' } })
?.tags?.language
).toBe('rus');
// Some panels hoist it out of `tags`.
expect(
readStreamInfo({ codec_name: 'ac3', language: 'eng' })?.language
).toBe('eng');
});
it('says nothing when the provider said nothing', () => {
// Empty beats wrong: an absent codec must not become a stated one.
expect(readStreamInfo([])).toBeUndefined();
expect(readStreamInfo(['', ' '])).toBeUndefined();
expect(readStreamInfo(undefined)).toBeUndefined();
expect(readStreamInfo(null)).toBeUndefined();
expect(readStreamInfo('H.264')).toBeUndefined();
});
});
@@ -0,0 +1,279 @@
import { inject, Injectable, Injector } from '@angular/core';
import {
XTREAM_DATA_SOURCE,
XtreamApiService,
XtreamUrlService,
type IXtreamDataSource,
type XtreamCredentials,
} from '@iptvnator/portal/xtream/data-access';
import type {
ResolvedPortalPlayback,
VodSourceCandidate,
} from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
applyApiMetadata,
type ProviderVodMetadata,
} from './vod-source-metadata.util';
/**
* Turns a discovered source into something playable.
*
* This exists because discovery alone is not enough: the `content` table has
* no `container_extension` column, and `constructVodUrl` returns '' without
* one. Every alternative therefore costs one live `get_vod_info` against a
* FOREIGN playlist's credentials — which is exactly why resolution is lazy and
* never fanned out on page load.
*
* That same call is also the first moment real facts about a source exist, so
* resolution doubles as the metadata upgrade from `parsed` to `api`.
*/
export interface ResolvedVodSource {
playback: ResolvedPortalPlayback;
/** The candidate with provider-stated facts merged in. */
candidate: VodSourceCandidate;
}
@Injectable({ providedIn: 'root' })
export class VodSourceResolverService {
// Redacting: a failed `get_vod_info` carries the foreign playlist's
// URL, and Xtream puts the username and password in that query string.
private readonly logger = createLogger('VodSourceResolver');
/**
* The Xtream chain is resolved LAZILY, never at construction.
*
* This service is root-provided but its dependencies are route-scoped
* (`XTREAM_DATA_SOURCE` comes from `provideXtreamDataSource()`, and
* `XtreamApiService` pulls in `DataService`). Injecting them eagerly would
* make merely *providing* multi-source throw outside a fully configured
* Xtream route. Resolution only ever happens on an explicit user action —
* play, pin, or check — so deferring costs nothing and cannot explode.
*/
private readonly injector = inject(Injector);
private getXtreamDeps(): {
dataSource: IXtreamDataSource;
api: XtreamApiService;
urlService: XtreamUrlService;
} | null {
const dataSource = this.injector.get(XTREAM_DATA_SOURCE, null, {
optional: true,
});
if (!dataSource) {
return null;
}
return {
dataSource,
api: this.injector.get(XtreamApiService),
urlService: this.injector.get(XtreamUrlService),
};
}
/**
* Containers are immutable per (playlist, stream) and cost a network round
* trip to learn, so the second visit to a movie resolves instantly.
*/
private readonly containerCache = new Map<string, string>();
async resolve(
candidate: VodSourceCandidate,
options: { startTime?: number } = {}
): Promise<ResolvedVodSource | null> {
if (candidate.portalType !== 'xtream') {
// v1 discovers Xtream only; the guard keeps a future Stalker
// candidate from silently resolving to a broken Xtream URL.
return null;
}
const deps = this.getXtreamDeps();
if (!deps) {
return null;
}
const playlist = await this.loadPlaylist(
deps.dataSource,
candidate.playlistId
);
if (!playlist) {
return null;
}
const credentials: XtreamCredentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
allowedOutputFormats: playlist.allowedOutputFormats,
};
const details = await this.loadVodDetails(
deps.api,
credentials,
candidate
);
if (!details) {
return null;
}
const streamUrl = deps.urlService.constructVodUrl(
credentials,
details.vodDetails
);
if (!streamUrl) {
return null;
}
return {
playback: {
streamUrl,
title: candidate.rawTitle,
thumbnail: candidate.posterUrl ?? null,
isLive: false,
startTime: options.startTime,
contentInfo: {
// The NEW source owns the position from here on — the
// position key is (playlistId, contentXtreamId, type).
playlistId: candidate.playlistId,
contentXtreamId: candidate.contentId,
contentType: 'vod',
},
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
candidate: applyApiMetadata(candidate, details.metadata),
};
}
private async loadPlaylist(
dataSource: IXtreamDataSource,
playlistId: string
) {
try {
const playlist = await dataSource.getPlaylist(playlistId);
// A foreign playlist can be missing credentials entirely (an M3U
// row, or a partially imported portal) — resolving would build a
// nonsense URL.
if (!playlist?.serverUrl || !playlist.username) {
return null;
}
return playlist;
} catch (error) {
this.logger.warn('Loading the alternative playlist failed:', error);
return null;
}
}
private async loadVodDetails(
api: XtreamApiService,
credentials: XtreamCredentials,
candidate: VodSourceCandidate
) {
const cacheKey = `${candidate.playlistId}:${candidate.contentId}`;
const cachedContainer = this.containerCache.get(cacheKey);
try {
const vodDetails = await api.getVodInfo(
credentials,
candidate.contentId
);
const container = vodDetails?.movie_data?.container_extension;
if (container) {
this.containerCache.set(cacheKey, container);
}
return {
vodDetails,
metadata: providerVodMetadataOf(vodDetails),
};
} catch (error) {
this.logger.warn('Reading alternative VOD details failed:', error);
// The portal is unreachable right now, but a container learned on
// a previous visit is still valid — enough to build a URL and let
// playback itself report the real problem.
if (!cachedContainer) {
return null;
}
return {
vodDetails: {
movie_data: {
stream_id: candidate.contentId,
container_extension: cachedContainer,
},
} as Parameters<XtreamUrlService['constructVodUrl']>[1],
metadata: { containerExtension: cachedContainer },
};
}
}
}
/**
* Xtream panels disagree about `info.video` / `info.audio`.
*
* The declared shape is a string array (`['H.264']`), and that is what the
* mock server and many panels return; others return the ffprobe object with
* `codec_name`/`width`/`height`. Reading only the object shape silently lost
* the codec on every array-shaped response — so the source rows showed no
* provider-stated codec and the "dub may differ" warning could never fire.
*/
/**
* The provider's own stated facts about one movie, in the shape
* `applyApiMetadata` consumes.
*
* Shared with the ROUTE's source row, which the page can build from details it
* has already loaded. Without it the row the user is switching *from* carries
* no facts at all, so `audioDiffersFactually` has nothing to compare and the
* "dub may differ" warning cannot fire on the commonest switch there is.
*/
export function providerVodMetadataOf(
vodDetails:
| { info?: unknown; movie_data?: { container_extension?: string } }
| null
| undefined
): ProviderVodMetadata {
// A provider can send `info` as an array, which carries no stream data.
const info = Array.isArray(vodDetails?.info)
? undefined
: (vodDetails?.info as
| { video?: unknown; audio?: unknown }
| undefined);
const video = readStreamInfo(info?.video);
const audio = readStreamInfo(info?.audio);
return {
containerExtension: vodDetails?.movie_data?.container_extension ?? null,
videoCodec: video?.codec_name ?? null,
audioCodec: audio?.codec_name ?? null,
// ffprobe puts the language in `tags`; some panels hoist it.
audioLanguage: audio?.tags?.language ?? audio?.language ?? null,
width: video?.width ?? null,
height: video?.height ?? null,
};
}
export function readStreamInfo(value: unknown):
| {
codec_name?: string;
width?: number;
height?: number;
language?: string;
tags?: { language?: string };
}
| undefined {
if (Array.isArray(value)) {
const codec = value.find(
(entry): entry is string =>
typeof entry === 'string' && entry.trim() !== ''
);
return codec ? { codec_name: codec.trim() } : undefined;
}
return typeof value === 'object' && value !== null
? (value as { codec_name?: string; width?: number; height?: number })
: undefined;
}
@@ -1,5 +1,8 @@
import { signal } from '@angular/core';
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import type {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { createExternalPlaybackButtonState } from './external-playback-button-state';
function session(
@@ -135,4 +138,73 @@ describe('createExternalPlaybackButtonState', () => {
expect(api.matchedSession()).toBeNull();
});
it('also owns a session launched for another playlist it switched to', () => {
// Multi-source: same film, other playlist, other stream id. Without
// this the page shows Play while its own switch is streaming, and
// clicking it opens a second player instead of stopping the first.
const api = createExternalPlaybackButtonState({
session: signal<ExternalPlayerSession | null>(
session({
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
})
),
playlistId: signal<string | null>('playlist-1'),
contentId: signal<number | null>(42),
alsoOwns: signal<PlayerContentInfo | null>({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
}),
});
expect(api.matchedSession()).not.toBeNull();
expect(api.isStopAction()).toBe(true);
});
it('disowns that session once playback moved back', () => {
const api = createExternalPlaybackButtonState({
session: signal<ExternalPlayerSession | null>(
session({
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
})
),
playlistId: signal<string | null>('playlist-1'),
contentId: signal<number | null>(42),
alsoOwns: signal<PlayerContentInfo | null>(null),
});
expect(api.matchedSession()).toBeNull();
});
it('still checks the content type of an owned alternative', () => {
const api = createExternalPlaybackButtonState({
session: signal<ExternalPlayerSession | null>(
session({
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'episode',
},
})
),
playlistId: signal<string | null>('playlist-1'),
contentId: signal<number | null>(42),
alsoOwns: signal<PlayerContentInfo | null>({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
}),
});
expect(api.matchedSession()).toBeNull();
});
});
@@ -1,5 +1,8 @@
import { computed, type Signal } from '@angular/core';
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import type {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
/**
* Derives the primary Play/Stop button's state from the active external
@@ -21,6 +24,15 @@ export interface ExternalPlaybackButtonStateConfig {
contentId: Signal<number | null | undefined>;
/** Defaults to `'vod'`. */
contentType?: 'vod' | 'episode';
/**
* A second pair of ids this page also owns, if any.
*
* Multi-source can launch an external player on a copy of the same film in
* ANOTHER playlist, and the session then carries that playlist's ids.
* Without this the page shows Play while its own switch is streaming, and
* the user has no way to stop it from here.
*/
alsoOwns?: Signal<PlayerContentInfo | null>;
}
export interface ExternalPlaybackButtonStateApi {
@@ -53,15 +65,19 @@ export function createExternalPlaybackButtonState(
}
const info = session.contentInfo;
if (
info.playlistId !== config.playlistId() ||
info.contentType !== contentType ||
info.contentXtreamId !== config.contentId()
) {
if (info.contentType !== contentType) {
return null;
}
return session;
const alsoOwns = config.alsoOwns?.();
const owned =
(info.playlistId === config.playlistId() &&
info.contentXtreamId === config.contentId()) ||
(!!alsoOwns &&
info.playlistId === alsoOwns.playlistId &&
info.contentXtreamId === alsoOwns.contentXtreamId);
return owned ? session : null;
});
const primaryLabel = computed(() => {
@@ -110,4 +110,76 @@ describe('createInlinePlaybackPositionWriter', () => {
})
);
});
describe('resume latch', () => {
function resuming(startTime: number): ResolvedPortalPlayback {
return { ...playbackWithInfo(), startTime };
}
it('drops updates emitted before the engine reaches startTime', () => {
// A resuming engine ticks at ~0 while it is still seeking. Writing
// one of those overwrites the very position being resumed from.
const { writer, save } = setup(resuming(2538));
expect(
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 })
).toBe(false);
expect(save).not.toHaveBeenCalled();
});
it('releases once, so a later seek backwards is still saved', () => {
jest.useFakeTimers();
const { writer, save } = setup(resuming(2538));
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 });
// Reaching the resume point latches the guard open for good.
writer.handleTimeUpdate({ currentTime: 2540, duration: 7744 });
jest.advanceTimersByTime(16000);
expect(
writer.handleTimeUpdate({ currentTime: 30, duration: 7744 })
).toBe(true);
expect(save).toHaveBeenLastCalledWith(
'playlist-1',
expect.objectContaining({ positionSeconds: 30 })
);
});
it('gives up on a resume point the source cannot reach', () => {
// Two hours carried into a 90-minute cut: the engine can never
// report that time, so latching on it would suppress every save
// for the whole session.
const { writer, save } = setup(resuming(7200));
expect(
writer.handleTimeUpdate({ currentTime: 12, duration: 5400 })
).toBe(true);
expect(save).toHaveBeenCalled();
});
it('keeps the latch closed across an update with no contentInfo', () => {
const { writer, playback } = setup(resuming(2538));
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 });
playback.set({ streamUrl: 'http://example.com/x.mkv', title: 'X' });
// Reporting `true` here would tell multi-source that a timecode of
// ~0 can be carried to the next source.
expect(
writer.handleTimeUpdate({ currentTime: 13, duration: 7744 })
).toBe(false);
});
it('is cleared by reset, so a new resume waits again', () => {
const { writer, playback } = setup();
writer.handleTimeUpdate({ currentTime: 40, duration: 7744 });
playback.set(resuming(2538));
writer.reset();
expect(
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 })
).toBe(false);
});
});
});
@@ -12,12 +12,17 @@ import type {
* behaves differently per portal is exactly the kind of divergence users
* notice.
*
* Deliberately behaviour-identical to the two implementations it replaces.
* Beyond that throttle it holds one resume latch, because a position written
* before the engine reaches `startTime` overwrites the point being resumed
* from — a bug both copies had.
*/
/** The player fires ~4x/second; persisting that often would hammer SQLite. */
const DEFAULT_THROTTLE_MS = 15000;
/** Engines land near, not exactly on, the requested `startTime`. */
const RESUME_TOLERANCE_SECONDS = 5;
export interface InlinePlaybackPositionWriterConfig {
/** The playback currently mounted in the inline player. */
playback: Signal<ResolvedPortalPlayback | null>;
@@ -28,8 +33,14 @@ export interface InlinePlaybackPositionWriterConfig {
}
export interface InlinePlaybackPositionWriter {
handleTimeUpdate(event: { currentTime: number; duration: number }): void;
/** Clears the throttle so the next update is written immediately. */
/**
* @returns whether `currentTime` can be believed yet — false while the
* engine is still on its way to `startTime`. Multi-source switching needs
* the same answer to carry a timecode across, and one latch has to serve
* both or the two disagree about where playback is.
*/
handleTimeUpdate(event: { currentTime: number; duration: number }): boolean;
/** Clears the throttle and the resume latch for a new playback. */
reset(): void;
}
@@ -38,18 +49,46 @@ export function createInlinePlaybackPositionWriter(
): InlinePlaybackPositionWriter {
const throttleMs = config.throttleMs ?? DEFAULT_THROTTLE_MS;
let lastSaveTime = 0;
/** Cleared on every start; latches once playback reaches `startTime`. */
let resumeSettled = false;
return {
handleTimeUpdate(event) {
const playback = config.playback();
// Without contentInfo there is no key to store the position under.
// The latch never ran, so the answer is whatever it already was —
// not a fresh "no".
if (!playback?.contentInfo) {
return;
return resumeSettled;
}
// A resuming engine can emit timeupdates at ~0 before it finishes
// seeking to startTime. Writing one would overwrite the very
// position being resumed from — which a source switch then
// inherits, restarting the film from the beginning.
//
// This is a one-shot latch, not a filter: once playback has
// reached the resume point the guard is done, so a deliberate seek
// backwards is still saved normally.
if (!resumeSettled) {
const startTime = playback.startTime ?? 0;
// A shorter cut can never reach a position carried into it,
// and latching on that would suppress every save this session.
const reachable =
!(event.duration > 0) || startTime < event.duration;
if (
reachable &&
startTime > 0 &&
event.currentTime < startTime - RESUME_TOLERANCE_SECONDS
) {
return false;
}
resumeSettled = true;
}
const now = Date.now();
if (now - lastSaveTime <= throttleMs) {
return;
return true;
}
lastSaveTime = now;
@@ -61,10 +100,12 @@ export function createInlinePlaybackPositionWriter(
config.save(playback.contentInfo.playlistId, position);
config.onSaved?.(position);
return true;
},
reset() {
lastSaveTime = 0;
resumeSettled = false;
},
};
}
@@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing';
import {
DatabaseService,
PlaybackPositionService,
VodSourcePinService,
XtreamPendingRestoreService,
} from '@iptvnator/services';
import {
@@ -56,6 +57,13 @@ export function createDbServiceMock() {
};
}
export function createVodSourcePinServiceMock() {
return {
listForPlaylist: jest.fn().mockResolvedValue([]),
set: jest.fn().mockResolvedValue(true),
};
}
export function createPlaybackServiceMock() {
return {
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
@@ -85,6 +93,7 @@ export interface ElectronXtreamDataSourceHarness {
dataSource: ElectronXtreamDataSource;
dbService: ReturnType<typeof createDbServiceMock>;
playbackService: ReturnType<typeof createPlaybackServiceMock>;
vodSourcePinService: ReturnType<typeof createVodSourcePinServiceMock>;
pendingRestoreService: ReturnType<typeof createPendingRestoreServiceMock>;
apiService: ReturnType<typeof createApiServiceMock>;
}
@@ -92,6 +101,7 @@ export interface ElectronXtreamDataSourceHarness {
export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness {
const dbService = createDbServiceMock();
const playbackService = createPlaybackServiceMock();
const vodSourcePinService = createVodSourcePinServiceMock();
const pendingRestoreService = createPendingRestoreServiceMock();
const apiService = createApiServiceMock();
@@ -100,6 +110,10 @@ export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness
ElectronXtreamDataSource,
{ provide: DatabaseService, useValue: dbService },
{ provide: PlaybackPositionService, useValue: playbackService },
{
provide: VodSourcePinService,
useValue: vodSourcePinService,
},
{
provide: XtreamPendingRestoreService,
useValue: pendingRestoreService,
@@ -112,6 +126,7 @@ export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness
dataSource: TestBed.inject(ElectronXtreamDataSource),
dbService,
playbackService,
vodSourcePinService,
pendingRestoreService,
apiService,
};
@@ -4,6 +4,7 @@ import {
PlaybackPositionService,
XtreamPendingRestoreService,
XtreamImportStatus,
VodSourcePinService,
} from '@iptvnator/services';
import {
PlaybackPositionData,
@@ -39,6 +40,7 @@ import {
export class ElectronXtreamDataSource implements IXtreamDataSource {
private readonly dbService = inject(DatabaseService);
private readonly playbackService = inject(PlaybackPositionService);
private readonly vodSourcePinService = inject(VodSourcePinService);
private readonly pendingRestoreService = inject(
XtreamPendingRestoreService
);
@@ -540,9 +542,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
async clearPlaylistContent(
playlistId: string
): Promise<XtreamPendingRestoreState> {
const [result, playbackPositions] = await Promise.all([
const [result, playbackPositions, sourcePins] = await Promise.all([
this.dbService.deleteXtreamPlaylistContent(playlistId),
this.playbackService.getAllPlaybackPositions(playlistId),
this.vodSourcePinService.listForPlaylist(playlistId),
]);
return {
@@ -550,6 +553,11 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
favorites: result.favorites,
recentlyViewed: result.recentlyViewed,
playbackPositions,
sourcePins: sourcePins.map((pin) => ({
matchKey: pin.matchKey,
contentId: pin.contentId,
...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}),
})),
};
}
@@ -573,5 +581,27 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playbackPosition
);
}
// The fresh-import path lands here rather than in the backup service:
// a new playlist has no content yet when the archive is read, so its
// user state is parked and applied once the import finishes.
for (const pin of restoreState.sourcePins ?? []) {
const written = await this.vodSourcePinService.set({
matchKey: pin.matchKey,
playlistId,
contentId: pin.contentId,
portalType: 'xtream',
...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}),
});
// Throwing keeps the pending state for a later retry — the caller
// only clears it when this resolves. Dropping it here would lose
// the preference with the import still reporting success.
if (!written) {
throw new Error(
`Restoring the pinned source for "${pin.matchKey}" failed.`
);
}
}
}
}
@@ -194,9 +194,57 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
favorites,
recentlyViewed,
playbackPositions: [position],
sourcePins: [],
});
});
it('applies parked source pins against the imported playlist', async () => {
// The fresh-import path: a new playlist has no content when the
// archive is read, so its user state is parked and replayed here.
// Without this the backup's pins are dropped for every new import.
const restoreState = {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
sourcePins: [
{
matchKey: 'tmdb:603',
contentId: 501,
updatedAt: '2026-07-06T09:00:00.000Z',
},
],
} as never;
await harness.dataSource.restoreUserData(playlistId, restoreState);
expect(harness.vodSourcePinService.set).toHaveBeenCalledWith({
matchKey: 'tmdb:603',
playlistId,
contentId: 501,
portalType: 'xtream',
updatedAt: '2026-07-06T09:00:00.000Z',
});
});
it('keeps the pending state when a pin cannot be written', async () => {
harness.vodSourcePinService.set.mockResolvedValue(false);
const restoreState = {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
sourcePins: [{ matchKey: 'tmdb:603', contentId: 501 }],
} as never;
// The caller clears the parked state only when this resolves, so
// resolving here would drop the preference on a transient DB
// failure while the import still reported success.
await expect(
harness.dataSource.restoreUserData(playlistId, restoreState)
).rejects.toThrow('tmdb:603');
});
it('restores user data, then resets and replays playback positions', async () => {
const positionA = { contentXtreamId: 1 } as never;
const positionB = { contentXtreamId: 2 } as never;
@@ -0,0 +1,130 @@
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { DownloadsService } from '@iptvnator/services';
import { resolveXtreamVodPlaybackSource } from '@iptvnator/portal/xtream/data-access';
import { XtreamVodDetails } from '@iptvnator/shared/interfaces';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
/**
* Offline downloads for the movie on screen.
*
* Every read re-touches `downloads()` so the computed tracks the signal the
* service updates — the per-item helpers are plain lookups and would not
* otherwise re-run when a download's state changes.
*/
@Injectable()
export class VodDetailsDownloadsService {
private readonly downloadsService = inject(DownloadsService);
private readonly xtreamStore = inject(XtreamStore);
private routeContentId: Signal<number> = signal(NaN);
bind(bindings: { routeContentId: Signal<number> }): void {
this.routeContentId = bindings.routeContentId;
}
private readonly context = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
// Read so the state below re-derives as downloads progress.
this.downloadsService.downloads();
return playlistId ? { playlistId, vodId: this.routeContentId() } : null;
});
readonly isDownloaded = computed(() => {
const context = this.context();
return context
? this.downloadsService.isDownloaded(
context.vodId,
context.playlistId,
'vod'
)
: false;
});
readonly isDownloading = computed(() => {
const context = this.context();
return context
? this.downloadsService.isDownloading(
context.vodId,
context.playlistId,
'vod'
)
: false;
});
readonly isPausedDownload = computed(() => {
const context = this.context();
return context
? this.downloadsService.isPaused(
context.vodId,
context.playlistId,
'vod'
)
: false;
});
async resumePaused(): Promise<void> {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
await this.downloadsService.resumeDownloadByContent(
this.routeContentId(),
playlistId,
'vod'
);
}
async start(vodItem: XtreamVodDetails | null): Promise<void> {
const playlist = this.xtreamStore.currentPlaylist();
if (!vodItem || !playlist) {
return;
}
// A provider can omit the route id entirely (sparse details), and the
// resolved source is then the only place the stream id exists.
const source = resolveXtreamVodPlaybackSource(vodItem);
if (!source) {
return;
}
const presentation = resolveXtreamVodPlaybackPresentation(vodItem);
const routeVodId = this.routeContentId();
const id =
Number.isSafeInteger(routeVodId) && routeVodId > 0
? routeVodId
: source.streamId;
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: id,
contentType: 'vod',
title: presentation.title,
url: this.xtreamStore.constructVodStreamUrl(vodItem),
posterUrl: presentation.posterUrl,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
});
}
async playLocal(): Promise<void> {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
const filePath = this.downloadsService.getDownloadedFilePath(
this.routeContentId(),
playlistId,
'vod'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
}
}
@@ -0,0 +1,267 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import type {
PlaybackPositionData,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
/**
* Which external session this page owns.
*
* Multi-source can launch MPV/VLC for a movie in ANOTHER playlist, and the
* session then carries that playlist's ids — so the matcher decides whether
* the primary button can stop it or silently launches a second player.
*/
/**
* Which external process this page owns, and what replacing it entails.
* Split from the position/ownership spec to keep both inside the file-size
* rule.
*/
describe('VodDetailsPlaybackService — external playback handoff', () => {
const ROUTE_PLAYLIST = 'playlist-1';
const ROUTE_VOD_ID = 650020;
let service: VodDetailsPlaybackService;
/** The bridge callback the service registers at construction. */
let positionListener: ((data: PlaybackPositionData) => void) | undefined;
const addRecentItem = jest.fn();
const activeSession = signal<unknown>(null);
const closeSession = jest.fn().mockResolvedValue(undefined);
const openResolvedPlayback = jest.fn();
const activeSource = signal<PlayerContentInfo | null>(null);
function sessionFor(playlistId: string, contentXtreamId: number) {
return {
player: 'mpv',
status: 'playing',
contentInfo: {
playlistId,
contentXtreamId,
contentType: 'vod' as const,
},
};
}
beforeEach(() => {
activeSession.set(null);
activeSource.set(null);
positionListener = undefined;
addRecentItem.mockClear();
TestBed.configureTestingModule({
providers: [
VodDetailsPlaybackService,
{
provide: XtreamStore,
useValue: {
currentPlaylist: signal({ id: ROUTE_PLAYLIST }),
addRecentItem,
constructVodStreamUrl: jest
.fn()
.mockReturnValue('https://example.com/route.mkv'),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession, closeSession },
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition: jest.fn(),
savePlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback,
},
},
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: {
onPlaybackPositionUpdate: (
listener: (data: PlaybackPositionData) => void
) => {
positionListener = listener;
return () => undefined;
},
},
},
],
});
service = TestBed.inject(VodDetailsPlaybackService);
service.bind({
vodId: signal(ROUTE_VOD_ID),
vodInfo: signal(null),
activeSource,
});
});
it('closes the alternative it launched, once the badge has moved on', async () => {
// The controller marks the DESTINATION active before playback is
// handed over, so by the time the switch reaches the service the
// running process no longer looks like "ours" — and was left playing
// beside its replacement.
activeSession.set(sessionFor('playlist-2', 991));
activeSource.set({
playlistId: 'playlist-3',
contentXtreamId: 77,
contentType: 'vod',
});
closeSession.mockClear();
// Pretend the alternative we are replacing is the one we started.
await service.startResolvedPlayback({
streamUrl: 'https://example.com/first.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
});
closeSession.mockClear();
await service.startResolvedPlayback({
streamUrl: 'https://example.com/second.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-3',
contentXtreamId: 77,
contentType: 'vod',
},
});
expect(closeSession).toHaveBeenCalledWith(
expect.objectContaining({
contentInfo: expect.objectContaining({
contentXtreamId: 991,
}),
})
);
});
it('still starts the replacement when closing the old player fails', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
closeSession.mockRejectedValue(new Error('close ipc failed'));
openResolvedPlayback.mockClear();
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
});
// The switch is already committed — the badge names the new source.
// Bailing out here left the page claiming a source with nothing
// started at all.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
closeSession.mockResolvedValue(undefined);
});
it('launches only the newest source when two switches overlap', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
// One shared promise: both calls see the same running session, so
// both await the same close rather than each making its own.
let releaseClose: (() => void) | undefined;
const closing = new Promise<void>((resolve) => {
releaseClose = () => resolve();
});
closeSession.mockReturnValue(closing);
// These spies are module-level and never cleared between cases.
openResolvedPlayback.mockClear();
const first = service.startResolvedPlayback({
streamUrl: 'https://example.com/one.mkv',
title: 'Example Movie',
});
const second = service.startResolvedPlayback({
streamUrl: 'https://example.com/two.mkv',
title: 'Example Movie',
});
releaseClose?.();
await Promise.all([first, second]);
// Both saw the same running session and awaited its close. Launching
// both afterwards is how two detached players appear again, with the
// older one holding a source the user has already moved on from.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/two.mkv',
}),
true
);
closeSession.mockResolvedValue(undefined);
});
it('drops a switch that a plain Play overtook', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
let releaseClose: (() => void) | undefined;
const closing = new Promise<void>((resolve) => {
releaseClose = () => resolve();
});
closeSession.mockReturnValue(closing);
openResolvedPlayback.mockClear();
const switching = service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
});
// The user presses Play on the route copy while that close is pending.
service.playVod({
movie_data: {
stream_id: ROUTE_VOD_ID,
name: 'Example Movie',
container_extension: 'mp4',
},
} as never);
releaseClose?.();
await switching;
// Only the route copy may be playing; the switch was overtaken.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
expect(openResolvedPlayback).not.toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/alt.mkv',
}),
true
);
closeSession.mockResolvedValue(undefined);
});
it('stops the running external player before switching sources', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
});
// A switch REPLACES what is playing. With MPV/VLC and instance reuse
// off the backend spawns a second detached player otherwise: both
// sources keep running, and Stop owns only the newer one.
expect(closeSession).toHaveBeenCalled();
});
});
@@ -0,0 +1,108 @@
import type {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
/**
* Which external player process, and which position rows, belong to this page.
*
* Multi-source makes both questions harder than they look: playback can be on
* a copy of the film in ANOTHER playlist, whose ids the session and the
* position rows then carry, and during a switch the controller has already
* moved "active" to the destination before playback is handed over.
*/
/** Ids this page owns, beyond the route's own copy. */
export interface OwnedContentIds {
routePlaylistId: string | undefined;
routeContentId: number | undefined;
/** The alternative currently in use, if it is not the route's own. */
alternative: PlayerContentInfo | null;
}
/**
* Whether a position update refers to content this page owns.
*
* An absent playlist id must never match an absent current playlist, or
* `undefined === undefined` would adopt a stranger's progress.
*/
export function ownsContent(
info:
| {
playlistId?: string;
contentXtreamId?: number;
contentType?: string;
}
| undefined,
owned: OwnedContentIds
): boolean {
if (!info?.playlistId || info.contentType !== 'vod') {
return false;
}
const { alternative } = owned;
return (
(info.playlistId === owned.routePlaylistId &&
info.contentXtreamId === owned.routeContentId) ||
(!!alternative &&
info.playlistId === alternative.playlistId &&
info.contentXtreamId === alternative.contentXtreamId)
);
}
/**
* The external process this page started, if it is still up.
*
* Matched on the ids we LAUNCHED with rather than on what is active now: a
* switch marks the destination active before handing playback over, so asking
* "is this session ours?" at that moment answers no and leaves the running
* process playing beside its replacement.
*/
export function runningExternalSession(
session: ExternalPlayerSession | null,
launched: PlayerContentInfo | null,
matched: ExternalPlayerSession | null
): ExternalPlayerSession | null {
if (
!session?.contentInfo ||
session.status === 'closed' ||
session.status === 'error'
) {
return null;
}
const info = session.contentInfo;
const isLaunchedOne =
!!launched &&
info.playlistId === launched.playlistId &&
info.contentXtreamId === launched.contentXtreamId;
return isLaunchedOne ? session : matched;
}
/**
* Close the running external player before its replacement starts.
*
* A failure is logged rather than propagated: the caller has already
* committed the switch, so refusing to launch would leave the page naming a
* source with nothing playing — worse than a possibly-lingering process, and
* a close that rejects usually means the session was gone already.
*/
export async function closeRunningExternalSession(
session: ExternalPlayerSession | null,
close: (session: ExternalPlayerSession) => Promise<void>,
warn: (message: string, error: unknown) => void
): Promise<void> {
if (!session) {
return;
}
try {
await close(session);
} catch (error) {
warn(
'Closing the previous external player failed; starting the replacement anyway.',
error
);
}
}
@@ -0,0 +1,329 @@
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { SettingsStore } from '@iptvnator/services';
import {
PlaybackPositionData,
PlayerContentInfo,
playlistDisplayLabel,
reportsPlaybackFailures,
type VodSourceCandidate,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import {
createPrimaryActionPosition,
formatPlaybackPosition,
isResumablePosition,
} from './vod-primary-action-position';
/**
* The VOD details route's multi-source concerns.
*
* Everything here answers one of two questions: what is TRUE about playback
* right now, and what does the primary button act on. Both are easy to get
* subtly wrong — the controller's "active source" means selected rather than
* playing, and positions are keyed per (playlist, stream) so the row the page
* loaded is not necessarily the one a click will start.
*
* Component-provided, like the host service whose session it reads.
*/
@Injectable()
export class VodDetailsMultiSourceUiService {
private readonly multiSource = inject(VodMultiSourceHostService);
private readonly playback = inject(VodDetailsPlaybackService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly xtreamStore = inject(XtreamStore);
private readonly settingsStore = inject(SettingsStore);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
/** The vod id the route is showing; bound once by the host component. */
private routeContentId: Signal<number> = signal(NaN);
/** The movie identity, for the sources popover header. */
private movieTitle: Signal<string> = signal('');
bind(bindings: {
routeContentId: Signal<number>;
movieTitle: Signal<string>;
}): void {
this.routeContentId = bindings.routeContentId;
this.movieTitle = bindings.movieTitle;
}
/** True from a playback failure until something plays again. */
private readonly playbackFailed = signal(false);
/**
* Cleared on every start; set by the first timeupdate.
*
* `inlinePlayback()` is the REQUEST to play — it is non-null while the
* engine is still opening the stream, and stays non-null after it fails.
* A timeupdate is the engine reporting that it is producing frames, which
* is the only evidence the page has that something is really playing.
*/
private readonly inlineTimeSeen = signal(false);
/**
* Whether a stream is on screen right now.
*
* Both the "Playing from" caption and the row's Playing badge are claims
* about the present, and discovery marks a source active the moment the
* page opens — long before anything plays, and again after the player is
* closed. Gating both on this keeps them from lying.
*/
readonly playbackLive = computed(() => {
if (this.playback.inlinePlayback()) {
return !this.playbackFailed() && this.inlineTimeSeen();
}
// The external player is a window of its own: once it is open the film
// is on screen, and only 'launching' means it is not there yet.
const status = this.playback.matchedExternalPlayback()?.status;
return status === 'opened' || status === 'playing';
});
/**
* The source playing when it is NOT the route's own.
*
* Both halves of the comparison matter: a pinned copy can live in the
* route's OWN playlist, and matching on playlist alone would call it "the
* route source" — losing the Stop state and every position update for it.
*/
readonly activeAlternativeSource = computed<PlayerContentInfo | null>(
() => {
const active = this.multiSource
.sources()
.find((source) => source.isActive);
const routePlaylistId = this.xtreamStore.currentPlaylist()?.id;
if (
!active ||
(active.playlistId === routePlaylistId &&
active.contentId === this.routeContentId())
) {
return null;
}
return {
playlistId: active.playlistId,
contentXtreamId: active.contentId,
contentType: 'vod' as const,
};
}
);
private readonly primaryAction = createPrimaryActionPosition({
sources: this.multiSource.sources,
routePlaylistId: computed(() => this.xtreamStore.currentPlaylist()?.id),
routeContentId: computed(() => this.routeContentId()),
routePosition: this.playback.routePlaybackPosition,
livePosition: this.playback.vodPlaybackPosition,
load: (source) => this.positionFor(source),
});
readonly hasPlaybackPosition = this.primaryAction.hasPosition;
formatPosition(): string {
return formatPlaybackPosition(this.primaryAction.position());
}
/**
* Where a specific source was last watched.
*
* Positions are keyed by (playlist, stream), so a pinned alternative has
* its own row — the one this page loaded belongs to the route's copy.
*/
readonly positionFor = (
source: VodSourceCandidate | VodSourceDescriptor
): Promise<PlaybackPositionData | null> =>
this.playbackPositions.getPlaybackPosition(
source.playlistId,
source.contentId,
'vod'
);
/**
* The seconds a pinned play should resume from — `null` once the copy has
* been watched through, so a row the button offers as Play does not seek
* back to where it ended.
*/
readonly resumeSecondsFor = async (
source: VodSourceCandidate
): Promise<number | null> => {
const position = await this.positionFor(source);
return isResumablePosition(position)
? (position?.positionSeconds ?? null)
: null;
};
/** True when the primary button acts on a pinned copy, not the route's. */
readonly primaryIsPinnedCopy = computed(
() => this.primaryAction.foreignPin() !== null
);
/** Title shown in the sources popover header. */
readonly multiSourceTitle = computed(() => this.movieTitle());
/** The ".srcbar" caption under the action row: where this is playing from. */
readonly activeSourceCaption = computed(() => {
const active = this.multiSource
.sources()
.find((source) => source.isActive);
// "Playing from" only while something actually is. Discovery marks a
// source active as soon as the page opens, so gating on that alone
// makes the line a claim about a player that has not started, one the
// user has since closed, or one that failed and is showing an error.
if (!active || !this.playbackLive()) {
return null;
}
// Only FACTS reach the caption. A guessed quality would read as a
// claim about the stream the user is watching right now.
const facts = [
active.quality?.provenance !== 'parsed'
? active.quality?.value
: null,
active.container?.provenance !== 'parsed'
? active.container?.value
: null,
].filter(Boolean);
return {
// Never the raw playlist name: it is routinely the pasted URL,
// credentials and all, and this line sits in the open on every
// screenshot of the detail page.
source: [
playlistDisplayLabel(active.playlistName, active.playlistId),
...facts,
].join(' · '),
// The caption speaks of PLAYLISTS ("also found in 2 others"), so
// three copies inside one portal must not read as three portals.
alternativeCount: this.multiSource.alternativePlaylistCount(),
};
});
/**
* Only the built-in web players raise a playback diagnostic, so on MPV,
* VLC or Embedded MPV nothing would ever call `onPlaybackFailed()`. The
* toggle is hidden there rather than left promising a switch that cannot
* happen.
*/
readonly autoFailoverSupported = computed(() =>
reportsPlaybackFailures(this.settingsStore.player?.())
);
/** A new stream is starting: nothing yet vouches for it. */
beginPlayback(): void {
this.playbackFailed.set(false);
this.inlineTimeSeen.set(false);
}
/** Route reuse — the previous movie's evidence must not carry over. */
reset(): void {
this.inlineTimeSeen.set(false);
}
playFromSource(sourceId: string): void {
// Only once the switch actually starts something. A source picked off
// the error screen that cannot be resolved leaves the diagnostic up,
// and clearing eagerly would have the caption claim playback again.
void this.multiSource.play(sourceId).then((switched) => {
if (switched) {
this.playbackFailed.set(false);
}
});
}
pinSource(sourceId: string): void {
void this.multiSource.togglePin(sourceId);
}
checkSource(sourceId: string): void {
void this.multiSource.check(sourceId);
}
setAutoFailover(enabled: boolean): void {
// `updateSettings` patches memory first and REJECTS if the write
// fails, so without this the toggle looks saved, reverts on restart,
// and the rejection surfaces only as an unhandled promise.
this.settingsStore
.updateSettings({ vodAutoFailover: enabled })
.catch(() =>
this.snackBar.open(
this.translateService.instant(
'SETTINGS.SETTINGS_SAVE_FAILED'
),
this.translateService.instant('CLOSE'),
{ duration: 10000 }
)
);
}
/**
* A source failed. With auto-failover on we move to the best untried
* source and ANNOUNCE it; otherwise the player's own error overlay — which
* is already showing the alternatives — is left to do its job.
*/
async onPlaybackFailed(): Promise<void> {
// The error screen is up: nothing is playing from anywhere until a
// source actually starts again.
this.playbackFailed.set(true);
const notice = await this.multiSource.failover();
if (!notice) {
return;
}
this.snackBar
.open(
this.translateService.instant(
'PORTALS.MULTI_SOURCE.SWITCHED_TO',
{ playlist: notice.playlistName }
) +
(notice.audioMayDiffer
? ` — ${this.translateService.instant(
'PORTALS.MULTI_SOURCE.SWITCH_AUDIO_WARNING'
)}`
: ''),
this.translateService.instant('PORTALS.MULTI_SOURCE.UNDO'),
{ duration: 10000 }
)
.onAction()
.subscribe(() => this.undoFailover());
}
/** Return to the source that was playing before the automatic switch. */
private undoFailover(): void {
const previousId = this.multiSource.previousSourceId();
if (previousId) {
void this.multiSource.play(previousId);
}
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
// The engine is producing time, so whatever failed before is over.
this.playbackFailed.set(false);
this.inlineTimeSeen.set(true);
const settled = this.playback.handleInlineTimeUpdate(event);
// Ahead of the service's 15s persistence throttle, so a source switch
// resumes from where playback actually is rather than up to 15s back.
//
// Until the engine has finished seeking to the resume point it reports
// ~0, and that is not where the film is — it is where it has not got
// to yet. Feeding it to multi-source would make a switch or a failure
// during those first seconds restart the movie from the beginning, so
// the position we asked the engine for stands in until it arrives.
this.multiSource.reportPosition(
settled
? event.currentTime
: (this.playback.inlinePlayback()?.startTime ?? 0)
);
}
}
@@ -0,0 +1,264 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import type {
PlaybackPositionData,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
/**
* Which external session this page owns.
*
* Multi-source can launch MPV/VLC for a movie in ANOTHER playlist, and the
* session then carries that playlist's ids — so the matcher decides whether
* the primary button can stop it or silently launches a second player.
*/
describe('VodDetailsPlaybackService — external session ownership', () => {
const ROUTE_PLAYLIST = 'playlist-1';
const ROUTE_VOD_ID = 650020;
let service: VodDetailsPlaybackService;
/** The bridge callback the service registers at construction. */
let positionListener: ((data: PlaybackPositionData) => void) | undefined;
const addRecentItem = jest.fn();
const activeSession = signal<unknown>(null);
const closeSession = jest.fn().mockResolvedValue(undefined);
const openResolvedPlayback = jest.fn();
const activeSource = signal<PlayerContentInfo | null>(null);
function sessionFor(playlistId: string, contentXtreamId: number) {
return {
player: 'mpv',
status: 'playing',
contentInfo: {
playlistId,
contentXtreamId,
contentType: 'vod' as const,
},
};
}
beforeEach(() => {
activeSession.set(null);
activeSource.set(null);
positionListener = undefined;
addRecentItem.mockClear();
TestBed.configureTestingModule({
providers: [
VodDetailsPlaybackService,
{
provide: XtreamStore,
useValue: {
currentPlaylist: signal({ id: ROUTE_PLAYLIST }),
addRecentItem,
constructVodStreamUrl: jest
.fn()
.mockReturnValue('https://example.com/route.mkv'),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession, closeSession },
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition: jest.fn(),
savePlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback,
},
},
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: {
onPlaybackPositionUpdate: (
listener: (data: PlaybackPositionData) => void
) => {
positionListener = listener;
return () => undefined;
},
},
},
],
});
service = TestBed.inject(VodDetailsPlaybackService);
service.bind({
vodId: signal(ROUTE_VOD_ID),
vodInfo: signal(null),
activeSource,
});
});
it('owns a session launched for the route’s own stream', () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
expect(service.matchedExternalPlayback()).not.toBeNull();
expect(service.isExternalStopAction()).toBe(true);
});
it('owns a session launched for the alternative it switched to', () => {
// Same movie, other playlist, other stream id. Before this the page
// disowned its own session: the button never became Stop, stopping
// found nothing to stop, and another click opened a second player.
activeSource.set({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
});
activeSession.set(sessionFor('playlist-2', 991));
expect(service.matchedExternalPlayback()).not.toBeNull();
expect(service.isExternalStopAction()).toBe(true);
});
it('disowns a session belonging to some other movie entirely', () => {
activeSource.set({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
});
activeSession.set(sessionFor('playlist-3', 12345));
expect(service.matchedExternalPlayback()).toBeNull();
expect(service.isExternalStopAction()).toBe(false);
});
it('disowns an alternative session once playback moved back', () => {
activeSession.set(sessionFor('playlist-2', 991));
// No active alternative: the switch was undone, so that session is
// no longer this page's to stop.
expect(service.matchedExternalPlayback()).toBeNull();
});
it('records a source started through multi-source as recently viewed', async () => {
// Playing an alternative from the picker, or letting a pin decide the
// primary Play, is still watching the movie — it belongs in Recently
// Viewed exactly as an ordinary Play does.
//
// Awaited: the handoff always yields once now, because replacing a
// running external player is a round-trip even when there is none.
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
});
expect(addRecentItem).toHaveBeenCalled();
});
it('gives up on a resume point the source cannot reach', () => {
// Carrying a two-hour position into a 90-minute cut: the engine can
// never report that time, so latching on it would suppress every save
// for the whole session and keep re-reporting the impossible start.
service.inlinePlayback.set({
streamUrl: 'https://example.com/short-cut.mkv',
title: 'Example Movie',
startTime: 7200,
contentInfo: {
playlistId: ROUTE_PLAYLIST,
contentXtreamId: ROUTE_VOD_ID,
contentType: 'vod',
},
});
expect(
service.handleInlineTimeUpdate({ currentTime: 12, duration: 5400 })
).toBe(true);
});
it('still waits for a resume point the source can reach', () => {
service.inlinePlayback.set({
streamUrl: 'https://example.com/full.mkv',
title: 'Example Movie',
startTime: 2538,
contentInfo: {
playlistId: ROUTE_PLAYLIST,
contentXtreamId: ROUTE_VOD_ID,
contentType: 'vod',
},
});
expect(
service.handleInlineTimeUpdate({ currentTime: 12, duration: 7744 })
).toBe(false);
});
describe('position updates from the bridge', () => {
function emit(playlistId: string, contentXtreamId: number, at: number) {
positionListener?.({
playlistId,
contentXtreamId,
contentType: 'vod',
positionSeconds: at,
durationSeconds: 7744,
});
}
it('keeps the route’s own resume point when an alternative plays', () => {
activeSource.set({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
});
// An alternative's position arrives under ITS ids. Letting it stand in
// for the route copy's row would have Resume start the route stream at
// a timecode nobody ever reached in it.
emit('playlist-2', 991, 4200);
expect(service.vodPlaybackPosition()?.positionSeconds).toBe(4200);
expect(service.routePlaybackPosition()).toBeNull();
expect(service.hasPlaybackPosition()).toBe(false);
emit(ROUTE_PLAYLIST, ROUTE_VOD_ID, 60);
expect(service.routePlaybackPosition()?.positionSeconds).toBe(60);
});
it('takes the route stream’s progress', () => {
emit(ROUTE_PLAYLIST, ROUTE_VOD_ID, 120);
expect(service.vodPlaybackPosition()?.positionSeconds).toBe(120);
});
it('takes the progress of the alternative it switched to', () => {
activeSource.set({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
});
// An external player running an alternative reports under THAT
// playlist's ids. Dropping these leaves the resume point where
// playback started, and a later switch rewinds the whole session.
emit('playlist-2', 991, 3600);
expect(service.vodPlaybackPosition()?.positionSeconds).toBe(3600);
});
it('ignores progress for a movie this page is not showing', () => {
emit('playlist-3', 12345, 900);
expect(service.vodPlaybackPosition()).toBeNull();
});
});
});
@@ -28,13 +28,25 @@ import {
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import {
closeRunningExternalSession,
ownsContent,
runningExternalSession,
} from './vod-details-external-session';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
import { formatPlaybackPosition } from './vod-primary-action-position';
export interface VodDetailsPlaybackBindings {
/** Current vod id resolved from the route */
vodId: Signal<number>;
/** Usable metadata of the selected VOD, if any */
vodInfo: Signal<XtreamVodInfo | null>;
/**
* The source playing when it is NOT the route's own. An external player
* launched for an alternative carries that playlist's ids, so without
* this the session belongs to no page and never offers Stop.
*/
activeSource?: Signal<PlayerContentInfo | null>;
}
/**
@@ -57,13 +69,33 @@ export class VodDetailsPlaybackService {
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
/**
* The LAST position seen, whichever copy produced it.
*
* Multi-source can put playback on a copy in another playlist, and this
* follows it — the progress bar and the switch feed both want the stream
* on screen, not the one the route happens to address.
*/
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
/**
* The ROUTE copy's own row.
*
* Everything that acts on the route's stream — Resume, its label, its
* timecode — has to read this instead. Positions are keyed by (playlist,
* stream), so once an alternative has played, `vodPlaybackPosition` names
* a different film's row entirely and resuming from it would jump the
* route copy to a timecode nobody reached in it.
*/
readonly routePlaybackPosition = signal<PlaybackPositionData | null>(null);
private readonly externalButton = createExternalPlaybackButtonState({
session: this.externalPlayback.activeSession,
playlistId: computed(() => this.xtreamStore.currentPlaylist()?.id),
contentId: computed(() => this.bindings()?.vodId()),
alsoOwns: computed(() => this.bindings()?.activeSource?.() ?? null),
});
readonly matchedExternalPlayback = this.externalButton.matchedSession;
readonly externalPrimaryLabel = this.externalButton.primaryLabel;
readonly externalPrimaryIcon = this.externalButton.primaryIcon;
@@ -74,9 +106,28 @@ export class VodDetailsPlaybackService {
getPortalPlaybackProgressPercent(this.vodPlaybackPosition())
);
/** Mirrors an incoming position into the route's row when it owns it. */
private trackPosition(position: PlaybackPositionData | null): void {
this.vodPlaybackPosition.set(position);
if (this.isRouteContent(position)) {
this.routePlaybackPosition.set(position);
}
}
private isRouteContent(position: PlaybackPositionData | null): boolean {
return (
!!position &&
position.playlistId === this.xtreamStore.currentPlaylist()?.id &&
position.contentXtreamId === this.bindings()?.vodId()
);
}
/** Whether the ROUTE copy has somewhere to resume from. */
readonly hasPlaybackPosition = computed(() => {
const inProgress =
this.vodPlaybackProgress() > 0 && this.vodPlaybackProgress() < 90;
const progress = getPortalPlaybackProgressPercent(
this.routePlaybackPosition()
);
const inProgress = progress > 0 && progress < 90;
this.logger.debug('hasPlaybackPosition check', {
vodId: this.bindings()?.vodId(),
inProgress,
@@ -88,24 +139,33 @@ export class VodDetailsPlaybackService {
const unsubscribePositionUpdates =
this.playbackPositionBridge.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const vodId = this.bindings()?.vodId();
if (
data.contentType !== 'vod' ||
data.playlistId !== playlistId ||
data.contentXtreamId !== vodId
) {
return;
// An external player on an ALTERNATIVE reports under
// that playlist's ids; dropping those rewinds a switch.
if (this.ownsContent(data)) {
this.trackPosition(data);
}
this.vodPlaybackPosition.set(data);
}
) ?? null;
inject(DestroyRef).onDestroy(() => unsubscribePositionUpdates?.());
}
private ownsContent(
info:
| {
playlistId?: string;
contentXtreamId?: number;
contentType?: string;
}
| undefined
): boolean {
return ownsContent(info, {
routePlaylistId: this.xtreamStore.currentPlaylist()?.id,
routeContentId: this.bindings()?.vodId(),
alternative: this.bindings()?.activeSource?.() ?? null,
});
}
/** Wires the host component's context signals. Call once at construction. */
bind(bindings: VodDetailsPlaybackBindings): void {
this.bindings.set(bindings);
@@ -171,6 +231,8 @@ export class VodDetailsPlaybackService {
const presentation = resolveXtreamVodPlaybackPresentation(vodItem);
this.addToRecentlyViewed();
// Master's sparse-details fallback: a provider that omits the route
// id still has the stream id on the resolved source.
const routeVodId = this.bindings()?.vodId();
const vodId =
routeVodId != null &&
@@ -178,7 +240,10 @@ export class VodDetailsPlaybackService {
routeVodId > 0
? routeVodId
: source.streamId;
const position = this.vodPlaybackPosition();
// The ROUTE copy's row, not the last position seen: Resume starts the
// route's stream, and an alternative's timecode belongs to a
// different (playlist, stream) key.
const position = this.routePlaybackPosition();
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const contentInfo: PlayerContentInfo = {
@@ -222,13 +287,7 @@ export class VodDetailsPlaybackService {
}
formatPosition(): string {
const position = this.vodPlaybackPosition();
if (!position) return '';
const date = new Date(0);
date.setSeconds(position.positionSeconds);
const timeString = date.toISOString().substr(11, 8);
return timeString.startsWith('00:') ? timeString.substr(3) : timeString;
return formatPlaybackPosition(this.routePlaybackPosition());
}
closeInlinePlayer(): void {
@@ -243,14 +302,19 @@ export class VodDetailsPlaybackService {
playlistId,
position
),
onSaved: (position) => this.vodPlaybackPosition.set(position),
onSaved: (position) => this.trackPosition(position),
});
/**
* @returns whether `currentTime` can be believed yet — see the writer's
* resume latch. Multi-source reads the same answer before carrying a
* timecode to another source.
*/
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
this.positionWriter.handleTimeUpdate(event);
}): boolean {
return this.positionWriter.handleTimeUpdate(event);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
@@ -267,6 +331,7 @@ export class VodDetailsPlaybackService {
'vod'
);
this.vodPlaybackPosition.set(position);
this.routePlaybackPosition.set(position);
}
private addToRecentlyViewed(): void {
@@ -278,14 +343,75 @@ export class VodDetailsPlaybackService {
});
}
/**
* The single inline-vs-external fork. Public so multi-source can switch
* the playing source through exactly the same path a normal Play takes —
* a second, parallel start path is how the two drift apart.
*
* For an inline switch this is a single `.set()` on a signal the host
* template already renders through `@if`, so the player component and its
* engine survive and simply re-seek to `playback.startTime`.
*/
/** Bumped by every start; only the newest may launch after its close. */
private startGeneration = 0;
/**
* What we last launched externally, remembered independently of the
* controller's active source.
*
* `matchedExternalPlayback` cannot answer this during a switch: the
* controller marks the DESTINATION active before playback is handed over,
* so by the time we get here the running process no longer looks like
* ours and would be left playing beside its replacement.
*/
private launchedExternally: PlayerContentInfo | null = null;
async startResolvedPlayback(
playback: ResolvedPortalPlayback
): Promise<void> {
const generation = ++this.startGeneration;
// A switch REPLACES what is playing. With MPV or VLC and instance
// reuse off, the backend spawns a second detached player otherwise —
// both sources keep running and Stop owns only the newer one.
await closeRunningExternalSession(
runningExternalSession(
this.externalPlayback.activeSession(),
this.launchedExternally,
this.matchedExternalPlayback()
),
(session) => this.externalPlayback.closeSession(session),
(message, error) => this.logger.warn(message, error)
);
// Closing is a round-trip, and a second pick across it would otherwise
// reach this line too: both would have seen the same session, closed
// it once, and then launched independently — two detached players
// again, the older one holding a source the user has moved on from.
if (generation !== this.startGeneration) {
return;
}
// Same movie, different source: still a view.
this.addToRecentlyViewed();
this.startPlayback(playback);
}
private startPlayback(playback: ResolvedPortalPlayback): void {
// EVERY start claims the generation, not just the switch path. Play,
// Resume and Restart reach here directly, and a switch still waiting
// on its `closeSession` would otherwise pass the check afterwards and
// launch on top of what the user just chose.
this.startGeneration++;
this.positionWriter.reset();
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
this.launchedExternally = null;
return;
}
this.closeInlinePlayer();
this.launchedExternally = playback.contentInfo ?? null;
void this.portalPlayer.openResolvedPlayback(playback, true);
}
}
@@ -0,0 +1,92 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { VodDetailsRouteComponent } from './vod-details-route.component';
import {
configureVodDetailsRouteTestBed,
createVodDetailsRouteStubs,
resetVodDetailsRouteStubs,
silenceRouteLogging,
} from './vod-details-route.harness';
/**
* Who gets offered auto-failover, and what happens when the preference cannot
* be stored. Split from the playback-actions spec to keep both inside the
* repository's file-size rule.
*/
describe('VodDetailsRouteComponent — auto-failover', () => {
let fixture: ComponentFixture<VodDetailsRouteComponent>;
let restoreLogging: (() => void) | undefined;
const stubs = createVodDetailsRouteStubs();
const { selectedPlayer, snackBarOpen, updateSettings } = stubs;
beforeEach(async () => {
restoreLogging = silenceRouteLogging();
resetVodDetailsRouteStubs(stubs);
await configureVodDetailsRouteTestBed(stubs);
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
afterEach(() => {
restoreLogging?.();
});
beforeEach(() => {
selectedPlayer.set(VideoPlayer.Html5Player);
updateSettings.mockReset().mockResolvedValue(undefined);
snackBarOpen.mockClear();
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])(
'is not offered on %s',
(player) => {
// Those players never raise the playback diagnostic that
// calls onPlaybackFailed(), so the switch could never happen.
selectedPlayer.set(player);
expect(fixture.componentInstance.autoFailoverSupported()).toBe(
false
);
}
);
it.each([
VideoPlayer.Html5Player,
VideoPlayer.VideoJs,
VideoPlayer.ArtPlayer,
])('is offered on %s', (player) => {
selectedPlayer.set(player);
expect(fixture.componentInstance.autoFailoverSupported()).toBe(
true
);
});
it('tells the user when the preference could not be stored', async () => {
// updateSettings patches memory and REJECTS on a failed write, so
// without this the toggle looks saved and silently reverts on the
// next start — and the rejection is unhandled.
updateSettings.mockRejectedValue(new Error('disk full'));
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
expect(snackBarOpen).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
expect.anything()
);
});
it('stays quiet when the write succeeds', async () => {
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
expect(updateSettings).toHaveBeenCalledWith({
vodAutoFailover: true,
});
expect(snackBarOpen).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,258 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodDetailsRouteComponent } from './vod-details-route.component';
import {
configureVodDetailsRouteTestBed,
createVodDetailsRouteStubs,
resetVodDetailsRouteStubs,
silenceRouteLogging,
} from './vod-details-route.harness';
/**
* What the page CLAIMS is playing.
*
* "Playing from ..." is a statement of fact about the stream on screen, and
* discovery marks a source active long before one exists — so the line has to
* be gated on playback rather than on selection.
*/
describe('VodDetailsRouteComponent — source caption', () => {
let fixture: ComponentFixture<VodDetailsRouteComponent>;
let restoreLogging: (() => void) | undefined;
const stubs = createVodDetailsRouteStubs();
const {
activeSession,
addRecentItem,
checkFavoriteStatus,
closeSession,
constructVodStreamUrl,
currentPlaylist,
detailsError,
fetchVodDetailsWithMetadata,
getPlaybackPosition,
isFavorite,
isLoadingDetails,
selectedItem,
setSelectedItem,
toggleFavorite,
vodCategories,
vodStreams,
} = stubs;
beforeEach(async () => {
restoreLogging = silenceRouteLogging();
resetVodDetailsRouteStubs(stubs);
await configureVodDetailsRouteTestBed(stubs);
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
afterEach(() => {
restoreLogging?.();
});
/**
* Stand in for a discovered source list.
*
* The real one comes from a worker-backed discovery the route spec cannot
* reach, and every test here only needs "this row is the active one".
*/
function withActiveSource(playlistId: string, contentId: number): void {
Object.defineProperty(
fixture.componentInstance.multiSource,
'sources',
{
configurable: true,
value: () => [
{
id: `${playlistId}:xtream:${contentId}`,
playlistId,
playlistName: 'Portal One',
portalType: 'xtream',
contentId,
rawTitle: 'Example',
matchConfidence: 'exact',
year: null,
isActive: true,
isPinned: false,
isTried: true,
probe: { status: 'idle' },
},
],
}
);
}
it('claims to be playing only while something is', () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
withActiveSource('playlist-1', 650020);
// Discovery marks a source active as soon as the page opens, so the
// caption would otherwise say "Playing from ..." before Play is
// pressed — and again after the player is closed.
expect(component.activeSourceCaption()).toBeNull();
// Mounting the player is a REQUEST to play. The stream may still be
// opening, or about to fail; naming a source now is a guess.
playback.inlinePlayback.set({
streamUrl: 'http://example.com/movie.mkv',
title: 'Example',
});
expect(component.activeSourceCaption()).toBeNull();
// The engine reporting time is the evidence that it really is playing.
component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 });
expect(component.activeSourceCaption()).not.toBeNull();
playback.inlinePlayback.set(null);
expect(component.activeSourceCaption()).toBeNull();
});
it('waits for the next stream to start after Play is pressed again', () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
// The inline path, or Restart would tear the host down and the test
// would pass on the player being gone rather than on the latch.
stubs.isEmbeddedPlayer.mockReturnValue(true);
withActiveSource('playlist-1', 650020);
playback.inlinePlayback.set({
streamUrl: 'http://example.com/movie.mkv',
title: 'Example',
});
component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 });
expect(component.activeSourceCaption()).not.toBeNull();
// Restart keeps the same host mounted, so without clearing the latch
// the caption would carry the previous stream's claim into the new one.
component.playVod({
movie_data: { stream_id: 650020, name: 'Example' },
} as never);
expect(playback.inlinePlayback()).not.toBeNull();
expect(component.activeSourceCaption()).toBeNull();
});
it('waits again after a manual source switch', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
stubs.isEmbeddedPlayer.mockReturnValue(true);
withActiveSource('playlist-1', 650020);
playback.inlinePlayback.set({
streamUrl: 'http://example.com/movie.mkv',
title: 'Example',
});
component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 });
expect(component.activeSourceCaption()).not.toBeNull();
// A switch mounts a DIFFERENT stream in the same host and marks the
// new source active at once, so the old stream's timeupdate must not
// vouch for it — it may still be opening, or never start at all.
// The exact callback `switchToSource` invokes once it has resolved a
// new source. Standing up the resolver here would test the host, not
// the route's half of the seam.
(
component.multiSource as unknown as {
bindings: {
startPlayback: (playback: ResolvedPortalPlayback) => void;
};
}
).bindings.startPlayback({
streamUrl: 'http://example.com/alt.mkv',
title: 'Example',
startTime: 3,
});
// Replacing a running external player is a round-trip, so the handoff
// yields once before the new playback is mounted.
await Promise.resolve();
expect(playback.inlinePlayback()?.streamUrl).toBe(
'http://example.com/alt.mkv'
);
expect(component.activeSourceCaption()).toBeNull();
component.handleInlineTimeUpdate({ currentTime: 4, duration: 90 });
expect(component.activeSourceCaption()).not.toBeNull();
});
it('says nothing while an external player is still launching', () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
withActiveSource('playlist-1', 650020);
const session = (status: string) => ({
player: 'mpv',
status,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
// MPV has been asked to open; nothing is on screen yet.
activeSession.set(session('launching'));
expect(component.activeSourceCaption()).toBeNull();
// Its window is up, so the film really is playing — just not here.
activeSession.set(session('playing'));
expect(component.activeSourceCaption()).not.toBeNull();
});
it('stops claiming playback once the error screen is up', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
withActiveSource('playlist-1', 650020);
playback.inlinePlayback.set({
streamUrl: 'http://example.com/movie.mkv',
title: 'Example',
});
component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 });
expect(component.activeSourceCaption()).not.toBeNull();
// The host stays mounted through a failure, so the caption would go on
// naming a source while the diagnostic says it could not be played.
await component.onPlaybackFailed();
expect(component.activeSourceCaption()).toBeNull();
// And comes back when the engine produces time again.
component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 });
expect(component.activeSourceCaption()).not.toBeNull();
});
it('keeps the failure state when the chosen source will not resolve', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
withActiveSource('playlist-1', 650020);
playback.inlinePlayback.set({
streamUrl: 'http://example.com/movie.mkv',
title: 'Example',
});
component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 });
await component.onPlaybackFailed();
expect(component.activeSourceCaption()).toBeNull();
// Picking an alternative off the error screen that cannot be resolved
// leaves the diagnostic up — so the caption must stay away too.
jest.spyOn(component.multiSource, 'play').mockResolvedValue(false);
component.playFromSource('playlist-2:xtream:991');
await Promise.resolve();
await Promise.resolve();
expect(component.activeSourceCaption()).toBeNull();
});
});
@@ -0,0 +1,396 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodDetailsRouteComponent } from './vod-details-route.component';
import {
configureVodDetailsRouteTestBed,
createVodDetailsRouteStubs,
resetVodDetailsRouteStubs,
silenceRouteLogging,
} from './vod-details-route.harness';
/**
* What the primary button and the resume point do, as opposed to what the
* page renders. Split from the rendering spec to keep both inside the
* repository's file-size rule.
*/
describe('VodDetailsRouteComponent — playback actions', () => {
let fixture: ComponentFixture<VodDetailsRouteComponent>;
let restoreLogging: (() => void) | undefined;
const stubs = createVodDetailsRouteStubs();
const {
currentPlaylist,
activeSession,
addRecentItem,
closeSession,
getPlaybackPosition,
selectedItem,
selectedPlayer,
snackBarOpen,
updateSettings,
} = stubs;
beforeEach(async () => {
restoreLogging = silenceRouteLogging();
resetVodDetailsRouteStubs(stubs);
await configureVodDetailsRouteTestBed(stubs);
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
afterEach(() => {
restoreLogging?.();
});
/**
* Stand in for a discovered source list.
*
* The real one comes from a worker-backed discovery the route spec cannot
* reach, and every test here only needs "this row is the active one".
*/
function withActiveSource(playlistId: string, contentId: number): void {
Object.defineProperty(
fixture.componentInstance.multiSource,
'sources',
{
configurable: true,
value: () => [
{
id: `${playlistId}:xtream:${contentId}`,
playlistId,
playlistName: 'Portal One',
portalType: 'xtream',
contentId,
rawTitle: 'Example',
matchConfidence: 'exact',
year: null,
isActive: true,
isPinned: false,
isTried: true,
probe: { status: 'idle' },
},
],
}
);
}
it('owns an external session for a copy in its own playlist', () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
// A pinned copy can now live in the route's OWN playlist. Comparing
// playlists alone would call this "the route source", and the page
// would disown the session it started for it.
withActiveSource('playlist-1', 4242);
activeSession.set({
player: 'mpv',
status: 'playing',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 4242,
contentType: 'vod',
},
});
expect(playback.matchedExternalPlayback()).not.toBeNull();
expect(component.isExternalStopAction()).toBe(true);
});
it('drops the carried position when Restart starts from the beginning', () => {
const component = fixture.componentInstance;
const reported = jest.spyOn(component.multiSource, 'reportPosition');
component.playVod({} as XtreamVodDetails);
// The controller still holds whatever this page was seeded with, and
// a failure before the first timeupdate would resolve the next source
// back at it instead of honouring the restart.
expect(reported).toHaveBeenCalledWith(0);
});
it('stops the external player when the button says Stop', async () => {
currentPlaylist.set({ id: 'playlist-1' });
activeSession.set({
player: 'mpv',
status: 'playing',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
const component = fixture.componentInstance;
const playPinned = jest.spyOn(
component.multiSource,
'playPinnedSource'
);
expect(component.isExternalStopAction()).toBe(true);
await component.onPrimaryAction({} as XtreamVodDetails);
// Consulting the pin first would launch a second player while the
// first keeps running — the control doing the opposite of its label.
expect(playPinned).not.toHaveBeenCalled();
expect(closeSession).toHaveBeenCalled();
});
it('follows external playback progress, which has no timeupdate', () => {
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
const reported = jest.spyOn(component.multiSource, 'reportPosition');
// MPV/VLC report only through the polled position, so this IS their
// live timecode. Treating it as a one-shot seed would freeze the
// resume point at the start and rewind a later source switch by
// however long the user had been watching.
playback.vodPlaybackPosition.set({
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
positionSeconds: 120,
durationSeconds: 7744,
});
fixture.detectChanges();
expect(reported).toHaveBeenLastCalledWith(120);
playback.vodPlaybackPosition.set({
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
positionSeconds: 3600,
durationSeconds: 7744,
});
fixture.detectChanges();
expect(reported).toHaveBeenLastCalledWith(3600);
});
it('holds the resume point until the engine has seeked to it', () => {
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
playback.inlinePlayback.set({
streamUrl: 'http://example.com/movie/650020.mp4',
title: 'City of McFarland',
startTime: 2538,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
const reported = jest.spyOn(component.multiSource, 'reportPosition');
// A resuming engine emits timeupdates at ~0 on its way to 2538. That
// is not where the film is, and multi-source must not switch or fail
// over back to the beginning because of it.
component.handleInlineTimeUpdate({ currentTime: 0.2, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(2538);
component.handleInlineTimeUpdate({ currentTime: 2540, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(2540);
// One-shot latch, not a filter: a deliberate seek backwards counts.
component.handleInlineTimeUpdate({ currentTime: 12, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(12);
});
it('drops the previous movie’s resume point on a reused route', () => {
currentPlaylist.set({ id: 'playlist-1' });
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
fixture.detectChanges();
playback.routePlaybackPosition.set({
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
positionSeconds: 2538,
durationSeconds: 7744,
});
// The Similar rail reuses this component. Until the new lookup lands
// the button would offer the PREVIOUS movie's "Resume 42:18" — and
// start the new stream there.
currentPlaylist.set({ id: 'playlist-2' });
fixture.detectChanges();
expect(playback.routePlaybackPosition()).toBeNull();
expect(playback.hasPlaybackPosition()).toBe(false);
});
it('downloads the movie the route currently shows', async () => {
currentPlaylist.set({ id: 'playlist-1' });
fixture.detectChanges();
await fixture.componentInstance.downloadVod({
// A DIFFERENT id in the payload: the route's id must win.
movie_data: {
stream_id: 111,
name: 'Example',
container_extension: 'mp4',
},
} as never);
// The id comes from the route params SIGNAL, not `snapshot.params`:
// the router reuses this component for detail-to-detail navigation
// (the Similar rail), and the snapshot still names the film the user
// came from — so the download would fetch the wrong movie.
expect(stubs.startDownload).toHaveBeenCalledWith(
expect.objectContaining({ xtreamId: 650020 })
);
});
it('replaces an alternative’s timecode when Resume starts the route copy', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
withActiveSource('playlist-1', 650020);
playback.routePlaybackPosition.set({
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
positionSeconds: 120,
durationSeconds: 7744,
});
const reported = jest.spyOn(component.multiSource, 'reportPosition');
// An alternative left its own timecode in the controller. Resuming the
// ROUTE copy has to overwrite it, or a failure before the first
// timeupdate resolves the next source at a position from another copy.
component.multiSource.reportPosition(4200);
component.resumeVod({
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mp4',
},
} as never);
expect(reported).toHaveBeenLastCalledWith(120);
});
it('does not start the route source when a newer action owns the screen', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
withActiveSource('playlist-1', 650020);
// Double-clicking Play while the pin is resolving: the second click
// supersedes the first, and the first must not conclude "no usable
// pin" and start the route source over what the second just began.
jest.spyOn(component.multiSource, 'playPinnedSource').mockResolvedValue(
'superseded'
);
const reported = jest.spyOn(component.multiSource, 'reportPosition');
await component.onPrimaryAction({
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mp4',
},
} as never);
expect(reported).not.toHaveBeenCalled();
});
it('takes the route wrappers when the primary button falls through', () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
withActiveSource('playlist-1', 650020);
jest.spyOn(component.multiSource, 'playPinnedSource').mockResolvedValue(
'unavailable'
);
const reported = jest.spyOn(component.multiSource, 'reportPosition');
// Reaching the service directly would skip the bookkeeping a route
// start needs — the controller would keep an alternative's timecode.
void component.onPrimaryAction({
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mp4',
},
} as never);
return Promise.resolve().then(() => {
expect(reported).toHaveBeenCalledWith(0);
});
});
it('offers no resume point for a pinned copy watched through', async () => {
const component = fixture.componentInstance;
const resumeSecondsFor = component['msUi'].resumeSecondsFor;
getPlaybackPosition.mockResolvedValue({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
positionSeconds: 6900,
durationSeconds: 7200,
});
// The button reads Play at 95%, so the start must mean zero — not a
// seek back to where the film ended.
await expect(
resumeSecondsFor({
playlistId: 'playlist-2',
contentId: 991,
} as never)
).resolves.toBeNull();
});
it('still resumes a pinned copy left mid-film', async () => {
const component = fixture.componentInstance;
getPlaybackPosition.mockResolvedValue({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
positionSeconds: 2538,
durationSeconds: 7200,
});
await expect(
component['msUi'].resumeSecondsFor({
playlistId: 'playlist-2',
contentId: 991,
} as never)
).resolves.toBe(2538);
});
it('restarts the pinned copy, not the route copy', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
withActiveSource('playlist-1', 650020);
// Resume honours the pin, so the Restart beside it must too —
// otherwise the button quietly switches the user's playlist.
Object.defineProperty(component['msUi'], 'primaryIsPinnedCopy', {
configurable: true,
value: () => true,
});
const pinnedPlay = jest
.spyOn(component.multiSource, 'playPinnedSource')
.mockResolvedValue('played');
await component.restartVod({
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mp4',
},
} as never);
expect(pinnedPlay).toHaveBeenCalled();
// Restart means zero, whichever copy it starts.
const resumeFor = pinnedPlay.mock.calls[0][0];
await expect(resumeFor?.({} as never)).resolves.toBe(0);
});
});
@@ -34,6 +34,19 @@ import { VodDetailsRouteComponent } from './vod-details-route.component';
})
class StubPortalInlinePlayerComponent {
readonly playback = input<unknown>(null);
// Multi-source wiring: the real player takes these, so the stub has to
// accept them or the template binding fails to compile.
readonly alternativeSources = input<unknown>([]);
readonly allSources = input<unknown>([]);
readonly sourcesMatchKind = input<unknown>(null);
readonly sourcesAutoFailoverEnabled = input(false);
readonly sourcesAutoFailoverSupported = input(true);
readonly sourcesPlaybackLive = input(false);
readonly sourcesAutoFailoverToggled = output<boolean>();
readonly alternativeSourceRequested = output<string>();
readonly sourcePinRequested = output<string>();
readonly sourceCheckRequested = output<string>();
readonly playbackFailed = output<unknown>();
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
readonly backClicked = output<void>();
@@ -274,13 +287,18 @@ describe('VodDetailsRouteComponent fallback actions', () => {
downloadsAvailable.set(true);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodPlaybackPosition.set({
const storedPosition = {
contentXtreamId: 650020,
contentType: 'vod',
playlistId: 'playlist-1',
positionSeconds: 42,
durationSeconds: 120,
} satisfies PlaybackPositionData);
} satisfies PlaybackPositionData;
// Both, as `loadPosition` does: the Resume button reads the ROUTE
// copy's row, while `vodPlaybackPosition` follows whichever copy last
// reported — multi-source can put those on different streams.
fixture.componentInstance.vodPlaybackPosition.set(storedPosition);
fixture.componentInstance.routePlaybackPosition.set(storedPosition);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
host.querySelector<HTMLButtonElement>(
@@ -29,7 +29,7 @@
@if (!isExternalStopAction()) {
<button
class="play-btn play-btn--restart"
(click)="playVod(playableItem)"
(click)="restartVod(playableItem)"
>
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
@@ -107,6 +107,47 @@
</button>
}
}
@if (multiSource.hasAlternatives()) {
<app-vod-sources-chip
[sources]="multiSource.sources()"
[title]="multiSourceTitle()"
[matchKind]="multiSource.matchKind()"
[count]="multiSource.alternativeCount()"
[autoFailoverEnabled]="multiSource.autoFailoverEnabled()"
[autoFailoverSupported]="autoFailoverSupported()"
[playbackLive]="playbackLive()"
(playRequested)="playFromSource($event)"
(pinRequested)="pinSource($event)"
(checkRequested)="checkSource($event)"
(autoFailoverToggled)="setAutoFailover($event)"
/>
}
@if (activeSourceCaption(); as caption) {
<p class="source-caption">
<span class="source-caption__label">
{{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }}
</span>
<span class="source-caption__source">{{ caption.source }}</span>
@if (multiSource.hasAlternatives()) {
<span class="source-caption__extra">
@if (caption.alternativeCount === 1) {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE'
| translate
}}
} @else {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER'
| translate
: { count: caption.alternativeCount }
}}
}
</span>
}
</p>
}
}
</ng-template>
@@ -115,6 +156,17 @@
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
[alternativeSources]="multiSource.alternatives()"
[allSources]="multiSource.sources()"
[sourcesMatchKind]="multiSource.matchKind()"
[sourcesAutoFailoverEnabled]="multiSource.autoFailoverEnabled()"
[sourcesAutoFailoverSupported]="autoFailoverSupported()"
[sourcesPlaybackLive]="playbackLive()"
(sourcesAutoFailoverToggled)="setAutoFailover($event)"
(alternativeSourceRequested)="playFromSource($event)"
(sourcePinRequested)="pinSource($event)"
(sourceCheckRequested)="checkSource($event)"
(playbackFailed)="onPlaybackFailed()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
@@ -4,6 +4,27 @@
height: 100%;
min-height: 0;
// "Playing from: SharkTV · 1080p · mkv — also found in 2 playlists".
// Projected into the hero's action row, which is a flex row, so it claims
// a full-width line of its own below the buttons.
::ng-deep .source-caption {
flex: 0 0 100%;
margin: 10px 0 0;
font-size: 0.75rem;
line-height: 1.4;
color: var(--text-muted);
.source-caption__source {
margin-left: 6px;
font-weight: 600;
color: var(--text-secondary);
}
.source-caption__extra {
margin-left: 6px;
}
}
::ng-deep .details__tag--status {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--accent-color) 28%, transparent);
@@ -20,6 +20,7 @@ import {
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
PortalDetailShellComponent,
VodSourcesChipComponent,
} from '@iptvnator/ui/components';
import { SafePipe } from '@iptvnator/pipes';
import { createLogger } from '@iptvnator/portal/shared/util';
@@ -40,12 +41,17 @@ import {
import {
getXtreamVodInfo,
normalizeTitleKeys,
playlistDisplayLabel,
reportsPlaybackFailures,
TmdbEnrichedCastMember,
XtreamCategory,
XtreamVodDetails,
XtreamVodInfo,
XtreamVodStream,
youtubeEmbedUrl,
type PlaybackPositionData,
type VodSourceCandidate,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import {
SimilarCatalogItem,
@@ -56,7 +62,11 @@ import {
hasUsableXtreamVodMetadata,
} from './vod-details-fallback.util';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
import { VodDetailsMultiSourceUiService } from './vod-details-multi-source-ui.service';
import { VodDetailsDownloadsService } from './vod-details-downloads.service';
import { VodDetailsSimilarService } from './vod-details-similar.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
@Component({
templateUrl: './vod-details-route.component.html',
@@ -65,7 +75,13 @@ import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-pre
'./vod-details-route.component.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [VodDetailsPlaybackService],
providers: [
VodDetailsPlaybackService,
VodMultiSourceHostService,
VodDetailsMultiSourceUiService,
VodDetailsSimilarService,
VodDetailsDownloadsService,
],
imports: [
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
@@ -77,6 +93,7 @@ import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-pre
SlicePipe,
TranslateModule,
PortalInlinePlayerComponent,
VodSourcesChipComponent,
],
})
export class VodDetailsRouteComponent implements OnInit, OnDestroy {
@@ -90,12 +107,19 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playback = inject(VodDetailsPlaybackService);
/** Alternative sources for this movie in the user's other playlists */
readonly multiSource = inject(VodMultiSourceHostService);
private readonly msUi = inject(VodDetailsMultiSourceUiService);
private readonly similar = inject(VodDetailsSimilarService);
private readonly downloads = inject(VodDetailsDownloadsService);
private readonly logger = createLogger('VodDetailsRoute');
/** `playlistId:vodId` of the last initialized detail view */
private readonly lastInitKey = signal<string | null>(null);
private readonly backdropBackfillKey = signal<string | null>(null);
readonly inlinePlayback = this.playback.inlinePlayback;
readonly vodPlaybackPosition = this.playback.vodPlaybackPosition;
/** The route copy's own row — what Resume acts on. */
readonly routePlaybackPosition = this.playback.routePlaybackPosition;
/**
* Reactive route params: the component is reused when navigating
@@ -191,6 +215,23 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
) ?? null
);
});
/** Movie identity for multi-source discovery; null until a title exists */
private readonly multiSourceMovie = computed(() =>
resolveVodMultiSourceMovie({
playlistId: this.xtreamStore.currentPlaylist()?.id,
// `title` is the alias the Xtream data source actually writes
// (createPlaylist maps name -> title), so reading only `name`
// would fall back to the raw playlist UUID in the sources list.
playlistName:
this.xtreamStore.currentPlaylist()?.name ??
this.xtreamStore.currentPlaylist()?.title,
vodId: this.selectedVodId(),
vodInfo: this.selectedVodInfo(),
catalogItem: this.selectedCatalogItem(),
containerExtension:
this.selectedItem()?.movie_data?.container_extension,
})
);
readonly selectedVodInfo = computed(() => {
const item = this.selectedItem();
return item && hasUsableXtreamVodMetadata(item)
@@ -224,92 +265,78 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
readonly externalPrimaryButtonState =
this.playback.externalPrimaryButtonState;
readonly vodPlaybackProgress = this.playback.vodPlaybackProgress;
readonly hasPlaybackPosition = this.playback.hasPlaybackPosition;
readonly isDownloaded = computed(() => {
const vodId = this.selectedVodId();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) return false;
this.downloadsService.downloads();
return this.downloadsService.isDownloaded(vodId, playlistId, 'vod');
});
readonly hasPlaybackPosition = this.msUi.hasPlaybackPosition;
readonly isDownloading = computed(() => {
const vodId = this.selectedVodId();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) return false;
this.downloadsService.downloads();
return this.downloadsService.isDownloading(vodId, playlistId, 'vod');
});
readonly isPausedDownload = computed(() => {
const vodId = this.selectedVodId();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) return false;
this.downloadsService.downloads();
return this.downloadsService.isPaused(vodId, playlistId, 'vod');
});
readonly isDownloaded = this.downloads.isDownloaded;
readonly isDownloading = this.downloads.isDownloading;
readonly isPausedDownload = this.downloads.isPausedDownload;
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer)
);
/** TMDB recommendations matched against the loaded VOD catalog */
readonly similarItems = computed<SimilarCatalogItem[]>(() => {
const info = this.selectedVodInfo();
if (!info?.tmdb_recommendations?.length) {
return [];
}
return matchRecommendationsToCatalog(
info.tmdb_recommendations,
this.scopedVodStreams(),
{ excludeId: this.selectedVodId() }
);
});
readonly similarItems = this.similar.similarItems;
readonly similarInPortals = this.similar.similarInPortals;
/** Recommendations found in the user's OTHER portals (Electron only) */
private readonly crossPortalItems = signal<CrossPortalSimilarItem[]>([]);
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
const localTitles = new Set(
this.similarItems().map(
(item) => normalizeTitleKeys(item.title).exact
)
);
return this.crossPortalItems().filter(
(item) => !localTitles.has(normalizeTitleKeys(item.title).exact)
);
});
private readonly loadCrossPortalSimilar = effect(() => {
const recommendations = this.selectedVodInfo()?.tmdb_recommendations;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
untracked(() => {
this.crossPortalItems.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'movie', {
excludePlaylistId: playlistId,
})
.then((items) => {
if (
this.selectedVodInfo()?.tmdb_recommendations ===
recommendations
) {
this.crossPortalItems.set(items);
}
});
});
});
/**
* The alternative the player is on, in playback's terms — null while the
* route's own source is playing, which the matcher already recognises.
*/
constructor() {
this.downloads.bind({ routeContentId: this.selectedVodId });
this.similar.bind({
vodInfo: this.selectedVodInfo,
routeContentId: this.selectedVodId,
});
this.msUi.bind({
routeContentId: this.selectedVodId,
movieTitle: computed(() => this.multiSourceMovie()?.title ?? ''),
});
this.playback.bind({
vodId: this.selectedVodId,
vodInfo: this.selectedVodInfo,
activeSource: this.msUi.activeAlternativeSource,
});
effect(() => {
const position = this.playback.vodPlaybackPosition();
if (!position) {
return;
}
if (this.inlinePlayback()) {
// Seeding only: the inline player reports the live timecode
// itself, and this stored value lags it by up to the save
// throttle — applying it would rewind the switch. Before the
// first timeupdate there is nothing to protect, so a switch
// made straight off the Resume button still resumes.
this.multiSource.seedResumePosition(position.positionSeconds);
return;
}
// MPV and VLC have no timeupdate to report; this polled position
// IS their live one, so a source switch after an hour in an
// external player must not rewind to where it started.
this.multiSource.reportPosition(position.positionSeconds);
});
this.multiSource.bind({
// Route every switch through the same inline-vs-external fork a
// normal Play uses, so the two paths cannot drift apart.
startPlayback: (playback) => {
// A switch mounts a DIFFERENT stream in the same host, so the
// evidence that the previous one was playing says nothing
// about this one — without clearing it the caption and the
// badge would claim the new source while it is still opening.
this.msUi.reset();
void this.playback.startResolvedPlayback(playback);
},
movie: this.multiSourceMovie,
playbackLive: this.playbackLive,
});
// Initializes on first render and RE-initializes when the route
@@ -324,7 +351,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.lastInitKey.set(initKey);
this.inlinePlayback.set(null);
// Both, or the primary button keeps the previous movie's Resume
// label until the new lookup lands — and starts the new stream
// there. `loadPosition` is guarded on the same key, so an older
// lookup cannot repopulate either one.
this.vodPlaybackPosition.set(null);
this.playback.routePlaybackPosition.set(null);
this.msUi.reset();
this.initializeVodDetails(playlistId, vodId);
});
@@ -396,15 +429,78 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
playVod(vodItem: XtreamVodDetails | null): void {
// Restart means from the beginning. The controller still holds the
// position this page was seeded with, and a failure before the first
// timeupdate would otherwise resolve the next source back at it.
this.multiSource.reportPosition(0);
this.multiSource.markRouteSourceActive();
this.msUi.beginPlayback();
this.playback.playVod(vodItem);
}
/**
* Restart from the beginning — of whatever the primary button acts on.
*
* When a pin points at another copy, Resume honours it, so Restart sitting
* beside it must too; calling `playVod` there would quietly switch the
* user to the route's playlist.
*/
async restartVod(vodItem: XtreamVodDetails | null): Promise<void> {
if (this.msUi.primaryIsPinnedCopy()) {
const outcome = await this.multiSource.playPinnedSource(async () =>
Promise.resolve(0)
);
if (outcome !== 'unavailable') {
return;
}
}
this.playVod(vodItem);
}
resumeVod(vodItem: XtreamVodDetails | null): void {
this.multiSource.markRouteSourceActive();
this.msUi.beginPlayback();
// The controller can still hold an ALTERNATIVE's timecode. A failure
// before the first timeupdate would otherwise resolve the next source
// at a position that belongs to a different copy.
this.multiSource.reportPosition(
this.playback.routePlaybackPosition()?.positionSeconds ?? 0
);
this.playback.resumeVod(vodItem);
}
onPrimaryAction(vodItem: XtreamVodDetails | null): void {
this.playback.onPrimaryAction(vodItem);
async onPrimaryAction(vodItem: XtreamVodDetails | null): Promise<void> {
// When the button reads Stop, it stops. Consulting the pin first would
// make the control do the opposite of what it says — launching a
// second player while the first keeps running.
if (this.playback.isExternalStopAction()) {
this.playback.onPrimaryAction(vodItem);
return;
}
// A pinned source is an explicit "play this movie from here", so it
// outranks the playlist the route happens to be on. Falls through to
// the normal path when nothing is pinned or the pin cannot resolve.
const pinned = await this.multiSource.playPinnedSource(
this.msUi.resumeSecondsFor
);
// Only "no usable pin" falls through. A superseded attempt means a
// newer action already owns the screen — starting the route source
// here would override the playback that action just began.
if (pinned !== 'unavailable') {
return;
}
// Through the route's OWN wrappers, not the service's: they carry the
// bookkeeping a route start needs — clearing the playback evidence and
// replacing whatever timecode an alternative left in the controller.
if (this.playback.hasPlaybackPosition()) {
this.resumeVod(vodItem);
return;
}
this.playVod(vodItem);
}
stopExternalPlayback(): Promise<void> {
@@ -412,7 +508,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
formatPosition(): string {
return this.playback.formatPosition();
return this.msUi.formatPosition();
}
toggleFavorite(): void {
@@ -442,11 +538,38 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.playback.closeInlinePlayer();
}
readonly multiSourceTitle = this.msUi.multiSourceTitle;
readonly activeSourceCaption = this.msUi.activeSourceCaption;
playFromSource(sourceId: string): void {
this.msUi.playFromSource(sourceId);
}
pinSource(sourceId: string): void {
this.msUi.pinSource(sourceId);
}
checkSource(sourceId: string): void {
this.msUi.checkSource(sourceId);
}
readonly autoFailoverSupported = this.msUi.autoFailoverSupported;
setAutoFailover(enabled: boolean): void {
this.msUi.setAutoFailover(enabled);
}
onPlaybackFailed(): Promise<void> {
return this.msUi.onPlaybackFailed();
}
readonly playbackLive = this.msUi.playbackLive;
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
this.playback.handleInlineTimeUpdate(event);
this.msUi.handleInlineTimeUpdate(event);
}
showCopyNotification(): void {
@@ -463,70 +586,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.playback.handleExternalFallbackRequest(request);
}
async resumePausedDownload(): Promise<void> {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
await this.downloadsService.resumeDownloadByContent(
this.selectedVodId(),
playlistId,
'vod'
);
resumePausedDownload(): Promise<void> {
return this.downloads.resumePaused();
}
async downloadVod(vodItem: XtreamVodDetails | null): Promise<void> {
if (!vodItem) {
return;
}
const source = resolveXtreamVodPlaybackSource(vodItem);
if (!source) {
return;
}
const presentation = resolveXtreamVodPlaybackPresentation(vodItem);
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const routeVodId = Number(this.route.snapshot.params.vodId);
const id =
Number.isSafeInteger(routeVodId) && routeVodId > 0
? routeVodId
: source.streamId;
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: id,
contentType: 'vod',
title: presentation.title,
url: streamUrl,
posterUrl: presentation.posterUrl,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
});
downloadVod(vodItem: XtreamVodDetails | null): Promise<void> {
return this.downloads.start(vodItem);
}
async playFromLocal(): Promise<void> {
const vodId = Number(this.route.snapshot.params.vodId);
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) return;
const filePath = this.downloadsService.getDownloadedFilePath(
vodId,
playlistId,
'vod'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
playFromLocal(): Promise<void> {
return this.downloads.playLocal();
}
private initializeVodDetails(playlistId: string, vodId: number): void {
@@ -0,0 +1,228 @@
import { Location } from '@angular/common';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import {
VideoPlayer,
XtreamCategory,
XtreamVodDetails,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { VodDetailsRouteComponent } from './vod-details-route.component';
/**
* The TestBed every VOD-details route spec needs.
*
* The route pulls in the store, both playback ports, downloads, settings and
* the router, so standing it up costs ~150 lines. Three specs carried their
* own near-identical copy of that, which is how they drifted and how each one
* ran into the file-size rule. One harness, one set of stubs.
*/
/** Every stub the harness installs, so specs can drive and assert on them. */
export function createVodDetailsRouteStubs() {
return {
selectedItem: signal<XtreamVodDetails | null>(null),
isLoadingDetails: signal(false),
detailsError: signal<string | null>(null),
isFavorite: signal(false),
currentPlaylist: signal<{
id: string;
userAgent?: string;
referrer?: string;
origin?: string;
} | null>(null),
vodStreams: signal<Partial<XtreamVodStream>[]>([]),
vodCategories: signal<Partial<XtreamCategory>[]>([]),
fetchVodDetailsWithMetadata: jest.fn(),
checkFavoriteStatus: jest.fn(),
setSelectedItem: jest.fn(),
toggleFavorite: jest.fn(),
constructVodStreamUrl: jest
.fn()
.mockReturnValue('http://example.com/movie/650020.mp4'),
addRecentItem: jest.fn(),
cancelDetailsRequest: jest.fn(),
vodStreamsPlaylistId: signal<string | null>(null),
downloads: signal([]),
getPlaybackPosition: jest.fn().mockResolvedValue(null),
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
activeSession: signal<unknown>(null),
closeSession: jest.fn(),
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
snackBarOpen: jest.fn(),
startDownload: jest.fn().mockResolvedValue(undefined),
locationBack: jest.fn(),
selectedPlayer: signal<VideoPlayer>(VideoPlayer.Html5Player),
updateSettings: jest.fn().mockResolvedValue(undefined),
};
}
export type VodDetailsRouteStubs = ReturnType<
typeof createVodDetailsRouteStubs
>;
/** Back to the state a fresh `beforeEach` expects. */
export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void {
stubs.selectedItem.set(null);
stubs.isLoadingDetails.set(false);
stubs.detailsError.set(null);
stubs.isFavorite.set(false);
stubs.currentPlaylist.set(null);
stubs.vodStreams.set([]);
stubs.vodCategories.set([]);
stubs.activeSession.set(null);
stubs.selectedPlayer.set(VideoPlayer.Html5Player);
stubs.updateSettings.mockClear().mockResolvedValue(undefined);
for (const value of Object.values(stubs)) {
if (jest.isMockFunction(value) && value !== stubs.updateSettings) {
value.mockClear();
}
}
}
/**
* Silences the route's own debug chatter without hiding anything else — a
* genuine warning from the component under test must still reach the console.
*/
export function silenceRouteLogging(): () => void {
const consoleDebug = console.debug.bind(console);
const consoleWarn = console.warn.bind(console);
const debugSpy = jest
.spyOn(console, 'debug')
.mockImplementation((...args: unknown[]) => {
if (
args[0] === '[VodDetailsRoute]' ||
args[0] === '[VodDetailsPlayback]'
) {
return;
}
consoleDebug(...args);
});
const warnSpy = jest
.spyOn(console, 'warn')
.mockImplementation((...args: unknown[]) => {
if (
args[0] === '[VodDetailsRoute]' &&
args[1] === 'Deferring VOD details init: playlist not ready'
) {
return;
}
consoleWarn(...args);
});
return () => {
debugSpy.mockRestore();
warnSpy.mockRestore();
};
}
export async function configureVodDetailsRouteTestBed(
stubs: VodDetailsRouteStubs
): Promise<void> {
await TestBed.configureTestingModule({
imports: [VodDetailsRouteComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: of({ vodId: '650020', categoryId: '235' }),
snapshot: {
params: { vodId: '650020', categoryId: '235' },
},
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
{
provide: XtreamStore,
useValue: {
selectedItem: stubs.selectedItem,
isLoadingDetails: stubs.isLoadingDetails,
detailsError: stubs.detailsError,
isFavorite: stubs.isFavorite,
currentPlaylist: stubs.currentPlaylist,
vodStreams: stubs.vodStreams,
vodCategories: stubs.vodCategories,
fetchVodDetailsWithMetadata:
stubs.fetchVodDetailsWithMetadata,
checkFavoriteStatus: stubs.checkFavoriteStatus,
setSelectedItem: stubs.setSelectedItem,
toggleFavorite: stubs.toggleFavorite,
constructVodStreamUrl: stubs.constructVodStreamUrl,
addRecentItem: stubs.addRecentItem,
cancelDetailsRequest: stubs.cancelDetailsRequest,
vodStreamsPlaylistId: stubs.vodStreamsPlaylistId,
},
},
{
provide: SettingsStore,
useValue: {
theme: signal('dark'),
player: stubs.selectedPlayer,
updateSettings: stubs.updateSettings,
},
},
{
provide: DownloadsService,
useValue: {
isAvailable: signal(false),
downloads: stubs.downloads,
isDownloaded: jest.fn().mockReturnValue(false),
isDownloading: jest.fn().mockReturnValue(false),
startDownload: stubs.startDownload,
getDownloadedFilePath: jest.fn(),
playDownload: jest.fn(),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: stubs.activeSession,
closeSession: stubs.closeSession,
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition: stubs.getPlaybackPosition,
savePlaybackPosition: stubs.savePlaybackPosition,
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: stubs.isEmbeddedPlayer,
openResolvedPlayback: stubs.openResolvedPlayback,
},
},
{ provide: MatSnackBar, useValue: { open: stubs.snackBarOpen } },
{ provide: Location, useValue: { back: stubs.locationBack } },
],
}).compileComponents();
}
@@ -0,0 +1,120 @@
import {
Injectable,
Signal,
computed,
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
} from '@iptvnator/services';
import {
normalizeTitleKeys,
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import {
SimilarCatalogItem,
matchRecommendationsToCatalog,
} from '../tmdb-similar.util';
/**
* The "Similar" rail on the VOD details page.
*
* Two sources feed it: TMDB recommendations matched against the catalog this
* playlist already loaded, and — in Electron — the same recommendations
* matched across every OTHER imported playlist. The second list is filtered
* against the first so one film does not appear twice under different portals.
*
* Component-provided: the cross-portal lookup is per-movie and its result must
* die with the page rather than leak into the next film.
*/
@Injectable()
export class VodDetailsSimilarService {
private readonly xtreamStore = inject(XtreamStore);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
private vodInfo: Signal<XtreamVodInfo | null> = signal(null);
private routeContentId: Signal<number> = signal(NaN);
bind(bindings: {
vodInfo: Signal<XtreamVodInfo | null>;
routeContentId: Signal<number>;
}): void {
this.vodInfo = bindings.vodInfo;
this.routeContentId = bindings.routeContentId;
}
/**
* The catalog only when it belongs to the playlist on screen — otherwise
* a rail would match against another portal's streams.
*/
private readonly scopedVodStreams = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodStreamsPlaylistId() === playlistId
? this.xtreamStore.vodStreams()
: [];
});
/** TMDB recommendations matched against the loaded VOD catalog. */
readonly similarItems = computed<SimilarCatalogItem[]>(() => {
const info = this.vodInfo();
if (!info?.tmdb_recommendations?.length) {
return [];
}
return matchRecommendationsToCatalog(
info.tmdb_recommendations,
this.scopedVodStreams(),
{ excludeId: this.routeContentId() }
);
});
/** Recommendations found in the user's OTHER portals (Electron only). */
private readonly crossPortalItems = signal<CrossPortalSimilarItem[]>([]);
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
const localTitles = new Set(
this.similarItems().map(
(item) => normalizeTitleKeys(item.title).exact
)
);
return this.crossPortalItems().filter(
(item) => !localTitles.has(normalizeTitleKeys(item.title).exact)
);
});
private readonly loadCrossPortalSimilar = effect(() => {
const recommendations = this.vodInfo()?.tmdb_recommendations;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
untracked(() => {
this.crossPortalItems.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'movie', {
excludePlaylistId: playlistId,
})
.then((items) => {
// The user can open another film across the lookup, and
// the rail must not describe the one they left.
if (
this.vodInfo()?.tmdb_recommendations === recommendations
) {
this.crossPortalItems.set(items);
}
});
});
});
}
@@ -0,0 +1,76 @@
import { signal } from '@angular/core';
import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces';
import { createVodSourceCounts } from './vod-multi-source-counts';
function source(
id: string,
playlistId: string,
isActive = false
): VodSourceDescriptor {
return {
id,
playlistId,
playlistName: playlistId,
portalType: 'xtream',
contentId: 1,
rawTitle: 'Example',
matchConfidence: 'exact',
year: null,
isActive,
isPinned: false,
isTried: false,
probe: { status: 'idle' },
} as VodSourceDescriptor;
}
describe('createVodSourceCounts', () => {
it('counts alternative streams for the chip', () => {
const counts = createVodSourceCounts(
signal([
source('a', 'playlist-1', true),
source('b', 'playlist-2'),
source('c', 'playlist-2'),
])
);
expect(counts.alternativeCount()).toBe(2);
expect(counts.hasAlternatives()).toBe(true);
});
it('counts OTHER playlists for the caption', () => {
// The caption says "also found in N other playlists", so a playlist's
// two copies are one playlist.
const counts = createVodSourceCounts(
signal([
source('a', 'playlist-1', true),
source('b', 'playlist-2'),
source('c', 'playlist-2'),
])
);
expect(counts.alternativePlaylistCount()).toBe(1);
});
it('does not count the playlist being watched as another', () => {
// Playing one of playlist-2's two copies leaves the other listed as an
// alternative, which put playlist-2's own id in the set.
const counts = createVodSourceCounts(
signal([
source('a', 'playlist-1'),
source('b', 'playlist-2', true),
source('c', 'playlist-2'),
])
);
expect(counts.alternativePlaylistCount()).toBe(1);
});
it('has nothing to offer for a single source', () => {
const counts = createVodSourceCounts(
signal([source('a', 'playlist-1', true)])
);
expect(counts.hasAlternatives()).toBe(false);
expect(counts.alternativePlaylistCount()).toBe(0);
});
});
@@ -0,0 +1,47 @@
import { Signal, computed } from '@angular/core';
import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces';
/**
* What the chip and the caption count.
*
* Kept apart because the two numbers differ and the distinction is easy to
* lose: the chip counts STREAMS, while the caption's "also found in N others"
* counts PLAYLISTS — the popover groups a playlist's copies under it, so three
* copies inside one portal must not read as three portals.
*/
export interface VodSourceCounts {
/** Every source except the one in use. */
alternatives: Signal<VodSourceDescriptor[]>;
/** The chip appears only when there is genuinely somewhere else to go. */
hasAlternatives: Signal<boolean>;
alternativeCount: Signal<number>;
alternativePlaylistCount: Signal<number>;
}
export function createVodSourceCounts(
sources: Signal<VodSourceDescriptor[]>
): VodSourceCounts {
const alternatives = computed(() =>
sources().filter((source) => !source.isActive)
);
return {
alternatives,
hasAlternatives: computed(() => alternatives().length > 0),
alternativeCount: computed(() => alternatives().length),
alternativePlaylistCount: computed(() => {
const playlists = new Set(
alternatives().map((source) => source.playlistId)
);
// A playlist holding two copies puts its own id in that set via
// the copy that is not playing, and the caption would then count
// the playlist the user is already watching as an "other".
const active = sources().find((source) => source.isActive);
if (active) {
playlists.delete(active.playlistId);
}
return playlists.size;
}),
};
}
@@ -0,0 +1,103 @@
import { audioDiffersFactually } from '@iptvnator/portal/shared/data-access';
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import { currentSourceRow } from './vod-multi-source-current-row';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
/**
* The row standing for the source the film is already playing from.
*
* It is never resolved — nothing needs to fetch a URL for the stream already on
* screen — so it is the one row that can silently hold no provider facts, and
* every comparison against it then has nothing on one side.
*/
const MOVIE = {
playlistId: 'playlist-1',
playlistName: 'Portal One',
vodId: 101,
catalogItem: { title: 'Dune' },
};
function alternative(language: string): VodSourceCandidate {
return {
id: 'playlist-2:xtream:991',
playlistId: 'playlist-2',
playlistName: 'Portal Two',
portalType: 'xtream',
contentId: 991,
rawTitle: 'Dune',
matchConfidence: 'exact',
year: null,
// Already canonical, as `applyApiMetadata` would have produced.
audioLanguage: { value: language, provenance: 'api' },
} as VodSourceCandidate;
}
describe('currentSourceRow', () => {
it('states no facts before get_vod_info has landed', () => {
const movie = resolveVodMultiSourceMovie({ ...MOVIE, vodInfo: null });
const row = currentSourceRow(movie as never);
// Silence is the honest reading of "not known yet" — a guess here
// would be published with `api` provenance.
expect(row.audio).toBeUndefined();
expect(row.container).toBeUndefined();
expect(row.quality).toBeUndefined();
});
it('carries the provider facts the route already loaded', () => {
const movie = resolveVodMultiSourceMovie({
...MOVIE,
vodInfo: {
name: 'Dune',
audio: { codec_name: 'ac3' },
video: { codec_name: 'h264', width: 1920, height: 1080 },
} as never,
containerExtension: 'mkv',
});
const row = currentSourceRow(movie as never);
// Only the video codecs have canonical spellings; an audio name is
// passed through as the provider wrote it.
expect(row.audio).toEqual({ value: 'ac3', provenance: 'api' });
expect(row.container).toEqual({ value: 'mkv', provenance: 'api' });
expect(row.quality).toEqual({ value: '1080p', provenance: 'api' });
});
it('lets the dub warning fire on a route-to-alternative switch', () => {
const movie = resolveVodMultiSourceMovie({
...MOVIE,
vodInfo: {
name: 'Dune',
audio: { codec_name: 'ac3', tags: { language: 'rus' } },
} as never,
});
const row = currentSourceRow(movie as never);
// `audioDiffersFactually` needs a fact on BOTH sides, so an empty
// route row made the warning structurally unreachable on the
// commonest switch there is — route to alternative. It only ever
// fired between two alternatives that had both been resolved.
expect(audioDiffersFactually(row, alternative('en'))).toBe(true);
expect(audioDiffersFactually(row, alternative('ru'))).toBe(false);
});
it('does not call a codec change a dub change', () => {
const movie = resolveVodMultiSourceMovie({
...MOVIE,
vodInfo: { name: 'Dune', audio: { codec_name: 'ac3' } } as never,
});
const row = currentSourceRow(movie as never);
// The row states a codec and nothing about language. AAC and AC3
// routinely carry the same dub, so there is nothing to warn about —
// and warning anyway trains people to ignore the one that matters.
expect(row.audio?.value).toBe('ac3');
expect(row.audioLanguage).toBeUndefined();
expect(audioDiffersFactually(row, alternative('en'))).toBe(false);
});
});
@@ -0,0 +1,36 @@
import { applyApiMetadata } from '@iptvnator/portal/shared/data-access';
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
/**
* The row standing for the source the route is already playing.
*
* Discovery only returns OTHER playlists, so without this the popover would
* list the alternatives and say nothing about where the film is coming from
* right now — and the "playing" badge would have nothing to attach to.
*
* It carries the provider's stated facts when the page has them. An
* alternative gets its facts from the resolve that precedes playing it, but
* this row is never resolved — the film is already playing from it — so
* without this it held no metadata at all. Everything comparing two sources
* then had nothing on one side: `audioDiffersFactually` in particular returns
* false whenever either side is silent, so the "dub may differ" warning could
* not fire on a route-to-alternative switch, which is the commonest one there
* is.
*/
export function currentSourceRow(
movie: VodMultiSourceMovie
): VodSourceCandidate {
const row: VodSourceCandidate = {
id: `${movie.playlistId}:xtream:${movie.contentId}`,
playlistId: movie.playlistId,
playlistName: movie.playlistName,
portalType: 'xtream',
contentId: movie.contentId,
rawTitle: movie.title,
matchConfidence: 'exact',
year: movie.year ?? null,
};
return movie.metadata ? applyApiMetadata(row, movie.metadata) : row;
}
@@ -0,0 +1,294 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
VodSourceDiscoveryService,
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
import {
ALT_THREE,
ALT_TWO,
CURRENT_A_ID,
MOVIE_A,
MOVIE_B,
PROBE_OK,
createDeferred,
resolveWith,
} from './vod-multi-source-host.fixtures';
/**
* What the database ends up holding.
*
* The row on screen is a promise that the preference survives reopening the
* movie, so a write that did not land must not be shown as one — and the
* aliases a movie can be looked up by are not all safe to write to or delete.
*/
describe('VodMultiSourceHostService — pin persistence', () => {
let service: VodMultiSourceHostService;
const movie = signal<VodMultiSourceMovie | null>(null);
// Whatever is on screen; the pin path distinguishes it from selection.
const playbackLive = signal(false);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
async function loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await service.load(target);
}
function rowFor(sourceId: string) {
return service.sources().find((source) => source.id === sourceId);
}
beforeEach(() => {
jest.resetAllMocks();
movie.set(null);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
resolver.resolve.mockImplementation(resolveWith());
pins.get.mockResolvedValue(null);
pins.set.mockResolvedValue(true);
pins.clear.mockResolvedValue(true);
probes.probe.mockResolvedValue(PROBE_OK);
TestBed.configureTestingModule({
providers: [
VodMultiSourceHostService,
{ provide: VodSourceDiscoveryService, useValue: discovery },
{ provide: VodSourceResolverService, useValue: resolver },
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
],
});
service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
service.bind({ startPlayback, movie, playbackLive })
);
});
it('lets a pin made during a rediscovery win over the old one', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO, ALT_THREE]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
// Enrichment reruns discovery for the same film...
const slow = createDeferred<{
sources: VodSourceCandidate[];
matchKind: string;
}>();
discovery.discover.mockReturnValueOnce(slow.promise);
const reloading = service.load({ ...MOVIE_A, tmdbId: 603 });
while (discovery.discover.mock.calls.length < 2) {
await Promise.resolve();
}
// ...and the user pins a different source while it is still out.
await service.togglePin(ALT_THREE.id);
slow.resolve({
sources: [ALT_TWO, ALT_THREE],
matchKind: 'title-year',
});
await reloading;
// The snapshot that rerun started with is now stale. Restoring it
// would leave the row and Play on a source the database no longer
// holds.
expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
});
it('stores itself under its own keys, never under a remake’s', async () => {
// With a TMDB id there are two keys naming this film and one shared
// with every remake, so both lists are worth asserting on.
await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 });
expect(pins.get.mock.calls[0][0]).toEqual([
'tmdb:603',
'title:the matrix:1999',
'title:the matrix:',
]);
await service.togglePin(ALT_TWO.id);
expect(pins.set).toHaveBeenCalledWith(
expect.objectContaining({ matchKey: 'tmdb:603' }),
expect.any(Array),
expect.any(Array)
);
// Both lists ride along with the write, so none of it can half-apply.
const [, retired, aliases] = pins.set.mock.calls[0] as [
unknown,
string[],
string[],
];
// This film's pre-enrichment key takes the SAME decision instead of
// being deleted: a reopen that has only a title and a year still finds
// the pin, and finds this source rather than the one just replaced.
expect(aliases).toContain('title:the matrix:1999');
expect(retired).not.toContain('title:the matrix:1999');
// The yearless form is shared by every remake: a Dune (2021) pin must
// not answer for — or delete — a row that may be Dune (1984)'s.
expect(aliases).not.toContain('title:the matrix:');
expect(retired).not.toContain('title:the matrix:');
// And never the row just written.
expect(retired).not.toContain('tmdb:603');
});
it('survives a reopen that has not been enriched yet', async () => {
// The whole point of the pin is that it outlives the page. Enrichment
// is asynchronous and optional, so the movie that owned a `tmdb:` key
// when the user pinned it opens again as nothing but a title and a
// year — and asks for the pin under THAT identity.
const rows = new Map<string, unknown>();
pins.set.mockImplementation(
async (
pin: { matchKey: string },
retire: string[] = [],
aliases: string[] = []
) => {
for (const key of [pin.matchKey, ...aliases]) {
rows.set(key, { ...pin, matchKey: key });
}
for (const key of retire) {
rows.delete(key);
}
return true;
}
);
pins.get.mockImplementation(async (keys: string[]) => {
for (const key of keys) {
if (rows.has(key)) {
return rows.get(key);
}
}
return null;
});
await loadMovie([ALT_TWO, ALT_THREE], { ...MOVIE_A, tmdbId: 603 });
await service.togglePin(ALT_THREE.id);
expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true);
// Away to another film and back. That is what makes this a REOPEN:
// the session key is (playlist, content), so a different movie is what
// drops the in-memory controller and forces the pin to be re-read from
// storage — the same thing reopening the page does.
await loadMovie([], MOVIE_B);
await loadMovie([ALT_TWO, ALT_THREE], MOVIE_A);
// MOVIE_A carries no TMDB id, so this lookup asks only for the title
// forms. Stored under the enriched key alone the preference would be
// invisible here, and Play would quietly start the route source.
expect(pins.get).toHaveBeenLastCalledWith([
'title:the matrix:1999',
'title:the matrix:',
]);
expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true);
});
it('keeps the stored pin when the replacement write fails', async () => {
await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 });
pins.set.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
// One transaction: a refused write retires nothing either.
expect(pins.clear).not.toHaveBeenCalled();
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
});
it('retires the ambiguous row it actually read', async () => {
// A pin set before the year was known lives under the yearless key.
// This session read it, so the user is unpinning THAT row — leaving it
// would make the unpin come back on the next open.
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
await service.togglePin(ALT_TWO.id);
const [retired] = pins.clear.mock.calls[0] as [string[]];
expect(retired).toContain('title:the matrix:');
});
it('does not show a pin the database refused to store', async () => {
await loadMovie([ALT_TWO]);
pins.set.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
// The icon promises the preference survives reopening the movie. A
// write that failed makes that a lie, so the row must not change.
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
});
it('retires the aliases in the same call that writes the pin', async () => {
await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 });
await service.togglePin(ALT_TWO.id);
// Previously a write followed by a clear: if the clear failed, the
// surviving alias was read BEFORE the canonical key on the next open
// and started the source the user had just replaced. Neither
// half-outcome had an honest answer, so there is only one call now.
expect(pins.set).toHaveBeenCalledTimes(1);
expect(pins.clear).not.toHaveBeenCalled();
const [, , aliases] = pins.set.mock.calls[0] as [
unknown,
string[],
string[],
];
expect(aliases).toContain('title:the matrix:1999');
});
it('keeps the pin when clearing it fails', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
pins.clear.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
});
});
@@ -0,0 +1,344 @@
import {
ALT_THREE,
ALT_TWO,
CURRENT_A_ID,
MOVIE_A,
createDeferred,
setupVodMultiSourceHost,
} from './vod-multi-source-host.fixtures';
/**
* "Make this the main source" as a persisted decision.
*
* A pin is not decoration: it decides where Play starts and outranks every
* other signal in failover. So what the row shows has to be what the database
* actually holds — under every alias the movie can be looked up by.
*/
describe('VodMultiSourceHostService — pinning', () => {
const harness = setupVodMultiSourceHost();
const {
discovery,
pins,
playbackLive,
resolver,
startPlayback,
vodAutoFailover,
} = harness;
const loadMovie = harness.loadMovie;
const rowFor = harness.rowFor;
let service = harness.service;
beforeEach(() => {
service = harness.reset();
});
it('plays the pinned source instead of the route playlist', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO, ALT_THREE]);
// Without this the persisted preference is only an icon: reopening the
// movie would still start the playlist the route is on.
await expect(service.playPinnedSource()).resolves.toBe('played');
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('resumes the pinned source from the stored position', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
// What the route knows after loading playback positions. Nothing has
// played yet, so no timeupdate has reported anything — and the button
// the user is about to press says "Resume".
service.seedResumePosition(2538);
await expect(service.playPinnedSource()).resolves.toBe('played');
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 2538 }
);
});
it('asks discovery to keep a copy pinned inside the current playlist', async () => {
// The pin was set on another copy of the film in the playlist the user
// is now opening. Excluding that playlist wholesale would leave the pin
// pointing at a row that is not in the list, so Play would ignore it.
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: MOVIE_A.playlistId,
contentId: 4242,
portalType: 'xtream',
});
await loadMovie([]);
expect(discovery.discover).toHaveBeenCalledWith(
expect.objectContaining({
currentPlaylistId: MOVIE_A.playlistId,
keepContentId: 4242,
})
);
});
it('asks for no exception when the pin is in another playlist', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
expect(discovery.discover).toHaveBeenCalledWith(
expect.objectContaining({ keepContentId: null })
);
});
it('resumes a pinned alternative from its OWN stored position', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
// What the page loaded belongs to the ROUTE's copy — a different row
// entirely from the one the user has been watching through the pin.
service.seedResumePosition(12);
const resumeFor = jest.fn().mockResolvedValue(3600);
await expect(service.playPinnedSource(resumeFor)).resolves.toBe(
'played'
);
expect(resumeFor).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id })
);
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 3600 }
);
});
it('honours the pin again after its player is closed', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
await service.play(ALT_TWO.id);
// Playing it makes its row active AND live: Play must not relaunch
// what is already on screen.
playbackLive.set(true);
expect(service.pendingPinnedSourceId()).toBeNull();
// Closing the player leaves the row selected. Reading `isActive`
// alone here sends the next Play to the route copy and ignores the
// stored preference until the page is reopened.
playbackLive.set(false);
expect(service.pendingPinnedSourceId()).toBe(ALT_TWO.id);
});
it('restarts a pinned copy that was watched through', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
service.seedResumePosition(2538);
// The caller applies the same in-progress rule the button uses, so a
// finished copy arrives here as null. Anything else would show Play
// and then start near the end.
await expect(
service.playPinnedSource(jest.fn().mockResolvedValue(null))
).resolves.toBe('played');
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 0 }
);
});
it('starts a pinned source that was never watched from the beginning', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
// The position the PAGE loaded belongs to the route's copy.
service.seedResumePosition(2538);
await expect(
service.playPinnedSource(jest.fn().mockResolvedValue(null))
).resolves.toBe('played');
// Positions are keyed by (playlist, stream). Carrying the route copy's
// timecode into a copy the user has never opened drops them 42 minutes
// into an unstarted film — and the first save writes it back under
// this source's key, making the mistake permanent.
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 0 }
);
});
it('leaves the position alone when the host cannot look one up', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
service.seedResumePosition(2538);
// No lookup was performed, so "never watched" was never established —
// zeroing here would throw away a position nobody contradicted.
await expect(service.playPinnedSource()).resolves.toBe('played');
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 2538 }
);
});
it('lists the route’s own copy once when the pin keeps it', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: MOVIE_A.playlistId,
contentId: MOVIE_A.contentId,
portalType: 'xtream',
});
// Pinning the copy the route is on makes discovery return that very
// row, and prepending the current source again would list one stream
// twice — a phantom copy in the grouping and a chip that counts it.
await loadMovie([
{
...ALT_TWO,
id: CURRENT_A_ID,
playlistId: MOVIE_A.playlistId,
contentId: MOVIE_A.contentId,
},
]);
expect(service.sources().map((source) => source.id)).toEqual([
CURRENT_A_ID,
]);
expect(service.hasAlternatives()).toBe(false);
});
it('hands the playing badge back when the route stream starts', async () => {
await loadMovie([ALT_TWO]);
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
// The user closed that player and pressed Play. The route's own stream
// is what runs now, so the picker and caption must not keep naming the
// alternative as "Playing".
service.markRouteSourceActive();
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(false);
});
it('waits for the stored pin before letting Play fall through', async () => {
const slow = createDeferred<{
sources: VodSourceCandidate[];
matchKind: string;
}>();
discovery.discover.mockReturnValueOnce(slow.promise);
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
const loading = service.load(MOVIE_A);
// Play pressed while the lookup is still out. Answering "nothing is
// pinned" here would start the route's own source and make a persisted
// preference depend on worker latency.
const playing = service.playPinnedSource();
slow.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loading;
await expect(playing).resolves.toBe('played');
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('leaves Play alone when nothing is pinned', async () => {
await loadMovie([ALT_TWO]);
await expect(service.playPinnedSource()).resolves.toBe('unavailable');
expect(startPlayback).not.toHaveBeenCalled();
});
it('prefers the pinned source when failing over', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_THREE.playlistId,
contentId: ALT_THREE.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO, ALT_THREE]);
vodAutoFailover.set(true);
await expect(service.failover()).resolves.not.toBeNull();
expect(rowFor(ALT_THREE.id)?.isActive).toBe(true);
});
it('round-trips a pin under the movie match key', async () => {
await loadMovie([ALT_TWO]);
const matchKeys: string[] = pins.get.mock.calls[0][0];
expect(matchKeys.length).toBeGreaterThan(0);
const pin = {
matchKey: matchKeys[0],
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
};
await service.togglePin(ALT_TWO.id);
// Both key lists travel with the write, in one transaction. This film
// has no TMDB id, so its title key IS the canonical one and there is
// nothing further to store it under or retire.
expect(pins.set).toHaveBeenCalledWith(pin, [], []);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
await service.togglePin(ALT_TWO.id);
expect(pins.clear).toHaveBeenCalled();
// Unpinning writes nothing further.
expect(pins.set).toHaveBeenCalledTimes(1);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
pins.get.mockResolvedValue(pin);
await loadMovie([ALT_TWO]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
expect(rowFor(CURRENT_A_ID)?.isPinned).toBe(false);
});
});
@@ -0,0 +1,361 @@
import { TestBed } from '@angular/core/testing';
import {
ALT_THREE,
ALT_TWO,
MOVIE_A,
MOVIE_B,
createDeferred,
resolveWith,
resolvedFor,
setupVodMultiSourceHost,
} from './vod-multi-source-host.fixtures';
describe('VodMultiSourceHostService — stale resolutions', () => {
const harness = setupVodMultiSourceHost();
const {
discovery,
movie,
pins,
probes,
resolver,
startPlayback,
vodAutoFailover,
} = harness;
const loadMovie = harness.loadMovie;
const rowFor = harness.rowFor;
let service = harness.service;
/** Two consecutive switches, so the "previous" side carries real audio. */
beforeEach(() => {
service = harness.reset();
});
it('drops a switch once the movie identity goes null mid-navigation', async () => {
await loadMovie([ALT_TWO]);
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve.mockReturnValueOnce(slow.promise);
const pending = service.play(ALT_TWO.id);
// Route navigation empties the identity BEFORE the next movie's load()
// runs. Without invalidating the session here, the guard would still
// pass and the old movie's source would start over the new page.
movie.set(null);
TestBed.tick();
slow.resolve(resolvedFor(ALT_TWO, 0));
await expect(pending).resolves.toBe(false);
expect(startPlayback).not.toHaveBeenCalled();
expect(rowFor(ALT_TWO.id)?.isActive).toBe(false);
});
it('lets a source picked during the pinned lookup win', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO, ALT_THREE]);
const lookup = createDeferred<number | null>();
let insideLookup = false;
const pinnedPlay = service.playPinnedSource(() => {
insideLookup = true;
return lookup.promise;
});
while (!insideLookup) {
await Promise.resolve();
}
// The user picks a different source while the pinned resume position
// is still being read.
await service.play(ALT_THREE.id);
startPlayback.mockClear();
lookup.resolve(0);
// The older pinned attempt must not finish last and replace it.
await expect(pinnedPlay).resolves.toBe('superseded');
expect(startPlayback).not.toHaveBeenCalled();
expect(rowFor(ALT_THREE.id)?.isActive).toBe(true);
});
it('tells a superseded pinned play apart from an unusable pin', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO, ALT_THREE]);
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve.mockReturnValueOnce(slow.promise);
const pinnedPlay = service.playPinnedSource();
// Wait until the pinned play is genuinely inside its resolve, so the
// second click is what supersedes it rather than a race on setup.
while (resolver.resolve.mock.calls.length === 0) {
await Promise.resolve();
}
// A second Play (double-click) supersedes the first while it resolves.
await service.play(ALT_THREE.id);
slow.resolve(resolvedFor(ALT_TWO, 0));
// Reporting "unavailable" here would have the caller start the ROUTE
// source over the playback the newer click just began.
await expect(pinnedPlay).resolves.toBe('superseded');
});
it('drops a slower switch that a newer selection already superseded', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve
.mockReturnValueOnce(slow.promise)
.mockImplementation(resolveWith());
// User clicks ALT_TWO, then changes their mind and clicks
// ALT_THREE before the first resolution comes back.
const first = service.play(ALT_TWO.id);
const second = await service.play(ALT_THREE.id);
expect(second).toBe(true);
slow.resolve(resolvedFor(ALT_TWO, 0));
await expect(first).resolves.toBe(false);
// The late arrival must not steal the active source back, nor
// repoint Undo at itself.
expect(rowFor(ALT_THREE.id)?.isActive).toBe(true);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(false);
expect(startPlayback).toHaveBeenLastCalledWith(
expect.objectContaining({
streamUrl: expect.stringContaining(String(ALT_THREE.contentId)),
})
);
});
it('drops a switch whose movie was navigated away from', async () => {
await loadMovie([ALT_TWO]);
service.reportPosition(2538);
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve.mockReturnValueOnce(slow.promise);
const pending = service.play(ALT_TWO.id);
// A different movie opens while the resolution is still in flight.
await loadMovie([ALT_TWO], MOVIE_B);
startPlayback.mockClear();
slow.resolve(resolvedFor(ALT_TWO, 2538));
await expect(pending).resolves.toBe(false);
// The old film's source must not start inside the new session —
// that would also restart it from the new session's zero position.
expect(startPlayback).not.toHaveBeenCalled();
expect(rowFor(ALT_TWO.id)?.isActive).toBe(false);
});
it('keeps a switch alive when the same movie is rediscovered', async () => {
await loadMovie([ALT_TWO]);
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve.mockReturnValueOnce(slow.promise);
const pending = service.play(ALT_TWO.id);
// Enrichment lands mid-resolution and reruns discovery for the SAME
// film. That is a refresh, not a navigation: cancelling here would
// make the user's click do nothing at all.
await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 });
slow.resolve(resolvedFor(ALT_TWO, 0));
await expect(pending).resolves.toBe(true);
expect(startPlayback).toHaveBeenCalledTimes(1);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('waits for a discovery still in flight before giving up', async () => {
const slow = createDeferred<{
sources: VodSourceCandidate[];
matchKind: string;
}>();
discovery.discover.mockReturnValueOnce(slow.promise);
vodAutoFailover.set(true);
const loading = service.load(MOVIE_A);
while (discovery.discover.mock.calls.length === 0) {
await Promise.resolve();
}
// The stream died faster than the database answered. Concluding
// "nowhere to go" here would strand the user on the error screen with
// alternatives landing a moment later and nothing left to retry them.
const failingOver = service.failover();
slow.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loading;
await expect(failingOver).resolves.not.toBeNull();
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('abandons a failover whose movie was left during the wait', async () => {
const slow = createDeferred<{
sources: VodSourceCandidate[];
matchKind: string;
}>();
discovery.discover.mockReturnValueOnce(slow.promise);
vodAutoFailover.set(true);
const loadingA = service.load(MOVIE_A);
// The pin lookup runs first now, so wait until A is genuinely inside
// discovery before the failure arrives.
while (discovery.discover.mock.calls.length === 0) {
await Promise.resolve();
}
const failingOver = service.failover();
// The user navigates while A's discovery is still out. Running against
// whatever controller is current afterwards would answer A's playback
// failure by starting one of B's alternatives.
await loadMovie([ALT_TWO, ALT_THREE], MOVIE_B);
startPlayback.mockClear();
slow.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loadingA;
await expect(failingOver).resolves.toBeNull();
expect(startPlayback).not.toHaveBeenCalled();
});
it('leaves the spinner on the row that is still resolving', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
const slowTwo = createDeferred<ReturnType<typeof resolvedFor>>();
const slowThree = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve
.mockReturnValueOnce(slowTwo.promise)
.mockReturnValueOnce(slowThree.promise);
const first = service.play(ALT_TWO.id);
const second = service.play(ALT_THREE.id);
expect(service.busySourceId()).toBe(ALT_THREE.id);
slowTwo.resolve(resolvedFor(ALT_TWO, 0));
await first;
// The abandoned pick finishing must not take the spinner off the row
// the user is actually waiting on.
expect(service.busySourceId()).toBe(ALT_THREE.id);
slowThree.resolve(resolvedFor(ALT_THREE, 0));
await second;
expect(service.busySourceId()).toBeNull();
});
it('abandons a pinned play whose movie was left during the lookup', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
const lookup = createDeferred<number | null>();
let insideLookup = false;
const playing = service.playPinnedSource(() => {
insideLookup = true;
return lookup.promise;
});
// Wait until the call is genuinely inside the position lookup —
// stopping earlier would only re-prove the guard before it.
while (!insideLookup) {
await Promise.resolve();
}
// Reading that source's own resume position is a database round-trip,
// and another movie can take the screen across it. Playing then would
// hand THIS film's source id to the film now showing.
await loadMovie([ALT_TWO, ALT_THREE], MOVIE_B);
startPlayback.mockClear();
lookup.resolve(3600);
await expect(playing).resolves.toBe('superseded');
expect(startPlayback).not.toHaveBeenCalled();
});
it('drops a switch the route’s own Play superseded', async () => {
await loadMovie([ALT_TWO]);
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve.mockReturnValueOnce(slow.promise);
const pending = service.play(ALT_TWO.id);
// The user gave up waiting and pressed Play on the route's own stream.
// The older resolution must not come back and replace it.
service.markRouteSourceActive();
startPlayback.mockClear();
slow.resolve(resolvedFor(ALT_TWO, 0));
await expect(pending).resolves.toBe(false);
expect(startPlayback).not.toHaveBeenCalled();
});
it('drops a pin change whose movie was navigated away from', async () => {
const pinFor = (candidate: VodSourceCandidate) => ({
matchKey: 'title:the matrix:1999',
playlistId: candidate.playlistId,
contentId: candidate.contentId,
portalType: 'xtream',
});
pins.get.mockResolvedValue(pinFor(ALT_TWO));
await loadMovie([ALT_TWO]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
const slow = createDeferred<boolean>();
pins.clear.mockReturnValueOnce(slow.promise);
const pending = service.togglePin(ALT_TWO.id);
// Another movie opens — with a pin of its own — before the clear
// comes back.
pins.get.mockResolvedValue(pinFor(ALT_THREE));
await loadMovie([ALT_TWO, ALT_THREE], MOVIE_B);
slow.resolve(true);
await pending;
// The late unpin belongs to the film the user left. Applying it here
// would clear the pin this movie just loaded, and its Play action
// would silently stop starting from the preferred source.
expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
});
it('drops a probe result whose movie was navigated away from', async () => {
await loadMovie([ALT_TWO]);
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve.mockReturnValueOnce(slow.promise);
const pending = service.check(ALT_TWO.id);
await loadMovie([ALT_TWO], MOVIE_B);
slow.resolve(resolvedFor(ALT_TWO, undefined));
await pending;
// A stale probe must not attach itself to the new movie's row.
expect(probes.probe).not.toHaveBeenCalled();
expect(rowFor(ALT_TWO.id)?.probe.status).toBe('idle');
});
});
@@ -0,0 +1,276 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
VodSourceDiscoveryService,
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
import {
ALT_THREE,
ALT_TWO,
CURRENT_A_ID,
MOVIE_A,
MOVIE_B,
PROBE_OK,
createDeferred,
resolveWith,
type DiscoveryResult,
} from './vod-multi-source-host.fixtures';
/**
* When a session starts, and when it merely refreshes.
*
* These two are one question: metadata enrichment reruns discovery for the
* film already on screen, and getting that wrong either loses the session or
* never rebuilds the pin keys.
*/
describe('VodMultiSourceHostService — session lifecycle', () => {
let service: VodMultiSourceHostService;
const movie = signal<VodMultiSourceMovie | null>(null);
// Whatever is on screen; the pin path distinguishes it from selection.
const playbackLive = signal(false);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
/** Runs pending root effects, then drains the work they started. */
async function flushEffects(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
TestBed.tick();
await Promise.resolve();
}
}
async function loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await service.load(target);
}
function rowFor(sourceId: string) {
return service.sources().find((source) => source.id === sourceId);
}
beforeEach(() => {
jest.resetAllMocks();
movie.set(null);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
resolver.resolve.mockImplementation(resolveWith());
pins.get.mockResolvedValue(null);
pins.set.mockResolvedValue(true);
pins.clear.mockResolvedValue(true);
probes.probe.mockResolvedValue(PROBE_OK);
TestBed.configureTestingModule({
providers: [
VodMultiSourceHostService,
{ provide: VodSourceDiscoveryService, useValue: discovery },
{ provide: VodSourceResolverService, useValue: resolver },
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
],
});
service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
service.bind({ startPlayback, movie, playbackLive })
);
});
it('discovers once per bound movie identity', async () => {
movie.set(MOVIE_A);
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(1);
expect(discovery.discover).toHaveBeenCalledWith({
title: 'The Matrix',
year: 1999,
currentPlaylistId: 'playlist-1',
keepContentId: null,
});
movie.set({ ...MOVIE_A });
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(1);
movie.set(MOVIE_B);
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(2);
});
it('rediscovers the same movie once enrichment describes it better', async () => {
movie.set(MOVIE_A);
await flushEffects();
// The year and TMDB id arrive with get_vod_info, after the catalog
// title opened the page. Discovery has to run again — that is how a
// yearless search gets its year and a `tmdb:` pin becomes findable.
movie.set({ ...MOVIE_A, tmdbId: 603 });
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(2);
});
it('does not burn a source the user only selected', async () => {
// One alternative, so the route copy is the ONLY fallback left and
// the outcome cannot depend on how candidates are ranked.
await loadMovie([ALT_TWO]);
// Straight to the alternative from the picker: the route copy was
// selected by discovery but never played, so it is still a fallback.
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
expect(rowFor(CURRENT_A_ID)?.isTried).toBe(false);
vodAutoFailover.set(true);
// Burning it at discovery made this failure report the options
// exhausted while a healthy copy sat untouched.
await expect(service.failover()).resolves.not.toBeNull();
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true);
});
it('keeps the playing source when enrichment reloads the same movie', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
// The route copy actually plays first, so its turn is genuinely spent.
service.markRouteSourceActive();
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
service.reportPosition(2538);
await loadMovie([ALT_TWO, ALT_THREE], { ...MOVIE_A, tmdbId: 603 });
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(false);
// The source it already left stays burned, so "each source at most
// once" still holds across the refresh.
expect(rowFor(CURRENT_A_ID)?.isTried).toBe(true);
expect(service.previousSourceId()).toBe(CURRENT_A_ID);
vodAutoFailover.set(true);
await expect(service.failover()).resolves.not.toBeNull();
expect(rowFor(ALT_THREE.id)?.isActive).toBe(true);
// And the live position survived, so it does not restart the film.
expect(resolver.resolve).toHaveBeenLastCalledWith(
expect.objectContaining({ id: ALT_THREE.id }),
{ startTime: 2538 }
);
});
it('keeps the playing row when the refined year rejects it', async () => {
await loadMovie([ALT_TWO]);
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
// Enrichment supplies the release year, and the year gate now rejects
// the copy the yearless search had admitted. Off the LIST is right —
// it is a different film. Off the SCREEN is not: it is the one
// streaming, and the caption must not name a playlist that is not.
await loadMovie([], { ...MOVIE_A, year: 2021 });
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(false);
});
it('resets the failover session when a new movie loads', async () => {
await loadMovie([ALT_TWO]);
vodAutoFailover.set(true);
await expect(service.failover()).resolves.not.toBeNull();
await expect(service.failover()).resolves.toBeNull();
expect(service.previousSourceId()).toBe(CURRENT_A_ID);
await loadMovie([ALT_TWO], MOVIE_B);
expect(service.previousSourceId()).toBeNull();
expect(service.lastSwitch()).toBeNull();
await expect(service.failover()).resolves.not.toBeNull();
});
it('discards a discovery that resolves after the movie changed', async () => {
const first = createDeferred<DiscoveryResult>();
discovery.discover.mockReturnValueOnce(first.promise);
const loadA = service.load(MOVIE_A);
// A's pin lookup runs before its discovery, so wait for the call that
// actually matters rather than guessing at microtask counts.
while (discovery.discover.mock.calls.length === 0) {
await Promise.resolve();
}
const loadB = loadMovie([ALT_THREE], MOVIE_B);
first.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loadA;
await loadB;
// A's alternatives must not appear anywhere in B's session.
expect(service.sources().map((source) => source.id)).toEqual([
'playlist-1:xtream:202',
ALT_THREE.id,
]);
});
it('takes on provider facts that arrive without changing the identity', async () => {
movie.set(MOVIE_A);
await flushEffects();
expect(rowFor(CURRENT_A_ID)?.audio).toBeUndefined();
// A sparse panel answers `get_vod_info` with no year and no TMDB id,
// so the movie key is unchanged and nothing reruns discovery. The
// route row would keep stating nothing, and every comparison against
// it stays one-sided — the dub warning could never fire.
movie.set({
...MOVIE_A,
metadata: { audioCodec: 'ac3', containerExtension: 'mkv' },
});
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(1);
expect(rowFor(CURRENT_A_ID)?.audio).toEqual({
value: 'ac3',
provenance: 'api',
});
expect(rowFor(CURRENT_A_ID)?.container).toEqual({
value: 'mkv',
provenance: 'api',
});
});
it('keeps what the row already knows when facts arrive', async () => {
movie.set(MOVIE_A);
await flushEffects();
await service.check(CURRENT_A_ID);
expect(rowFor(CURRENT_A_ID)?.probe?.status).toBe('ok');
movie.set({ ...MOVIE_A, metadata: { audioCodec: 'ac3' } });
await flushEffects();
// Rebuilding the row from the movie would be the obvious way to do
// this, and it would silently throw away a probe the user asked for.
expect(rowFor(CURRENT_A_ID)?.probe?.status).toBe('ok');
expect(rowFor(CURRENT_A_ID)?.audio?.value).toBe('ac3');
});
});
@@ -0,0 +1,197 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
VodSourceDiscoveryService,
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
/**
* Shared fixtures for the multi-source host specs, extracted so the
* race-condition suite can live in its own file rather than duplicating setup.
*/
import type {
ResolvedPortalPlayback,
VodSourceCandidate,
VodSourceField,
} from '@iptvnator/shared/interfaces';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
export const MOVIE_A: VodMultiSourceMovie = {
playlistId: 'playlist-1',
playlistName: 'Playlist One',
contentId: 101,
title: 'The Matrix',
year: 1999,
};
export const MOVIE_B: VodMultiSourceMovie = {
...MOVIE_A,
contentId: 202,
title: 'Blade Runner',
year: 1982,
}; // a second movie, to prove load() fully resets the session
export const CURRENT_A_ID = 'playlist-1:xtream:101';
export const API_AC3: VodSourceField = { value: 'ac3', provenance: 'api' };
export const API_AAC: VodSourceField = { value: 'aac', provenance: 'api' };
export const PARSED_DUB: VodSourceField = {
value: 'Дубляж',
provenance: 'parsed',
};
export const API_LANG_RUS: VodSourceField = {
value: 'rus',
provenance: 'api',
};
export const API_LANG_ENG: VodSourceField = {
value: 'eng',
provenance: 'api',
};
export const PROBE_OK = { status: 'ok', httpStatus: 200, latencyMs: 42 };
export type DiscoveryResult = {
sources: VodSourceCandidate[];
matchKind: string;
};
/**
* What a resolve should state about the audio.
*
* Two fields, because they answer different questions: `audio` is the codec
* (or a guessed dub marker) and is display-only, while `audioLanguage` is the
* one thing allowed to drive the "dub may differ" warning.
*/
export type AudioFacts = Pick<VodSourceCandidate, 'audio' | 'audioLanguage'>;
export type AudioPicker = (c: VodSourceCandidate) => Partial<AudioFacts>;
export function alternative(index: number): VodSourceCandidate {
return {
id: `playlist-${index}:xtream:${900 + index}`,
playlistId: `playlist-${index}`,
playlistName: `Playlist ${index}`,
portalType: 'xtream',
contentId: 900 + index,
rawTitle: 'The Matrix',
matchConfidence: 'exact',
year: 1999,
};
}
export const [ALT_TWO, ALT_THREE] = [alternative(2), alternative(3)];
export function resolvedFor(
candidate: VodSourceCandidate,
startTime: number | undefined,
audio?: Partial<AudioFacts>
) {
const playback: ResolvedPortalPlayback = {
streamUrl: `http://${candidate.playlistId}/${candidate.contentId}.mkv`,
title: candidate.rawTitle,
isLive: false,
startTime,
};
return { playback, candidate: { ...candidate, ...audio } };
}
/** Stands in for the resolver: echoes the start time, injects audio facts. */
export function resolveWith(audioFor?: AudioPicker) {
return async (
candidate: VodSourceCandidate,
options?: { startTime?: number }
) => resolvedFor(candidate, options?.startTime, audioFor?.(candidate));
}
export function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
/**
* The TestBed every host spec needs.
*
* Five specs carried their own identical copy of this — the same five stubs,
* the same reset, the same bind. One harness so they cannot drift, and so a
* new case does not cost sixty lines of setup.
*/
export function setupVodMultiSourceHost() {
const movie = signal<VodMultiSourceMovie | null>(null);
/** Whatever is on screen; the pin path distinguishes it from selection. */
const playbackLive = signal(false);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
const harness = {
movie,
playbackLive,
vodAutoFailover,
startPlayback,
discovery,
resolver,
pins,
probes,
service: null as unknown as VodMultiSourceHostService,
/** Call from `beforeEach`; returns the freshly bound service. */
reset(): VodMultiSourceHostService {
jest.resetAllMocks();
movie.set(null);
playbackLive.set(false);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
resolver.resolve.mockImplementation(resolveWith());
pins.get.mockResolvedValue(null);
pins.set.mockResolvedValue(true);
pins.clear.mockResolvedValue(true);
probes.probe.mockResolvedValue(PROBE_OK);
TestBed.configureTestingModule({
providers: [
VodMultiSourceHostService,
{ provide: VodSourceDiscoveryService, useValue: discovery },
{ provide: VodSourceResolverService, useValue: resolver },
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
],
});
harness.service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
harness.service.bind({ startPlayback, movie, playbackLive })
);
return harness.service;
},
async loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await harness.service.load(target);
},
rowFor(sourceId: string) {
return harness.service
.sources()
.find((source) => source.id === sourceId);
},
};
return harness;
}
@@ -0,0 +1,358 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
VodSourceDiscoveryService,
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import type {
VodSourceCandidate,
VodSourceField,
} from '@iptvnator/shared/interfaces';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
import {
ALT_THREE,
ALT_TWO,
API_AAC,
API_AC3,
API_LANG_ENG,
API_LANG_RUS,
CURRENT_A_ID,
MOVIE_A,
PARSED_DUB,
PROBE_OK,
createDeferred,
resolveWith,
type AudioFacts,
type DiscoveryResult,
} from './vod-multi-source-host.fixtures';
describe('VodMultiSourceHostService', () => {
let service: VodMultiSourceHostService;
const movie = signal<VodMultiSourceMovie | null>(null);
// Whatever is on screen; the pin path distinguishes it from selection.
const playbackLive = signal(false);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
async function loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await service.load(target);
}
function rowFor(sourceId: string) {
return service.sources().find((source) => source.id === sourceId);
}
function resolvedIds(): string[] {
return resolver.resolve.mock.calls.map((call) => call[0].id);
}
/** Two consecutive switches, so the "previous" side carries real audio. */
async function switchTwice(
first: Partial<AudioFacts> | undefined,
second: Partial<AudioFacts>
) {
await loadMovie([ALT_TWO, ALT_THREE]);
resolver.resolve.mockImplementation(
resolveWith((candidate) =>
candidate.id === ALT_TWO.id ? (first ?? {}) : second
)
);
await service.play(ALT_TWO.id);
await service.play(ALT_THREE.id);
return service.lastSwitch();
}
beforeEach(() => {
jest.resetAllMocks();
movie.set(null);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
resolver.resolve.mockImplementation(resolveWith());
pins.get.mockResolvedValue(null);
pins.set.mockResolvedValue(true);
pins.clear.mockResolvedValue(true);
probes.probe.mockResolvedValue(PROBE_OK);
TestBed.configureTestingModule({
providers: [
VodMultiSourceHostService,
{ provide: VodSourceDiscoveryService, useValue: discovery },
{ provide: VodSourceResolverService, useValue: resolver },
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
],
});
service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
service.bind({ startPlayback, movie, playbackLive })
);
});
it('counts only sources other than the playing one as alternatives', async () => {
await loadMovie([]);
expect(service.sources()).toHaveLength(1);
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true);
expect(service.hasAlternatives()).toBe(false);
expect(service.alternativeCount()).toBe(0);
await loadMovie([ALT_TWO]);
expect(service.sources()).toHaveLength(2);
expect(service.hasAlternatives()).toBe(true);
expect(service.alternativeCount()).toBe(1);
});
it('counts playlists, not copies, for the "found in N others" caption', async () => {
// Two copies inside ONE other playlist. The popover groups them under
// that single portal, so a caption reading "also found in 2 other
// playlists" would contradict the list it invites the user to open.
const secondCopy: VodSourceCandidate = {
...ALT_TWO,
id: `${ALT_TWO.playlistId}:xtream:999`,
contentId: 999,
};
await loadMovie([ALT_TWO, secondCopy]);
expect(service.alternativeCount()).toBe(2);
expect(service.alternativePlaylistCount()).toBe(1);
await loadMovie([ALT_TWO, secondCopy, ALT_THREE]);
expect(service.alternativePlaylistCount()).toBe(2);
});
it('carries the live timecode into the resolve call and the playback', async () => {
await loadMovie([ALT_TWO]);
service.reportPosition(2538);
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 2538 }
);
expect(startPlayback).toHaveBeenCalledTimes(1);
expect(startPlayback).toHaveBeenCalledWith(
expect.objectContaining({ startTime: 2538 })
);
});
it('never puts a credential-bearing playlist name in the notice', async () => {
// Users routinely name a playlist after the URL they pasted, and this
// string goes straight into a toast over the video.
const leaky = {
...ALT_TWO,
playlistName:
'http://portal.example.com:8080/get.php?username=alice&password=hunter2',
};
await loadMovie([leaky]);
await service.play(leaky.id);
const notice = service.lastSwitch();
expect(notice?.playlistName).not.toContain('hunter2');
expect(notice?.playlistName).not.toContain('alice');
expect(notice?.playlistName).toBe('portal.example.com:8080');
});
it('announces every switch with the target playlist', async () => {
await loadMovie([ALT_TWO]);
service.reportPosition(2538);
await service.play(ALT_TWO.id);
expect(service.lastSwitch()).toEqual(
expect.objectContaining({
playlistName: ALT_TWO.playlistName,
resumeSeconds: 2538,
})
);
expect(service.previousSourceId()).toBe(CURRENT_A_ID);
});
it.each<
[string, Partial<AudioFacts> | undefined, Partial<AudioFacts>, boolean]
>([
[
'fires when both sides state a different language',
{ audioLanguage: API_LANG_RUS },
{ audioLanguage: API_LANG_ENG },
true,
],
[
'is silent when the language is the same',
{ audioLanguage: API_LANG_RUS },
{ audioLanguage: API_LANG_RUS },
false,
],
[
// A codec is not a dub: AAC and AC3 routinely carry the same one,
// so this fired on every identical-language re-encode.
'is silent for a codec change alone',
{ audio: API_AC3 },
{ audio: API_AAC },
false,
],
[
'is silent for a guessed dub marker',
{ audioLanguage: PARSED_DUB },
{ audioLanguage: API_LANG_ENG },
false,
],
[
'is silent without both languages',
undefined,
{ audioLanguage: API_LANG_ENG },
false,
],
])('the dub warning %s', async (_name, first, second, expected) => {
expect(await switchTwice(first, second)).toEqual(
expect.objectContaining({ audioMayDiffer: expected })
);
});
it('does not fail over while auto-failover is off', async () => {
await loadMovie([ALT_TWO]);
resolver.resolve.mockClear();
await expect(service.failover()).resolves.toBeNull();
expect(resolver.resolve).not.toHaveBeenCalled();
expect(startPlayback).not.toHaveBeenCalled();
});
it('fails over and announces it once auto-failover is enabled', async () => {
await loadMovie([ALT_TWO]);
vodAutoFailover.set(true);
const notice = await service.failover();
expect(notice).toEqual(
expect.objectContaining({ playlistName: ALT_TWO.playlistName })
);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
expect(startPlayback).toHaveBeenCalledTimes(1);
});
it('never visits a source twice while failing over', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
vodAutoFailover.set(true);
await expect(service.failover()).resolves.not.toBeNull();
await expect(service.failover()).resolves.not.toBeNull();
await expect(service.failover()).resolves.toBeNull();
const visited = resolvedIds();
expect(visited).toHaveLength(2);
expect(new Set(visited).size).toBe(visited.length);
expect(visited).not.toContain(CURRENT_A_ID);
});
it('keeps going when the best candidate cannot be resolved', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
vodAutoFailover.set(true);
// Top-ranked source has a dead account: get_vod_info yields nothing.
resolver.resolve.mockResolvedValueOnce(null);
// Production calls failover() once, on the original failure — giving up
// here would strand a perfectly healthy lower-ranked source.
await expect(service.failover()).resolves.not.toBeNull();
const [failedId, playingId] = resolvedIds();
expect(playingId).not.toBe(failedId);
expect(rowFor(failedId)?.isActive).toBe(false);
expect(rowFor(failedId)?.isTried).toBe(true);
expect(rowFor(playingId)?.isActive).toBe(true);
expect(startPlayback).toHaveBeenCalledTimes(1);
});
it('gives up once every candidate fails to resolve', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
vodAutoFailover.set(true);
resolver.resolve.mockResolvedValue(null);
// Each attempt marks its source tried, so this terminates rather than
// spinning through the same candidate forever.
await expect(service.failover()).resolves.toBeNull();
expect(startPlayback).not.toHaveBeenCalled();
});
it('abandons a waiting failover once the user starts something else', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
vodAutoFailover.set(true);
// A same-movie rediscovery is still out when the stream fails.
const slow = createDeferred<DiscoveryResult>();
discovery.discover.mockReturnValueOnce(slow.promise);
const reloading = service.load({ ...MOVIE_A, tmdbId: 603 });
while (discovery.discover.mock.calls.length < 2) {
await Promise.resolve();
}
const failingOver = service.failover();
// Across that wait the user restarts the route's own stream. The
// session is unchanged — it only moves when the FILM does — so
// checking it alone lets failover treat what they just started as the
// thing that failed and switch away from it.
service.markRouteSourceActive();
slow.resolve({
sources: [ALT_TWO, ALT_THREE],
matchKind: 'title-year',
});
await reloading;
await expect(failingOver).resolves.toBeNull();
expect(startPlayback).not.toHaveBeenCalled();
});
it('reports unknown, never fail, for a source it cannot check', async () => {
await loadMovie([ALT_TWO]);
resolver.resolve.mockResolvedValueOnce(null);
await service.check(ALT_TWO.id);
expect(rowFor(ALT_TWO.id)?.probe.status).toBe('unknown');
expect(probes.probe).not.toHaveBeenCalled();
await service.check(ALT_TWO.id);
// Probed with the playlist's own playback headers, or a panel that
// requires them answers 403 and a working source looks dead.
expect(probes.probe).toHaveBeenCalledWith(
`http://${ALT_TWO.playlistId}/${ALT_TWO.contentId}.mkv`,
'HEAD',
expect.objectContaining({ userAgent: undefined })
);
expect(rowFor(ALT_TWO.id)?.probe).toEqual(PROBE_OK);
});
});
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more