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>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 01:16:56 +02:00
1 parent e3465d3a45
commit b1ad9657c3
20 files changed
+718 -188

No files matched your search

+23 -8
View File
@@ -16,7 +16,7 @@ current source is dead, serves an unsupported codec, or buffers badly.
| Environment | **Electron only** — the chip renders nothing in the PWA |
| Auto-failover | Opt-in, **off by default** (`Settings.vodAutoFailover`) |
| Pin scope | Per movie (a global portal priority is out of scope) |
| Stream probe | HEAD → reachable + latency. **No codec probing** |
| 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
@@ -128,12 +128,20 @@ pinned under any poorer form of itself:
| before the TMDB id | `title:{base}:{year}` |
| before the year too | `title:{base}:` |
`buildVodSourceMatchKeyCandidates` returns all three, most-trusted first, and
**writes go to every one of them**. Reading every alias is what keeps a late
TMDB id from orphaning an earlier pin; writing every alias is what stops the
lower-trust ones from still pointing at the source the user just replaced —
which a reopen before enrichment lands would then play. Unpinning clears them
all, so a stale row cannot resurrect it.
`buildVodSourceMatchKeyCandidates` returns all three, most-trusted first.
Reading every alias is what keeps a late TMDB id from orphaning an earlier pin.
A write **clears every alias and then stores only the canonical key** — both
halves matter, and each rules out the other's shortcut:
- Writing only the top key would leave the lower-trust aliases pointing at
whatever was pinned before, and a reopen that reads one of those (enrichment
has not landed, or its request failed) starts the source the user replaced.
- Writing the decision *into* 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 alias stays readable and
unwritten.
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,
@@ -228,6 +236,9 @@ 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
@@ -251,7 +262,11 @@ serves both, because two would eventually disagree.
## Failover
Only fires when `Settings.vodAutoFailover` is on. Ranking (`pickFailoverTarget`):
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.
Ranking (`pickFailoverTarget`):
1. never tried this session — a **hard filter**, not a preference
2. probed reachable; probed failing is penalised