From f7ebe91fc15dab3b202dc8f16c05eb95a99e6fdc Mon Sep 17 00:00:00 2001 From: "Kayashov.SM" Date: Wed, 16 Sep 2026 00:56:56 +0400 Subject: [PATCH] Search Kinopoisk by imdbId directly instead of confirming via detail-fetch Revised per feedback: Kinopoisk's "films by filter" endpoint (/api/v2.2/films?imdbId=...) accepts a direct imdbId lookup and returns imdbId on each result - no need to search by keyword and then spend extra requests confirming candidates one by one. Resolution is now two independent OR'd attempts (never one request ANDing both signals): searchByImdbId first when the source has one, falling back to searchByKeyword + the title/year heuristic only when there's no imdbId or that search comes back empty. This is also cheaper (1-2 requests per source instead of up to 4). Per-source outcomes are logged at DEBUG (successes) and DEBUG (misses, with a plain-English reason) rather than one INFO line per source - meaningful per-request logging without burying the phase summary INFO line under dozens/hundreds of lines every run. Co-Authored-By: Claude Sonnet 5 --- .../recommendarr/service/KinopoiskClient.java | 91 ++++++++++-------- .../service/RecomputeService.java | 95 +++++++++---------- 2 files changed, 92 insertions(+), 94 deletions(-) diff --git a/src/main/java/com/recommendarr/service/KinopoiskClient.java b/src/main/java/com/recommendarr/service/KinopoiskClient.java index 4e15ac6..00d1d3e 100644 --- a/src/main/java/com/recommendarr/service/KinopoiskClient.java +++ b/src/main/java/com/recommendarr/service/KinopoiskClient.java @@ -17,11 +17,16 @@ import java.util.List; /** * Thin wrapper around the unofficial kinopoiskapiunofficial.tech API - the - * second discovery source for Intersection rows alongside TmdbClient. Every - * data method degrades to an empty result on any error EXCEPT 402 (Payment - * Required - the actual daily quota is exhausted), which is surfaced as - * KinopoiskQuotaExceededException so RecomputeService can stop spending its - * budget on guaranteed-fail requests for the rest of the current run. + * second discovery source for Intersection rows alongside TmdbClient. + * Resolving a source's kinopoiskId prefers searchByImdbId (a real shared + * external key with TMDB/Radarr/Sonarr) and only falls back to the fuzzy + * searchByKeyword when there's no imdbId to search by, or it finds nothing - + * see RecomputeService.resolveKinopoiskId for the actual two-step strategy. + * + * Every data method degrades to an empty result on any error EXCEPT 402 + * (Payment Required - the actual daily quota is exhausted), which is + * surfaced as KinopoiskQuotaExceededException so RecomputeService can stop + * spending its budget on guaranteed-fail requests for the rest of the run. * * 429 (Too Many Requests) is a different, short-lived per-second rate limit * rather than the daily quota - observed to trigger after as few as ~14 @@ -79,10 +84,46 @@ public class KinopoiskClient { // English/original-title) library title - keep all of them rather than // collapsing to a single preferred field, which was hiding real matches // (e.g. "The Legend of Korra" only ever compared against "Легенда о Корре"). - public record KinopoiskCandidate(Integer kinopoiskId, String nameRu, String nameEn, Integer year) { + // imdbId is null from searchByKeyword (that endpoint doesn't return it) + // and populated from searchByImdbId. + public record KinopoiskCandidate(Integer kinopoiskId, String imdbId, String nameRu, String nameEn, Integer year) { } - /** Title search, used once per source to resolve+cache its Kinopoisk id. */ + /** + * Direct lookup by imdbId via Kinopoisk's "films by filter" endpoint - + * the primary resolution path: an external id shared with TMDB/Radarr/ + * Sonarr is a far stronger signal than any title comparison. Falls back + * to searchByKeyword only when this finds nothing (see RecomputeService). + */ + public List searchByImdbId(String imdbId) { + IntegrationSetting setting = settingService.getOrCreate(IntegrationType.KINOPOISK); + if (!setting.isEnabled() || setting.getApiKey() == null) { + return List.of(); + } + List results = new ArrayList<>(); + try { + String encoded = URLEncoder.encode(imdbId, StandardCharsets.UTF_8); + JsonNode response = getWithRateLimitHandling(setting, + "/api/v2.2/films?imdbId=" + encoded, "search-by-imdbId '" + imdbId + "'"); + if (response == null || !response.has("items")) return results; + for (JsonNode item : response.get("items")) { + Integer id = item.has("kinopoiskId") ? item.get("kinopoiskId").asInt() : null; + if (id == null) continue; + String foundImdbId = item.path("imdbId").asText(null); + String nameRu = item.path("nameRu").asText(null); + String nameEn = item.path("nameEn").asText(null); + Integer year = item.has("year") && !item.get("year").isNull() ? item.get("year").asInt() : null; + results.add(new KinopoiskCandidate(id, foundImdbId, nameRu, nameEn, year)); + } + } catch (KinopoiskQuotaExceededException e) { + throw e; + } catch (Exception e) { + log.warn("Kinopoisk search-by-imdbId failed for '{}': {}", imdbId, e.getMessage()); + } + return results; + } + + /** Title search, used as a fallback when a source has no imdbId or searchByImdbId found nothing. */ public List searchByKeyword(String keyword) { IntegrationSetting setting = settingService.getOrCreate(IntegrationType.KINOPOISK); if (!setting.isEnabled() || setting.getApiKey() == null) { @@ -100,7 +141,7 @@ public class KinopoiskClient { String nameRu = item.path("nameRu").asText(null); String nameEn = item.path("nameEn").asText(null); Integer year = parseYear(item.path("year").asText(null)); - if (id != null) results.add(new KinopoiskCandidate(id, nameRu, nameEn, year)); + if (id != null) results.add(new KinopoiskCandidate(id, null, nameRu, nameEn, year)); } } catch (KinopoiskQuotaExceededException e) { throw e; @@ -140,40 +181,6 @@ public class KinopoiskClient { return results; } - public record KinopoiskFilmDetails(Integer kinopoiskId, String imdbId, String nameRu, String nameEn, Integer year) { - } - - /** - * Full film details for one candidate - unlike search-by-keyword, this - * includes imdbId, so a candidate resolved via search can be confirmed - * against the source's own imdbId (already captured from Radarr/Sonarr) - * instead of relying on fuzzy title matching. Used sparingly (a handful - * of candidates per source, not the whole result list) since it costs - * one extra paced request per call. - */ - public KinopoiskFilmDetails getFilmDetails(int kinopoiskId) { - IntegrationSetting setting = settingService.getOrCreate(IntegrationType.KINOPOISK); - if (!setting.isEnabled() || setting.getApiKey() == null) { - return null; - } - try { - JsonNode response = getWithRateLimitHandling(setting, - "/api/v2.2/films/" + kinopoiskId, "film details for filmId " + kinopoiskId); - if (response == null) return null; - String imdbId = response.path("imdbId").asText(null); - String nameRu = response.path("nameRu").asText(null); - String nameEn = response.path("nameEn").asText(null); - Integer year = response.has("year") && !response.get("year").isNull() - ? response.get("year").asInt() : null; - return new KinopoiskFilmDetails(kinopoiskId, imdbId, nameRu, nameEn, year); - } catch (KinopoiskQuotaExceededException e) { - throw e; - } catch (Exception e) { - log.warn("Kinopoisk film details failed for filmId {}: {}", kinopoiskId, e.getMessage()); - return null; - } - } - private Integer parseYear(String yearText) { if (yearText == null || yearText.length() < 4) return null; try { diff --git a/src/main/java/com/recommendarr/service/RecomputeService.java b/src/main/java/com/recommendarr/service/RecomputeService.java index f5bd0d1..66467d6 100644 --- a/src/main/java/com/recommendarr/service/RecomputeService.java +++ b/src/main/java/com/recommendarr/service/RecomputeService.java @@ -209,7 +209,6 @@ public class RecomputeService { int newIntersections = 0; int requestsSpent = 0; int resolvedIds = 0; - int unmatchedSampleLogs = 0; for (int i = 0; i < candidates.size(); i++) { MediaEntity source = candidates.get(i); if (requestsSpent >= budget) { @@ -223,23 +222,21 @@ public class RecomputeService { } try { if (source.getKinopoiskId() == null) { - requestsSpent++; - List rawCandidates = kinopoiskClient.searchByKeyword(source.getTitle()); - KinopoiskResolution resolution = resolveKinopoiskCandidate(rawCandidates, source); - requestsSpent += resolution.extraRequests(); + KinopoiskResolution resolution = resolveKinopoiskId(source); + requestsSpent += resolution.requestsSpent(); if (resolution.candidate() != null) { source.setKinopoiskId(resolution.candidate().kinopoiskId()); resolvedIds++; - } else if (!rawCandidates.isEmpty() && unmatchedSampleLogs < 5) { - // Kinopoisk found something for this title, but neither - // imdbId confirmation nor the title+year heuristic - // accepted any of it - log a sample so a suspiciously - // low hit rate can be diagnosed without the raw API key. - unmatchedSampleLogs++; - KinopoiskClient.KinopoiskCandidate first = rawCandidates.get(0); - log.info("Kinopoisk search for '{}' ({}, imdb={}) had {} result(s), none matched - first: '{}'/'{}' ({})", - source.getTitle(), source.getYear(), source.getImdbId(), rawCandidates.size(), - first.nameRu(), first.nameEn(), first.year()); + log.debug("Kinopoisk resolved '{}' ({}) -> kinopoiskId {} via {}", + source.getTitle(), source.getYear(), resolution.candidate().kinopoiskId(), resolution.via()); + } else { + // Per-source outcome is DEBUG, not INFO - with dozens + // to hundreds of sources per run this would otherwise + // bury the INFO log; the phase summary below stays at + // INFO, and this is available by raising the log + // level for com.recommendarr when actually diagnosing. + log.debug("Kinopoisk: no match for '{}' ({}, imdb={}) - {}", + source.getTitle(), source.getYear(), source.getImdbId(), resolution.via()); } } if (source.getKinopoiskId() != null) { @@ -270,53 +267,47 @@ public class RecomputeService { return newIntersections; } - /** How many of a search's candidates get an extra detail-fetch to confirm via imdbId. */ - private static final int MAX_IMDB_CONFIRM_CANDIDATES = 3; - - private record KinopoiskResolution(KinopoiskClient.KinopoiskCandidate candidate, int extraRequests) { + private record KinopoiskResolution(KinopoiskClient.KinopoiskCandidate candidate, int requestsSpent, String via) { } /** - * Resolves a search-by-keyword candidate for `source`. We already have - * `source.getImdbId()` (captured from Radarr/Sonarr) and Kinopoisk's own - * film-details endpoint returns imdbId too - so the year-plausible - * candidates get one detail-fetch each to confirm by imdbId, a real - * external key, before ever falling back to fuzzy title matching. - * Title/year alone stays as the fallback for sources with no imdbId (or - * when the API doesn't have one for this title): Kinopoisk is a - * Russian-language catalog, so a source's (often English/original) title - * is checked against BOTH nameRu and nameEn - matching only nameRu, as an - * earlier version did, silently missed every title Kinopoisk hadn't - * localized (e.g. "The Legend of Korra" only ever compared against - * "Легенда о Корре"). + * Resolves `source`'s kinopoiskId in two independent steps (never a + * single request ANDing both signals together, which would demand both + * match at once - imdbId and title are tried as separate OR'd attempts): + * 1. If the source has an imdbId (captured from Radarr/Sonarr), search + * Kinopoisk BY that imdbId directly - a real shared external key + * beats any title comparison, so the first result is trusted as-is. + * 2. Only if there's no imdbId, or that search found nothing, fall back + * to the fuzzy searchByKeyword + title/year heuristic. Kinopoisk is a + * Russian-language catalog, so the source's (often English/original) + * title is checked against BOTH nameRu and nameEn - matching only + * nameRu, as an earlier version did, silently missed every title + * Kinopoisk hadn't localized (e.g. "The Legend of Korra" only ever + * compared against "Легенда о Корре"). */ - private KinopoiskResolution resolveKinopoiskCandidate( - List candidates, MediaEntity source) { - int extraRequests = 0; - if (source.getImdbId() != null && !source.getImdbId().isBlank()) { - List yearPlausibleFirst = new ArrayList<>(); - for (KinopoiskClient.KinopoiskCandidate c : candidates) { - if (yearMatches(c.year(), source.getYear())) yearPlausibleFirst.add(c); - } - for (KinopoiskClient.KinopoiskCandidate c : candidates) { - if (!yearMatches(c.year(), source.getYear())) yearPlausibleFirst.add(c); - } - for (KinopoiskClient.KinopoiskCandidate candidate : - yearPlausibleFirst.subList(0, Math.min(MAX_IMDB_CONFIRM_CANDIDATES, yearPlausibleFirst.size()))) { - extraRequests++; - KinopoiskClient.KinopoiskFilmDetails details = kinopoiskClient.getFilmDetails(candidate.kinopoiskId()); - if (details != null && details.imdbId() != null && source.getImdbId().equalsIgnoreCase(details.imdbId())) { - return new KinopoiskResolution(candidate, extraRequests); - } + private KinopoiskResolution resolveKinopoiskId(MediaEntity source) { + int requestsSpent = 0; + boolean hasImdbId = source.getImdbId() != null && !source.getImdbId().isBlank(); + if (hasImdbId) { + requestsSpent++; + List byImdb = kinopoiskClient.searchByImdbId(source.getImdbId()); + if (!byImdb.isEmpty()) { + return new KinopoiskResolution(byImdb.get(0), requestsSpent, "imdbId"); } } - for (KinopoiskClient.KinopoiskCandidate candidate : candidates) { + requestsSpent++; + List byKeyword = kinopoiskClient.searchByKeyword(source.getTitle()); + for (KinopoiskClient.KinopoiskCandidate candidate : byKeyword) { if (!yearMatches(candidate.year(), source.getYear())) continue; if (titleMatches(candidate.nameRu(), source.getTitle()) || titleMatches(candidate.nameEn(), source.getTitle())) { - return new KinopoiskResolution(candidate, extraRequests); + return new KinopoiskResolution(candidate, requestsSpent, + hasImdbId ? "title fallback (imdbId search found nothing)" : "title (no imdbId to search by)"); } } - return new KinopoiskResolution(null, extraRequests); + String via = hasImdbId + ? "imdbId search found nothing, " + byKeyword.size() + " title candidate(s) none matched" + : "no imdbId, " + byKeyword.size() + " title candidate(s) none matched"; + return new KinopoiskResolution(null, requestsSpent, via); } /**