Search Kinopoisk by imdbId directly instead of confirming via detail-fetch
Build and Deploy / build-and-deploy (push) Successful in 1m56s
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:
co-authored by
Claude Sonnet 5
parent
ce6dc533dc
commit
f7ebe91fc1
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user