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>
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 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>
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>