From 60dd95b93bffcf422dbcff5a5bd75ff040936163 Mon Sep 17 00:00:00 2001 From: "Kayashov.SM" Date: Fri, 18 Sep 2026 01:00:26 +0400 Subject: [PATCH] CRITICAL FIX: require title match, not just year, before adding to Sonarr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../controller/MediaActionController.java | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/recommendarr/controller/MediaActionController.java b/src/main/java/com/recommendarr/controller/MediaActionController.java index 908006a..03e8136 100644 --- a/src/main/java/com/recommendarr/controller/MediaActionController.java +++ b/src/main/java/com/recommendarr/controller/MediaActionController.java @@ -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),