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>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 21:40:34 +02:00
1 parent 6cce35274a
commit d3e337261e
7 files changed
+110 -63

No files matched your search

+22 -14
View File
@@ -151,24 +151,32 @@ 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.
## The candidate window
## What the queries are allowed to miss
Both queries take a bounded number of rows, so what fills that window decides
whether an alternative is findable at all:
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
budget before a single other playlist was read.
- **Short titles scan on a word boundary.** 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", and enough of those sorted by title push the real film out of
the window — so the scan asks for the token as a word
(`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`), orders by title
length (a match is the title plus decoration: "IT (2017) 1080p"), and gets a
wider budget than the relevance-ranked FTS path. 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.
FTS window before a single other playlist was read.
- **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 the movie's entire title is one or two
characters, and only films carrying that exact word come back.
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"