mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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>
This commit is contained in:
1 parent
004cb65fe6
commit
4db3a2fdea
6 files changed
+392
-108
No files matched your search
@@ -40,15 +40,32 @@ describe('title-sources.operations', () => {
|
||||
expect(all).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns nothing when no token survives the trigram minimum', async () => {
|
||||
const { db, all } = createDbMock([duneRow]);
|
||||
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('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 },
|
||||
]);
|
||||
|
||||
// "up" is two chars — the trigram tokenizer cannot match it, so
|
||||
// there is no FTS query left to run.
|
||||
await expect(
|
||||
findTitleSources(db, { title: 'Up' })
|
||||
).resolves.toEqual([]);
|
||||
expect(all).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns nothing instead of throwing when the FTS query fails', async () => {
|
||||
|
||||
@@ -50,6 +50,59 @@ function buildFtsMatchQuery(normalizedTitle: string): string {
|
||||
return tokens.join(' AND ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, but bounded by the same limit
|
||||
* and only reachable for the handful of titles FTS structurally cannot serve.
|
||||
*/
|
||||
function scanCandidateQuery(base: string) {
|
||||
const like = `%${base.replace(/[\\%_]/g, '\\$&')}%`;
|
||||
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 LOWER(c.title) LIKE ${like} ESCAPE '\\'
|
||||
ORDER BY c.title
|
||||
LIMIT ${CANDIDATE_LIMIT}
|
||||
`;
|
||||
}
|
||||
|
||||
function ftsCandidateQuery(matchQuery: string) {
|
||||
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'
|
||||
ORDER BY rank, c.title
|
||||
LIMIT ${CANDIDATE_LIMIT}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function findTitleSources(
|
||||
db: AppDatabase,
|
||||
request: FindTitleSourcesRequest
|
||||
@@ -60,38 +113,26 @@ export async function findTitleSources(
|
||||
}
|
||||
|
||||
const wanted = normalizeTitleKeys(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);
|
||||
if (!wanted.base || !matchQuery) {
|
||||
if (!wanted.base) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// The year the caller knows, falling back to a release tag on the title.
|
||||
const wantedYear = request.year ?? wanted.trailingYear ?? null;
|
||||
|
||||
// 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);
|
||||
|
||||
let rows: TitleSourceRow[];
|
||||
try {
|
||||
rows = (await db.all(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'
|
||||
ORDER BY rank, c.title
|
||||
LIMIT ${CANDIDATE_LIMIT}
|
||||
`)) as TitleSourceRow[];
|
||||
rows = matchQuery
|
||||
? ((await db.all(
|
||||
ftsCandidateQuery(matchQuery)
|
||||
)) as TitleSourceRow[])
|
||||
: ((await db.all(
|
||||
scanCandidateQuery(wanted.base)
|
||||
)) as TitleSourceRow[]);
|
||||
} catch {
|
||||
// Malformed FTS query for exotic titles — no sources, not a crash
|
||||
return [];
|
||||
|
||||
Reference in new issue
Block a user