feat: add recommendation system #53

Merged
skettiks merged 3 commits from feature/recommendation-model into develop 2026-05-22 09:01:35 +00:00
2 changed files with 35 additions and 19 deletions
Showing only changes of commit a615450896 - Show all commits
@@ -5,9 +5,9 @@ import com.project.movienight.adapters.web.dto.response.RecommendationResponse
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.RejectRecommendationCommand
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
import org.springframework.web.bind.annotation.GetMapping
@@ -36,20 +36,21 @@ class RecommendationController(
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
@RequestParam(required = false, defaultValue = "10") limit: Int,
): List<RecommendationResponse> =
getRecommendationsUseCase.recommend(
RecommendationQuery(
userId = userId,
contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() },
mood = mood,
libraryOnly = libraryOnly,
limit = limit,
),
).map { recommendation ->
RecommendationResponse.fromDomain(
recommendation = recommendation,
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
)
}
getRecommendationsUseCase
.recommend(
RecommendationQuery(
userId = userId,
contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() },
mood = mood,
libraryOnly = libraryOnly,
limit = limit,
),
).map { recommendation ->
RecommendationResponse.fromDomain(
recommendation = recommendation,
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
)
}
@PostMapping("/{filmId}/accept")
fun accept(
@@ -4,9 +4,9 @@ import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.RejectRecommendationCommand
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
@@ -179,7 +179,15 @@ class RecommendationService(
preferences?.eras.orEmpty().forEach { profile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) }
preferences?.castAndDirectors.orEmpty().forEach { profile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) }
preferences?.moods.orEmpty().forEach { profile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) }
preferences?.contentTypes.orEmpty().forEach { profile.add(feature("type", it.name), PREFERENCE_CONTENT_TYPE_WEIGHT) }
preferences
?.contentTypes
.orEmpty()
.forEach {
profile.add(
feature("type", it.name),
PREFERENCE_CONTENT_TYPE_WEIGHT,
)
}
ratings.forEach { rating ->
val film = filmsById[rating.filmId] ?: return@forEach
@@ -309,7 +317,13 @@ class RecommendationService(
film: Film,
preferences: UserPreferences?,
): Double {
val preferredGenres = preferences?.weightedGenres.orEmpty().keys.map(::normalize).toSet()
val preferredGenres =
preferences
?.weightedGenres
.orEmpty()
.keys
.map(::normalize)
.toSet()
val filmGenres = film.genres.map(::normalize).toSet()
return when {
preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE
@@ -423,7 +437,8 @@ class RecommendationService(
return dot / (leftNorm * rightNorm)
}
private fun roundScore(score: Double): Double = kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR
private fun roundScore(score: Double): Double =
kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR
private fun Iterable<Double>.averageOrNull(): Double? {
val values = toList()