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

+30 -8
View File
@@ -141,7 +141,27 @@ Wrong metadata is worse than no metadata, so id resolution is conservative:
provider year — portals report the current season's year while TMDB's
`first_air_date` is the premiere. Without a year, the exact-title match
must be unambiguous (single hit).
4. No confident match → the provider data stays untouched, and the negative
4. Several admitted results are ranked by **year evidence first**
(`yearEvidenceTier`): the provider's exact year beats a year off by one,
which beats the series "premiered earlier" tolerance. Popularity
(`vote_count`, then `popularity`) only breaks ties inside the strongest
tier any candidate reached.
The tolerance is a last resort, not an equal. Ranked alongside the
exact-year tier it handed every new series its older, better-known
namesake: because TMDB returns titles in the request language, an
unrelated 2018 foreign series came back under the same `ru-RU` name as a
2026 local-language series and outvoted it, rendering 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 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. Separating those two needs the older show's
season air dates, which a search response does not carry — and the shape
needs three coincidences at once, against one that needs none.
5. No confident match → the provider data stays untouched, and the negative
verdict is cached (shorter TTL) so browsing back doesn't re-search.
The year filter is applied client-side rather than via TMDB's strict
@@ -475,7 +495,7 @@ tmdb_metadata (
media_type 'movie' | 'tv' | 'person',
lookup_key 'id:<tmdbId>|v2' -- details payload row
'id:<tmdbId>|season:<n>' -- season payload row
'title:<query lowercased>|year:<y>|v3' -- search resolution row
'title:<query lowercased>|year:<y>|v4' -- search resolution row
'person:<personId>' -- person payload row
'trending:week' -- trending list row
'badProviderId:<tmdbId>' -- id confirmed 404 by TMDB
@@ -490,19 +510,21 @@ tmdb_metadata (
TTLs (enforced at read time in `TmdbCacheService.isFresh`): details and
positive matches 30 days, negative matches 7 days.
Search keys carry a `|v3` and details keys a `|v2` version suffix
Search keys carry a `|v4` and details keys a `|v2` version suffix
(`buildSearchLookupKey` / `buildDetailsLookupKey` in `tmdb-matcher.ts`): for
search rows so normalization or query changes cannot reuse stale positive or
negative resolutions, for details rows because payloads now include videos
via `append_to_response` and pre-videos cache rows had to be invalidated.
Search v2 → v3 retired the rows written while the folded comparison key was
also the wire query (see "Search query vs. comparison key" below). Database
also the wire query (see "Search query vs. comparison key" below); v3 → v4
retired the rows resolved before year evidence was tiered, where a positive
row naming the wrong show would otherwise stay fresh for 30 days. Database
startup deletes the rows of every retired search-key generation once, each
under its own `app_state` marker so a skipped release still runs the cleanups
it missed (`migration:tmdb-search-lookup-v2-cache-cleanup:v1` for the
unversioned rows, `migration:tmdb-search-lookup-v3-cache-cleanup:v1` for the
`|v2` rows; `LEGACY_TMDB_SEARCH_CACHE_CLEANUPS` in `connection.ts`); details
and person cache rows are unaffected.
unversioned rows, `…-v3-…` for the `|v2` rows, `…-v4-…` for the `|v3` rows;
`LEGACY_TMDB_SEARCH_CACHE_CLEANUPS` in `connection.ts`); details and person
cache rows are unaffected.
### Search query vs. comparison key
@@ -513,7 +535,7 @@ both sides fold the same way. `query` is `cleanTitleForSearch`
stripping, but the letters left as the provider wrote them. The search's
identity is the query with only its case removed (`searchQueryIdentity`):
variants are deduplicated by it and every attempted variant is cached under
its own key (`title:<identity>|year:<y>|v3`, in the language that variant
its own key (`title:<identity>|year:<y>|v4`, in the language that variant
was searched in), never by the folded key and never only under the first
variant — "Феик" and "Фейк" fold to one key but are different searches with
different answers, so a verdict for one must not be read back for the other;