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