Make watched titles full recompute sources, not just a scoring weight
Build and Deploy / build-and-deploy (push) Successful in 2m14s

The original design ("Rework source model: library membership drives
sources, watched is a weight only") meant marking something "Уже
смотрел" without owning it never seeded its own TMDB/Kinopoisk
discoveries at all - it only ever showed up as a candidate itself, or
boosted OTHER sources' weight if it happened to already be a source
via ownership. That contradicts the actual intent behind watched
tracking: it should build the recommendation graph, not just softly
exclude a title from Рекомендации. Sources are now IN_LIBRARY ∪
REMOVED ∪ every WatchSignal.watched=true title regardless of status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kayashov.SM
2026-09-17 23:50:57 +04:00
co-authored by Claude Sonnet 5
parent e2caeab70f
commit 2188458646
@@ -12,13 +12,17 @@ import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Core recommendation pipeline:
* 1. Sources = everything ever added to Radarr/Sonarr - still present
* (IN_LIBRARY) or since removed (REMOVED). Being watched never gates
* source membership, it only boosts a source's scoring weight.
* 1. Sources = everything ever added to Radarr/Sonarr (still present or
* since removed) UNION everything ever marked "Уже смотрел" - watching
* something is itself a signal worth building the recommendation graph
* from, not just a weight applied to sources that happen to already
* qualify some other way (a watched title that was never owned would
* otherwise never seed its own discoveries at all).
* 2. For each source, call TMDB recommendations + similar and record
* Intersection rows for whatever comes back.
* 3. Separately, Kinopoisk's own "similar films" list is consulted as a
@@ -44,6 +48,7 @@ public class RecomputeService {
private final MediaService mediaService;
private final MediaEntityRepository mediaRepository;
private final IntersectionRepository intersectionRepository;
private final WatchSignalRepository watchSignalRepository;
private final ScoringConfigService scoringConfigService;
private final IntegrationSettingService integrationSettingService;
@@ -66,6 +71,7 @@ public class RecomputeService {
KinopoiskClient kinopoiskClient, MediaService mediaService,
MediaEntityRepository mediaRepository,
IntersectionRepository intersectionRepository,
WatchSignalRepository watchSignalRepository,
ScoringConfigService scoringConfigService,
IntegrationSettingService integrationSettingService) {
this.radarrClient = radarrClient;
@@ -75,6 +81,7 @@ public class RecomputeService {
this.mediaService = mediaService;
this.mediaRepository = mediaRepository;
this.intersectionRepository = intersectionRepository;
this.watchSignalRepository = watchSignalRepository;
this.scoringConfigService = scoringConfigService;
this.integrationSettingService = integrationSettingService;
}
@@ -94,12 +101,17 @@ public class RecomputeService {
private void doRecompute() {
syncLibraries();
// Sources = everything ever added to Radarr/Sonarr, whether still
// present (IN_LIBRARY) or since removed (REMOVED) - being watched is
// a scoring weight boost only (see ScoringService), never a
// membership requirement.
// Sources = everything ever added to Radarr/Sonarr (still present or
// since removed) UNION everything ever marked watched, regardless of
// status - see the class Javadoc for why watched-but-never-owned
// titles need to be full sources, not just a weight on other sources.
Set<MediaEntity> sources = new LinkedHashSet<>(
mediaRepository.findByStatusIn(List.of(MediaStatus.IN_LIBRARY, MediaStatus.REMOVED)));
Set<Long> watchedIds = watchSignalRepository.findByWatchedTrue().stream()
.map(WatchSignal::getMediaEntityId).collect(Collectors.toSet());
if (!watchedIds.isEmpty()) {
sources.addAll(mediaRepository.findAllById(watchedIds));
}
List<MediaEntity> kinopoiskCandidates = computeKinopoiskCandidates(sources);
// Both phases count towards the same progress bar - computing the
// Kinopoisk candidate list upfront (instead of only once its phase