fix(tmdb): a new series no longer matches its older, better-known namesake

TMDB returns titles in the REQUEST language, so a Cyrillic query issued with
`ru-RU` collapses unrelated foreign shows onto ordinary local nouns: an older
foreign series comes back under exactly the same localized name as a recent
local-language one. `pickConfidentMatch` admitted the older row through the
series "premiered earlier" tolerance — portals report the running season's
year while TMDB reports the premiere — and then let `pickMostPopular` decide
across every admitted candidate, discarding the year evidence that had just
admitted them. 26 votes beat 4, and the 2026 series rendered the 2018 show's
poster, cast, genres and rating.

Rank admitted candidates by year evidence first (`yearEvidenceTier`: the
provider's exact year, then a year off by one, then the series tolerance) and
let popularity break ties only inside the strongest tier any candidate
reached. The tolerance stays — three of eight real lookups from one install
depend on it (asked 2026 / premiere 2025; asked 2025 / premiere 2023; asked
2026 / premiere 2023) — but it is a last resort, not an equal. Measured over
400 Cyrillic series titles sampled from a real catalog, 20 normalized keys
had a same-titled older series and 16 of those were the more popular row.

The mirror case is accepted knowingly: a long-running show whose stated
season year happens to BE another same-titled show's premiere year now
resolves to the newer show. Only the older show's season air dates could
separate the two and a search response does not carry them, while that shape
needs three coincidences at once against one that needs none.

Search cache keys move to `|v4` with a matching startup cleanup, because a
positive row naming the wrong show stays fresh for 30 days.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-09-21 07:44:46 +02:00
1 parent f80a21beef
commit b87ea9a178
9 files changed
+267 -65

No files matched your search

@@ -87,14 +87,14 @@ describe('TmdbIdResolverService.resolveBySearch', () => {
expect(searchTv).toHaveBeenCalledWith('Фейк', null, 'ru-RU', 'key');
expect(cacheSet).toHaveBeenCalledWith({
mediaType: 'tv',
lookupKey: 'title:фейк|year:2026|v3',
lookupKey: 'title:фейк|year:2026|v4',
language: 'ru-RU',
tmdbId: 317869,
payload: null,
});
});
it('caches the miss under the folded v3 key', async () => {
it('caches the miss under the current key, not the folded one', async () => {
searchTv.mockResolvedValue([]);
const service = createService();
@@ -112,7 +112,7 @@ describe('TmdbIdResolverService.resolveBySearch', () => {
);
expect(cacheSet).toHaveBeenCalledWith(
expect.objectContaining({
lookupKey: 'title:молодой шерлок|year:2026|v3',
lookupKey: 'title:молодой шерлок|year:2026|v4',
tmdbId: null,
})
);
@@ -133,7 +133,7 @@ describe('TmdbIdResolverService.resolveBySearch', () => {
expect(id).toBe(317869);
expect(cacheGet).toHaveBeenCalledWith(
'tv',
'title:фейк|year:2026|v3',
'title:фейк|year:2026|v4',
'ru-RU'
);
expect(searchTv).not.toHaveBeenCalled();
@@ -157,8 +157,8 @@ describe('TmdbIdResolverService.resolveBySearch', () => {
expect(
cacheSet.mock.calls.map(([row]) => [row.lookupKey, row.tmdbId])
).toEqual([
['title:феик|year:2026|v3', null],
['title:фейк|year:2026|v3', 317869],
['title:феик|year:2026|v4', null],
['title:фейк|year:2026|v4', 317869],
]);
});
@@ -167,7 +167,7 @@ describe('TmdbIdResolverService.resolveBySearch', () => {
// "феик". Item B shares the original title but displays "Фейк":
// the cached miss must not suppress B's own second variant.
cacheGet.mockImplementation(async (_type: string, key: string) =>
key === 'title:феик|year:2026|v3' ? { tmdbId: null } : null
key === 'title:феик|year:2026|v4' ? { tmdbId: null } : null
);
const service = createService();
const cacheService = (service as unknown as { cache: TmdbCacheService })
@@ -186,8 +186,8 @@ describe('TmdbIdResolverService.resolveBySearch', () => {
expect(searchTv).toHaveBeenCalledTimes(1);
expect(searchTv).toHaveBeenCalledWith('Фейк', null, 'ru-RU', 'key');
expect(cacheGet.mock.calls.map(([, key]) => key)).toEqual([
'title:феик|year:2026|v3',
'title:фейк|year:2026|v3',
'title:феик|year:2026|v4',
'title:фейк|year:2026|v4',
]);
});
@@ -80,10 +80,10 @@ describe('extractYear', () => {
describe('lookup keys', () => {
it('builds stable search and details keys', () => {
expect(buildSearchLookupKey('The Matrix', 1999)).toBe(
'title:the matrix|year:1999|v3'
'title:the matrix|year:1999|v4'
);
expect(buildSearchLookupKey('The Matrix', null)).toBe(
'title:the matrix|year:|v3'
'title:the matrix|year:|v4'
);
});
@@ -92,7 +92,7 @@ describe('lookup keys', () => {
// searches with different answers; a verdict cached for one must
// never be read back for the other.
expect(buildSearchLookupKey('Фейк', 2026)).toBe(
'title:фейк|year:2026|v3'
'title:фейк|year:2026|v4'
);
expect(buildSearchLookupKey('Феик', 2026)).not.toBe(
buildSearchLookupKey('Фейк', 2026)
@@ -329,6 +329,84 @@ describe('pickConfidentMatch', () => {
).toBe(theBoys);
});
it('prefers an exact-year series over an older, more popular namesake', () => {
// Regression: TMDB returns titles in the REQUEST language, so an
// unrelated older foreign series (2018, 26 votes) came back under the
// same localized name as the catalog's own 2026 series (4 votes). The
// older row is admitted only by the running-season tolerance and used
// to win the popularity tiebreak. Titles here are stand-ins.
const translatedNamesake: TmdbSearchResult = {
id: 1,
name: 'Nightfall',
original_name: 'Yoru no Tobari',
first_air_date: '2018-12-14',
vote_count: 26,
popularity: 3.4,
};
const localSeries: TmdbSearchResult = {
id: 2,
name: 'Nightfall',
original_name: 'Nightfall',
first_air_date: '2026-07-24',
vote_count: 4,
popularity: 2.4,
};
expect(
pickConfidentMatch(
[localSeries, translatedNamesake],
{ title: 'Nightfall (12 episodes)', year: 2026 },
'tv'
)
).toBe(localSeries);
});
it('still falls back to an older series when nothing matches the year', () => {
const theBoys: TmdbSearchResult = {
id: 76479,
name: 'The Boys',
first_air_date: '2019-07-26',
vote_count: 12000,
};
const unrelatedOlder: TmdbSearchResult = {
id: 999,
name: 'The Boys',
first_air_date: '2010-01-01',
vote_count: 5,
};
expect(
pickConfidentMatch(
[unrelatedOlder, theBoys],
{ title: 'The Boys s05', year: 2026 },
'tv'
)
).toBe(theBoys);
});
it('prefers the exact release year over one off by one', () => {
const exact: TmdbSearchResult = {
id: 1,
title: 'The Matrix',
release_date: '1999-03-31',
vote_count: 10,
};
const adjacent: TmdbSearchResult = {
id: 2,
title: 'The Matrix',
release_date: '1998-03-31',
vote_count: 9000,
};
expect(
pickConfidentMatch(
[adjacent, exact],
{ title: 'The Matrix', year: 1999 },
'movie'
)
).toBe(exact);
});
it('still rejects movies with a year that differs by more than one', () => {
expect(
pickConfidentMatch(
+76 -16
View File
@@ -121,7 +121,10 @@ export function buildSearchLookupKey(
// under v2 for a title with "й"/"ё" is that bug, not a missing title, and
// must not block the retry for its 7-day TTL. Rows are keyed by the
// query since then.
return `title:${searchQueryIdentity(query)}|year:${year ?? ''}|v3`;
// v4: year evidence is tiered (see `yearEvidenceTier`), so every v3 row
// resolved by popularity across tiers may name the wrong show — and a
// positive row stays fresh for 30 days.
return `title:${searchQueryIdentity(query)}|year:${year ?? ''}|v4`;
}
export function buildDetailsLookupKey(tmdbId: number): string {
@@ -256,6 +259,52 @@ function resultYear(
);
}
/**
* How strongly one candidate's own year backs the year the provider stated.
* Lower is stronger; `null` means the candidate is not admissible at all.
*
* The series tier is what makes long-running shows work: a portal reports
* the CURRENT season's year ("The Boys s05" → 2026) while TMDB's
* `first_air_date` is the 2019 premiere. It is a last resort, though, not an
* equal — ranked alongside the exact-year tier with popularity deciding, it
* hands every NEW series its older, better-known namesake. TMDB returns
* titles in the REQUEST language, so in a non-English catalog those
* collisions are routine rather than exotic: a 2026 local-language drama
* (4 votes) lost to an unrelated 2018 foreign show TMDB lists under the same
* localized name (26 votes), and rendered its poster, cast and genres. Over
* 400 Cyrillic series titles sampled from a real catalog, 20 normalized keys
* had a same-titled older series and 16 of those were the more popular row.
*
* The mirror case survives on purpose: a long-running show whose stated
* season year happens to BE another same-titled show's premiere year now
* resolves to the newer show. Only the older show's season air dates could
* separate the two, and a search response does not carry them — while the
* shape needs three coincidences at once, against one that needs none.
*/
const YEAR_TIER_EXACT = 0;
const YEAR_TIER_ADJACENT = 1;
const YEAR_TIER_EARLIER_SERIES = 2;
function yearEvidenceTier(
year: number | null,
wantedYear: number,
mediaType: TmdbMediaType
): number | null {
if (year === null) {
return null;
}
if (year === wantedYear) {
return YEAR_TIER_EXACT;
}
if (Math.abs(year - wantedYear) === 1) {
return YEAR_TIER_ADJACENT;
}
return mediaType === 'tv' && year < wantedYear
? YEAR_TIER_EARLIER_SERIES
: null;
}
/**
* Pick the search result that confidently matches the queried title/year.
* Returns `null` when confidence is insufficient — enrichment must never
@@ -283,25 +332,36 @@ export function pickConfidentMatch(
const wantedYear = query.year;
if (wantedYear !== null) {
const yearMatches = exactTitleMatches.filter((result) => {
const year = resultYear(result, mediaType);
if (year === null) {
return false;
}
if (Math.abs(year - wantedYear) <= 1) {
return true;
}
// Series: providers often report the CURRENT season's year
// ("The Boys s05" → 2026) while TMDB's first_air_date is the
// show's premiere (2019) — accept shows that started earlier.
return mediaType === 'tv' && year < wantedYear;
});
// Popularity only breaks ties INSIDE the strongest tier any
// candidate reached — see `yearEvidenceTier`.
const ranked = exactTitleMatches
.map((result) => ({
result,
tier: yearEvidenceTier(
resultYear(result, mediaType),
wantedYear,
mediaType
),
}))
.filter(
(
candidate
): candidate is {
result: TmdbSearchResult;
tier: number;
} => candidate.tier !== null
);
if (yearMatches.length === 0) {
if (ranked.length === 0) {
return null;
}
return pickMostPopular(yearMatches);
const bestTier = Math.min(...ranked.map((candidate) => candidate.tier));
return pickMostPopular(
ranked
.filter((candidate) => candidate.tier === bestTier)
.map((candidate) => candidate.result)
);
}
// Without a year the title must be unambiguous
@@ -159,20 +159,24 @@ describe('TMDB search lookup cache cleanup', () => {
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).toHaveBeenCalledTimes(2);
expect(deleteRun).toHaveBeenCalledTimes(2);
expect(transaction).toHaveBeenCalledTimes(3);
expect(deleteRun).toHaveBeenCalledTimes(3);
expect(markerRun.mock.calls.map(([key]) => key)).toEqual([
'migration:tmdb-search-lookup-v2-cache-cleanup:v1',
'migration:tmdb-search-lookup-v3-cache-cleanup:v1',
'migration:tmdb-search-lookup-v4-cache-cleanup:v1',
]);
const [unversionedDelete, v2Delete] = deleteStatements(sqlite);
const [unversionedDelete, v2Delete, v3Delete] =
deleteStatements(sqlite);
expect(unversionedDelete).toContain(
"lookup_key LIKE 'title:%|year:%' AND lookup_key NOT LIKE 'title:%|year:%|v%'"
);
// Only v2 search rows: the v3 rows the resolver writes now, and the
// `id:`/`person:`/`badProviderId:` rows, must survive.
// Each generation deletes only its own rows: the v4 rows the resolver
// writes now, and the `id:`/`person:`/`badProviderId:` rows, survive.
expect(v2Delete).toContain("WHERE lookup_key LIKE 'title:%|year:%|v2'");
expect(v2Delete).not.toContain('v3');
expect(v3Delete).toContain("WHERE lookup_key LIKE 'title:%|year:%|v3'");
expect(v3Delete).not.toContain('v4');
});
it('runs only the generations that have not completed yet', () => {
@@ -193,13 +197,14 @@ describe('TMDB search lookup cache cleanup', () => {
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).toHaveBeenCalledTimes(1);
expect(markerRun).toHaveBeenCalledTimes(1);
expect(markerRun).toHaveBeenCalledWith(
'migration:tmdb-search-lookup-v3-cache-cleanup:v1'
);
expect(transaction).toHaveBeenCalledTimes(2);
expect(markerRun.mock.calls.map(([key]) => key)).toEqual([
'migration:tmdb-search-lookup-v3-cache-cleanup:v1',
'migration:tmdb-search-lookup-v4-cache-cleanup:v1',
]);
expect(deleteStatements(sqlite)).toEqual([
expect.stringContaining("lookup_key LIKE 'title:%|year:%|v2'"),
expect.stringContaining("lookup_key LIKE 'title:%|year:%|v3'"),
]);
});
@@ -212,7 +217,7 @@ describe('TMDB search lookup cache cleanup', () => {
expect(transaction).not.toHaveBeenCalled();
// One marker read per retired generation, nothing else
expect(prepare).toHaveBeenCalledTimes(2);
expect(prepare).toHaveBeenCalledTimes(3);
});
});
@@ -45,6 +45,8 @@ const TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const TMDB_SEARCH_LOOKUP_V3_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
const TMDB_SEARCH_LOOKUP_V4_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000;
function readTraceFlag(name: string): boolean {
@@ -977,6 +979,10 @@ function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void {
* v2 every title with a Cyrillic "й"/"ё" was searched folded ("феик" for
* "Фейк", "елки" for "Ёлки"), got no answer, and was cached as missing for
* 7 days.
* - v3 → v4: year evidence became tiered. Under v3 a series admitted only by
* the "premiered earlier" tolerance competed with an exact-year match on
* popularity alone, so a new series resolved to its older, better-known
* namesake — and that positive row stays fresh for 30 days.
*/
const LEGACY_TMDB_SEARCH_CACHE_CLEANUPS: ReadonlyArray<{
migrationKey: string;
@@ -991,6 +997,10 @@ const LEGACY_TMDB_SEARCH_CACHE_CLEANUPS: ReadonlyArray<{
migrationKey: TMDB_SEARCH_LOOKUP_V3_CACHE_CLEANUP_MIGRATION_KEY,
rowPredicate: `lookup_key LIKE 'title:%|year:%|v2'`,
},
{
migrationKey: TMDB_SEARCH_LOOKUP_V4_CACHE_CLEANUP_MIGRATION_KEY,
rowPredicate: `lookup_key LIKE 'title:%|year:%|v3'`,
},
];
/**
@@ -28,11 +28,13 @@ it('drops only retired search rows across skipped, previous, pre-person, fresh a
console.log = () => undefined;
const V2_MARKER = 'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const V3_MARKER = 'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
const V4_MARKER = 'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
const ROWS = [
['tv', 'title:феик|year:2026', 'ru-RU', null],
['tv', 'title:феик|year:2026|v2', 'ru-RU', null],
['tv', 'title:the boys|year:2019|v2', 'en-US', 76479],
['tv', 'title:фейк|year:2026|v3', 'ru-RU', 317869],
['tv', 'title:nightfall|year:2026|v4', 'ru-RU', 424242],
['tv', 'id:317869|v2', 'ru-RU', 317869],
['tv', 'id:317869|season:1', 'ru-RU', 317869],
['person', 'person:287', 'en-US', 287],
@@ -61,11 +63,11 @@ it('drops only retired search rows across skipped, previous, pre-person, fresh a
hooks.runMigrations(skipped);
const skippedAfter = snapshot(skipped);
// Next startup: a row written meanwhile under the current key survives
skipped.prepare("INSERT INTO tmdb_metadata (media_type, lookup_key, language, tmdb_id) VALUES ('tv', 'title:гудовы|year:2026|v3', 'ru-RU', 318894)").run();
skipped.prepare("INSERT INTO tmdb_metadata (media_type, lookup_key, language, tmdb_id) VALUES ('tv', 'title:гудовы|year:2026|v4', 'ru-RU', 318894)").run();
hooks.runMigrations(skipped);
const repeated = snapshot(skipped);
// Previous release: the unversioned cleanup already ran; only v2 rows go
const previous = openDb([V2_MARKER]);
// Previous release: the earlier cleanups already ran; only v3 rows go
const previous = openDb([V2_MARKER, V3_MARKER]);
hooks.runMigrations(previous);
const previousAfter = snapshot(previous);
hooks.runMigrations(previous);
@@ -104,12 +106,13 @@ it('drops only retired search rows across skipped, previous, pre-person, fresh a
const V2_MARKER = 'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const V3_MARKER = 'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
const V4_MARKER = 'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
const survivors = [
'badProviderId:999',
'id:317869|season:1',
'id:317869|v2',
'person:287',
'title:фейк|year:2026|v3',
'title:nightfall|year:2026|v4',
'trending:week',
];
const detailsPayloads = ['{"id":317869}', '{"id":317869}'];
@@ -118,40 +121,54 @@ it('drops only retired search rows across skipped, previous, pre-person, fresh a
skippedAfter: {
keys: survivors,
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
repeated: {
keys: [...survivors, 'title:гудовы|year:2026|v3'].sort(),
keys: [...survivors, 'title:гудовы|year:2026|v4'].sort(),
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
previousAfter: {
// The unversioned row is that generation's business, already done
keys: [...survivors, 'title:феик|year:2026'].sort(),
// The older generations' rows are their business, already done
keys: [
...survivors,
'title:феик|year:2026',
'title:феик|year:2026|v2',
'title:the boys|year:2019|v2',
].sort(),
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
previousRepeated: {
keys: [...survivors, 'title:феик|year:2026'].sort(),
keys: [
...survivors,
'title:феик|year:2026',
'title:феик|year:2026|v2',
'title:the boys|year:2019|v2',
].sort(),
payloads: detailsPayloads,
markers: [V2_MARKER, V3_MARKER],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
prePersonAfter: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
check: true,
},
prePersonRepeated: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
freshAfter: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
freshAfter: { keys: [], payloads: [], markers: [V2_MARKER, V3_MARKER] },
freshRepeated: {
keys: [],
payloads: [],
markers: [V2_MARKER, V3_MARKER],
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
},
});
});