Search Kinopoisk by imdbId directly instead of confirming via detail-fetch
Build and Deploy / build-and-deploy (push) Successful in 1m56s

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 <noreply@anthropic.com>
This commit is contained in:
Kayashov.SM
2026-09-16 00:56:56 +04:00
co-authored by Claude Sonnet 5
parent ce6dc533dc
commit f7ebe91fc1
2 changed files with 92 additions and 94 deletions
@@ -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<KinopoiskCandidate> searchByImdbId(String imdbId) {
IntegrationSetting setting = settingService.getOrCreate(IntegrationType.KINOPOISK);
if (!setting.isEnabled() || setting.getApiKey() == null) {
return List.of();
}
List<KinopoiskCandidate> 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<KinopoiskCandidate> 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 {
@@ -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<KinopoiskClient.KinopoiskCandidate> 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<KinopoiskClient.KinopoiskCandidate> candidates, MediaEntity source) {
int extraRequests = 0;
if (source.getImdbId() != null && !source.getImdbId().isBlank()) {
List<KinopoiskClient.KinopoiskCandidate> 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<KinopoiskClient.KinopoiskCandidate> byImdb = kinopoiskClient.searchByImdbId(source.getImdbId());
if (!byImdb.isEmpty()) {
return new KinopoiskResolution(byImdb.get(0), requestsSpent, "imdbId");
}
}
for (KinopoiskClient.KinopoiskCandidate candidate : candidates) {
requestsSpent++;
List<KinopoiskClient.KinopoiskCandidate> 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);
}
/**