CRITICAL FIX: require title match, not just year, before adding to Sonarr
Build and Deploy / build-and-deploy (push) Successful in 2m14s

The title+year fallback added last commit had no title check at all -
it accepted the first search candidate whose year merely matched (or
ANY candidate at all when our year was null), silently adding a
completely unrelated show to Sonarr. Confirmed in production: "Первый
раз" (2025) resolved to "Around the World in 80 Weighs" (2025) - same
year, nothing else in common.

A candidate now must match both: title (against Sonarr's primary
title OR any alternateTitles, since a Russian-titled search rarely
hits TheTVDB's English primary title directly) AND year within 1,
with neither side allowed to be null/absent as a wildcard. No
confident match resolves to null (the existing 422 path) rather than
ever guessing - many Russian-titled shows will now correctly fail to
resolve instead of silently adding the wrong series.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kayashov.SM
2026-09-18 01:00:26 +04:00
co-authored by Claude Sonnet 5
parent 513eac8f80
commit 60dd95b93b
@@ -153,9 +153,19 @@ public class MediaActionController {
* id-based lookup (SonarrClient.lookupByTmdbId), but this Sonarr
* instance's /series/lookup doesn't recognize the "tmdb:" term prefix at
* all (empirically zero results even for a well-known show) - so the
* real fallback is a plain keyword search, picking the candidate whose
* year matches ours (title alone is too unreliable: Sonarr's TheTVDB-
* backed provider may not carry the same localized title we do).
* real fallback is a plain keyword search.
*
* BUG HISTORY: an earlier version of this fallback accepted the first
* candidate whose YEAR merely matched (or accepted anything at all when
* our year was null), with no title check whatsoever - Sonarr silently
* added a completely unrelated show ("Первый раз" (2025) resolved to
* "Around the World in 80 Weighs" (2025), same year, nothing else in
* common). A candidate must now match BOTH title and year - title
* against Sonarr's primary title OR any of its alternateTitles (a
* Russian-titled search rarely matches TheTVDB's English primary title
* directly), and year within 1. No confident match -> null, never a
* guess: an honest "couldn't find it" beats silently adding the wrong
* series to someone's library.
*/
private Integer resolveTvdbId(MediaEntity media) {
if (media.getTmdbId() != null) {
@@ -166,15 +176,41 @@ public class MediaActionController {
}
for (JsonNode candidate : sonarrClient.searchByTitle(media.getTitle())) {
if (!candidate.has("tvdbId") || candidate.get("tvdbId").asInt() <= 0) continue;
Integer candidateYear = candidate.has("year") && !candidate.get("year").isNull()
? candidate.get("year").asInt() : null;
if (media.getYear() == null || candidateYear == null || Math.abs(candidateYear - media.getYear()) <= 1) {
return candidate.get("tvdbId").asInt();
}
if (!candidateMatches(candidate, media)) continue;
return candidate.get("tvdbId").asInt();
}
return null;
}
private boolean candidateMatches(JsonNode candidate, MediaEntity media) {
Integer candidateYear = candidate.has("year") && !candidate.get("year").isNull()
? candidate.get("year").asInt() : null;
if (!yearMatches(candidateYear, media.getYear())) return false;
if (titleMatches(candidate.path("title").asText(null), media.getTitle())) return true;
if (candidate.has("alternateTitles")) {
for (JsonNode alt : candidate.get("alternateTitles")) {
if (titleMatches(alt.path("title").asText(null), media.getTitle())) return true;
}
}
return false;
}
/** Exact match, or one title containing the other (handles subtitle/prefix variants). Both sides required. */
private boolean titleMatches(String a, String b) {
if (a == null || b == null) return false;
String na = a.trim().toLowerCase();
String nb = b.trim().toLowerCase();
if (na.isEmpty() || nb.isEmpty()) return false;
return na.equals(nb) || na.contains(nb) || nb.contains(na);
}
/** A confident year match requires both sides present and within 1 - unlike the sourcing heuristics elsewhere, a missing year here is NOT treated as a wildcard. */
private boolean yearMatches(Integer a, Integer b) {
if (a == null || b == null) return false;
return Math.abs(a - b) <= 1;
}
/**
* On-demand "find recommendations for this release" from the detail
* modal - runs once per media (see MediaEntity.onDemandRecommendationsFetchedAt),