Реализована рекомендательная система: добавлены гибридный скоринг фильмов, события рекомендаций, accept/reject endpoints, watchUrl для Jellyfin, API DTO ответа и логирование выдачи рекомендаций.
This commit is contained in:
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.RecommendationEvent
|
||||||
|
import com.project.movienight.domain.model.RecommendationEventType
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class RecommendationEventRepository(
|
||||||
|
private val jdbc: JdbcTemplate,
|
||||||
|
) : RecommendationEventRepositoryPort {
|
||||||
|
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||||
|
RecommendationEvent(
|
||||||
|
id = UUID.fromString(rs.getString("id")),
|
||||||
|
userId = UUID.fromString(rs.getString("user_id")),
|
||||||
|
filmId = UUID.fromString(rs.getString("film_id")),
|
||||||
|
eventType = RecommendationEventType.valueOf(rs.getString("event_type")),
|
||||||
|
score = rs.getObject("score")?.let { (it as Number).toDouble() },
|
||||||
|
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(event: RecommendationEvent): RecommendationEvent {
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
INSERT INTO recommendation_events (
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
event_type,
|
||||||
|
score,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
event.id,
|
||||||
|
event.userId,
|
||||||
|
event.filmId,
|
||||||
|
event.eventType.name,
|
||||||
|
event.score,
|
||||||
|
event.createdAt,
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByUserId(userId: UUID): List<RecommendationEvent> =
|
||||||
|
jdbc.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
event_type,
|
||||||
|
score,
|
||||||
|
created_at
|
||||||
|
FROM recommendation_events
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,34 +1,91 @@
|
|||||||
package com.project.movienight.adapters.web
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.response.RecommendationEventResponse
|
||||||
|
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.GetRecommendationsUseCase
|
||||||
|
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.input.RecommendationQuery
|
||||||
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
import com.project.movienight.domain.model.ContentType
|
import com.project.movienight.domain.model.ContentType
|
||||||
import com.project.movienight.domain.model.RecommendationResult
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/users/{userId}/recommendations")
|
@RequestMapping("/api/users/{userId}/recommendations")
|
||||||
class RecommendationController(
|
class RecommendationController(
|
||||||
private val getRecommendationsUseCase: GetRecommendationsUseCase,
|
private val getRecommendationsUseCase: GetRecommendationsUseCase,
|
||||||
|
private val acceptRecommendationUseCase: AcceptRecommendationUseCase,
|
||||||
|
private val rejectRecommendationUseCase: RejectRecommendationUseCase,
|
||||||
|
private val jellyfinProperties: JellyfinIntegrationProperties,
|
||||||
) {
|
) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun recommend(
|
fun recommend(
|
||||||
@PathVariable userId: UUID,
|
@PathVariable userId: UUID,
|
||||||
@RequestParam(required = false) contentType: String?,
|
@RequestParam(required = false) contentType: String?,
|
||||||
@RequestParam(required = false) mood: String?,
|
@RequestParam(required = false) mood: String?,
|
||||||
|
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
|
||||||
@RequestParam(required = false, defaultValue = "10") limit: Int,
|
@RequestParam(required = false, defaultValue = "10") limit: Int,
|
||||||
): List<RecommendationResult> =
|
): List<RecommendationResponse> =
|
||||||
getRecommendationsUseCase.recommend(
|
getRecommendationsUseCase.recommend(
|
||||||
RecommendationQuery(
|
RecommendationQuery(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() },
|
contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() },
|
||||||
mood = mood,
|
mood = mood,
|
||||||
|
libraryOnly = libraryOnly,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
),
|
),
|
||||||
|
).map { recommendation ->
|
||||||
|
RecommendationResponse.fromDomain(
|
||||||
|
recommendation = recommendation,
|
||||||
|
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{filmId}/accept")
|
||||||
|
fun accept(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@PathVariable filmId: UUID,
|
||||||
|
): RecommendationEventResponse =
|
||||||
|
RecommendationEventResponse.fromDomain(
|
||||||
|
acceptRecommendationUseCase.accept(
|
||||||
|
AcceptRecommendationCommand(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@PostMapping("/{filmId}/reject")
|
||||||
|
fun reject(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
@PathVariable filmId: UUID,
|
||||||
|
): RecommendationEventResponse =
|
||||||
|
RecommendationEventResponse.fromDomain(
|
||||||
|
rejectRecommendationUseCase.reject(
|
||||||
|
RejectRecommendationCommand(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun buildWatchUrl(jellyfinItemId: String?): String? {
|
||||||
|
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
|
||||||
|
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
|
||||||
|
return "$baseUrl/web/#/details?id=$encodedItemId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.response
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.RecommendationEvent
|
||||||
|
import com.project.movienight.domain.model.RecommendationEventType
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class RecommendationEventResponse(
|
||||||
|
val id: UUID,
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
val eventType: RecommendationEventType,
|
||||||
|
val score: Double?,
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromDomain(event: RecommendationEvent): RecommendationEventResponse =
|
||||||
|
RecommendationEventResponse(
|
||||||
|
id = event.id,
|
||||||
|
userId = event.userId,
|
||||||
|
filmId = event.filmId,
|
||||||
|
eventType = event.eventType,
|
||||||
|
score = event.score,
|
||||||
|
createdAt = event.createdAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.response
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.RecommendationResult
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class RecommendationResponse(
|
||||||
|
val filmId: UUID,
|
||||||
|
val title: String,
|
||||||
|
val score: Double,
|
||||||
|
val reasons: List<String>,
|
||||||
|
val jellyfinItemId: String?,
|
||||||
|
val watchUrl: String?,
|
||||||
|
val film: FilmResponse,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromDomain(
|
||||||
|
recommendation: RecommendationResult,
|
||||||
|
watchUrl: String?,
|
||||||
|
): RecommendationResponse {
|
||||||
|
val film = recommendation.film
|
||||||
|
return RecommendationResponse(
|
||||||
|
filmId = film.id,
|
||||||
|
title = film.title,
|
||||||
|
score = recommendation.score,
|
||||||
|
reasons = recommendation.reasons,
|
||||||
|
jellyfinItemId = film.jellyfinItemId,
|
||||||
|
watchUrl = watchUrl,
|
||||||
|
film = FilmResponse.fromDomain(film),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -1,6 +1,7 @@
|
|||||||
package com.project.movienight.application.ports.input
|
package com.project.movienight.application.ports.input
|
||||||
|
|
||||||
import com.project.movienight.domain.model.ContentType
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import com.project.movienight.domain.model.RecommendationEvent
|
||||||
import com.project.movienight.domain.model.RecommendationResult
|
import com.project.movienight.domain.model.RecommendationResult
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -12,5 +13,24 @@ data class RecommendationQuery(
|
|||||||
val userId: UUID,
|
val userId: UUID,
|
||||||
val contentType: ContentType? = null,
|
val contentType: ContentType? = null,
|
||||||
val mood: String? = null,
|
val mood: String? = null,
|
||||||
|
val libraryOnly: Boolean = false,
|
||||||
val limit: Int = 10,
|
val limit: Int = 10,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface AcceptRecommendationUseCase {
|
||||||
|
fun accept(command: AcceptRecommendationCommand): RecommendationEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AcceptRecommendationCommand(
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface RejectRecommendationUseCase {
|
||||||
|
fun reject(command: RejectRecommendationCommand): RecommendationEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RejectRecommendationCommand(
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
)
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.project.movienight.application.ports.output
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.RecommendationEvent
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface RecommendationEventRepositoryPort {
|
||||||
|
fun save(event: RecommendationEvent): RecommendationEvent
|
||||||
|
|
||||||
|
fun findByUserId(userId: UUID): List<RecommendationEvent>
|
||||||
|
}
|
||||||
+482
-73
@@ -1,16 +1,33 @@
|
|||||||
package com.project.movienight.application.services
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
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.GetRecommendationsUseCase
|
||||||
|
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.input.RecommendationQuery
|
||||||
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||||
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.IdGenerator
|
||||||
|
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
|
||||||
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||||
import com.project.movienight.domain.model.ContentType
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
import com.project.movienight.domain.model.Film
|
import com.project.movienight.domain.model.Film
|
||||||
|
import com.project.movienight.domain.model.FilmLibrary
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import com.project.movienight.domain.model.RecommendationEvent
|
||||||
|
import com.project.movienight.domain.model.RecommendationEventType
|
||||||
import com.project.movienight.domain.model.RecommendationResult
|
import com.project.movienight.domain.model.RecommendationResult
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class RecommendationService(
|
class RecommendationService(
|
||||||
@@ -18,100 +35,492 @@ class RecommendationService(
|
|||||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||||
private val filmRatingRepository: FilmRatingRepositoryPort,
|
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||||
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
private val recommendationEventRepository: RecommendationEventRepositoryPort,
|
||||||
|
private val idGenerator: IdGenerator,
|
||||||
private val businessMetricsService: BusinessMetricsService,
|
private val businessMetricsService: BusinessMetricsService,
|
||||||
) : GetRecommendationsUseCase {
|
) : GetRecommendationsUseCase,
|
||||||
|
AcceptRecommendationUseCase,
|
||||||
|
RejectRecommendationUseCase {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
override fun recommend(query: RecommendationQuery): List<RecommendationResult> {
|
override fun recommend(query: RecommendationQuery): List<RecommendationResult> {
|
||||||
businessMetricsService.recordRecommendationRequest()
|
businessMetricsService.recordRecommendationRequest()
|
||||||
val preferences = userPreferencesRepository.findByUserId(query.userId)
|
userRepository.findById(query.userId)
|
||||||
val ratings = filmRatingRepository.findByUserId(query.userId).associateBy { it.filmId }
|
?: throw EntityNotFoundException(entity = "User", id = query.userId.toString())
|
||||||
val watchedFilmIds =
|
|
||||||
filmLibraryRepository
|
|
||||||
.findAll()
|
|
||||||
.filter {
|
|
||||||
it.userId == query.userId && it.isViewed
|
|
||||||
}.map { it.filmId }
|
|
||||||
.toSet()
|
|
||||||
|
|
||||||
return filmRepository
|
val preferences = userPreferencesRepository.findByUserId(query.userId)
|
||||||
.findAll()
|
val ratings = filmRatingRepository.findByUserId(query.userId)
|
||||||
|
val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId }
|
||||||
|
val libraryFilmIds = libraryEntries.map { it.filmId }.toSet()
|
||||||
|
val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet()
|
||||||
|
val films = filmRepository.findAll()
|
||||||
|
val filmsById = films.associateBy { it.id }
|
||||||
|
val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById)
|
||||||
|
|
||||||
|
val candidates =
|
||||||
|
films
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.filter { film -> query.contentType == null || film.contentType == query.contentType }
|
.filter { film -> query.contentType == null || film.contentType == query.contentType }
|
||||||
.map { film ->
|
.filter { film -> film.id !in watchedFilmIds }
|
||||||
scoreFilm(film, query.mood, preferences, ratings[film.id] != null, watchedFilmIds.contains(film.id))
|
.filter { film -> !query.libraryOnly || film.id in libraryFilmIds }
|
||||||
}.sortedByDescending { it.score }
|
.toList()
|
||||||
|
val recommendations =
|
||||||
|
candidates
|
||||||
|
.asSequence()
|
||||||
|
.map { film -> scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds) }
|
||||||
|
.sortedWith(compareByDescending<RecommendationResult> { it.score }.thenBy { it.film.title })
|
||||||
.take(query.limit.coerceAtLeast(1))
|
.take(query.limit.coerceAtLeast(1))
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
|
recommendations.forEach { recommendation ->
|
||||||
|
saveEvent(
|
||||||
|
userId = query.userId,
|
||||||
|
filmId = recommendation.film.id,
|
||||||
|
eventType = RecommendationEventType.RECOMMENDED,
|
||||||
|
score = recommendation.score,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
RECOMMENDATION_COMPLETED_LOG,
|
||||||
|
query.userId,
|
||||||
|
query.contentType,
|
||||||
|
!query.mood.isNullOrBlank(),
|
||||||
|
query.libraryOnly,
|
||||||
|
query.limit,
|
||||||
|
candidates.size,
|
||||||
|
recommendations.size,
|
||||||
|
)
|
||||||
|
if (log.isDebugEnabled) {
|
||||||
|
log.debug(
|
||||||
|
"Recommendation top results: userId='{}', results='{}'",
|
||||||
|
query.userId,
|
||||||
|
recommendations.joinToString(separator = ",") { "${it.film.id}:${it.score}" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun accept(command: AcceptRecommendationCommand): RecommendationEvent =
|
||||||
|
saveFeedbackEvent(
|
||||||
|
userId = command.userId,
|
||||||
|
filmId = command.filmId,
|
||||||
|
eventType = RecommendationEventType.ACCEPTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun reject(command: RejectRecommendationCommand): RecommendationEvent =
|
||||||
|
saveFeedbackEvent(
|
||||||
|
userId = command.userId,
|
||||||
|
filmId = command.filmId,
|
||||||
|
eventType = RecommendationEventType.REJECTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun saveFeedbackEvent(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
eventType: RecommendationEventType,
|
||||||
|
): RecommendationEvent {
|
||||||
|
userRepository.findById(userId)
|
||||||
|
?: throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||||
|
filmRepository.findById(filmId)
|
||||||
|
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
|
||||||
|
|
||||||
|
val event =
|
||||||
|
saveEvent(
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
eventType = eventType,
|
||||||
|
score = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
RECOMMENDATION_FEEDBACK_SAVED_LOG,
|
||||||
|
userId,
|
||||||
|
filmId,
|
||||||
|
eventType,
|
||||||
|
)
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveEvent(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
eventType: RecommendationEventType,
|
||||||
|
score: Double?,
|
||||||
|
): RecommendationEvent =
|
||||||
|
recommendationEventRepository.save(
|
||||||
|
RecommendationEvent(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
eventType = eventType,
|
||||||
|
score = score,
|
||||||
|
createdAt = LocalDateTime.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun buildUserProfile(
|
||||||
|
preferences: UserPreferences?,
|
||||||
|
ratings: List<FilmRating>,
|
||||||
|
libraryEntries: List<FilmLibrary>,
|
||||||
|
filmsById: Map<UUID, Film>,
|
||||||
|
): SparseVector {
|
||||||
|
val profile = MutableSparseVector()
|
||||||
|
|
||||||
|
preferences?.weightedGenres.orEmpty().forEach { (genre, weight) ->
|
||||||
|
profile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT)
|
||||||
|
}
|
||||||
|
preferences?.plotTypes.orEmpty().forEach { plotType ->
|
||||||
|
tokenize(plotType).forEach { profile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) }
|
||||||
|
}
|
||||||
|
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) }
|
||||||
|
|
||||||
|
ratings.forEach { rating ->
|
||||||
|
val film = filmsById[rating.filmId] ?: return@forEach
|
||||||
|
val signal = ratingSignal(rating.score)
|
||||||
|
profile.add(buildFilmVector(film).scale(signal))
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryEntries.filterNot { it.isViewed }.forEach { entry ->
|
||||||
|
val film = filmsById[entry.filmId] ?: return@forEach
|
||||||
|
profile.add(buildFilmVector(film).scale(LIBRARY_SIGNAL_WEIGHT))
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile.toSparseVector()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scoreFilm(
|
private fun scoreFilm(
|
||||||
film: Film,
|
film: Film,
|
||||||
mood: String?,
|
query: RecommendationQuery,
|
||||||
preferences: com.project.movienight.domain.model.UserPreferences?,
|
preferences: UserPreferences?,
|
||||||
hasUserRating: Boolean,
|
userProfile: SparseVector,
|
||||||
watched: Boolean,
|
inLibrary: Boolean,
|
||||||
): RecommendationResult {
|
): RecommendationResult {
|
||||||
var score = 0.0
|
|
||||||
val reasons = mutableListOf<String>()
|
val reasons = mutableListOf<String>()
|
||||||
|
val filmVector = buildFilmVector(film)
|
||||||
|
val preferenceScore = cosineSimilarity(userProfile, filmVector)
|
||||||
|
val qualityScore = qualityScore(film)
|
||||||
|
val contextScore = contextScore(film, query, preferences)
|
||||||
|
val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE
|
||||||
|
val diversityScore = diversityScore(film, preferences)
|
||||||
|
val score =
|
||||||
|
RELEVANCE_WEIGHT * preferenceScore +
|
||||||
|
QUALITY_WEIGHT * qualityScore +
|
||||||
|
CONTEXT_WEIGHT * contextScore +
|
||||||
|
NOVELTY_WEIGHT * noveltyScore +
|
||||||
|
DIVERSITY_WEIGHT * diversityScore
|
||||||
|
|
||||||
preferences?.contentTypes?.let {
|
if (preferenceScore > STRONG_REASON_THRESHOLD) {
|
||||||
if (it.isEmpty() || it.contains(film.contentType)) {
|
reasons += "Similar to user preferences and rating history"
|
||||||
score += 2.0
|
}
|
||||||
reasons += "Matches content preference"
|
matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre ->
|
||||||
|
reasons += "Matches preferred genre: $genre"
|
||||||
|
}
|
||||||
|
matchingPeople(film, preferences).take(MAX_REASON_ITEMS).forEach { person ->
|
||||||
|
reasons += "Matches preferred cast or director: $person"
|
||||||
|
}
|
||||||
|
query.mood?.takeIf { inferredMoods(film).contains(normalize(it)) }?.let { mood ->
|
||||||
|
reasons += "Matches requested mood: $mood"
|
||||||
|
}
|
||||||
|
film.releaseYear?.let { year ->
|
||||||
|
if (preferences?.eras.orEmpty().any { normalize(it) == normalize(decadeOf(year)) }) {
|
||||||
|
reasons += "Matches preferred era: ${decadeOf(year)}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (qualityScore >= QUALITY_REASON_THRESHOLD) {
|
||||||
preferences?.weightedGenres?.forEach { (genre, weight) ->
|
reasons += "High rating signal"
|
||||||
if (film.genres.any { it.equals(genre, ignoreCase = true) }) {
|
|
||||||
score += weight
|
|
||||||
reasons += "Matches genre $genre"
|
|
||||||
}
|
}
|
||||||
}
|
if (inLibrary) {
|
||||||
|
reasons += "Already in user library"
|
||||||
preferences?.castAndDirectors?.forEach { favorite ->
|
|
||||||
val found =
|
|
||||||
film.cast.any { it.equals(favorite, ignoreCase = true) } ||
|
|
||||||
film.directors.any { it.equals(favorite, ignoreCase = true) }
|
|
||||||
if (found) {
|
|
||||||
score += 1.5
|
|
||||||
reasons += "Matches favorite creator or cast member $favorite"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
preferences?.moods?.forEach { preferredMood ->
|
|
||||||
if (mood != null && preferredMood.equals(mood, ignoreCase = true)) {
|
|
||||||
score += 1.25
|
|
||||||
reasons += "Matches requested mood $mood"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
film.imdbRating?.let {
|
|
||||||
score += it / 2.0
|
|
||||||
reasons += "Strong IMDb signal"
|
|
||||||
}
|
|
||||||
|
|
||||||
film.platformRating?.let {
|
|
||||||
score += it
|
|
||||||
reasons += "Strong platform signal"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasUserRating) {
|
|
||||||
score += 2.0
|
|
||||||
reasons += "User has already rated similar content"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (watched) {
|
|
||||||
score -= 3.0
|
|
||||||
reasons += "Already watched"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mood != null && film.title.contains(mood, ignoreCase = true)) {
|
|
||||||
score += 0.5
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reasons.isEmpty()) {
|
if (reasons.isEmpty()) {
|
||||||
reasons += "Baseline recommendation from library catalog"
|
reasons += "Baseline recommendation from catalog quality"
|
||||||
}
|
}
|
||||||
|
|
||||||
return RecommendationResult(film = film, score = score, reasons = reasons)
|
return RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildFilmVector(film: Film): SparseVector {
|
||||||
|
val vector = MutableSparseVector()
|
||||||
|
val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() }
|
||||||
|
val plotTokens = tokenize("${film.title} ${film.description}")
|
||||||
|
val moods = inferredMoods(film)
|
||||||
|
val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() }
|
||||||
|
|
||||||
|
vector.add(feature("type", film.contentType.name), CONTENT_TYPE_VECTOR_WEIGHT)
|
||||||
|
distribute(vector, "genre", normalizedGenres, GENRE_VECTOR_WEIGHT)
|
||||||
|
distribute(vector, "plot", plotTokens, PLOT_VECTOR_WEIGHT)
|
||||||
|
distribute(vector, "mood", moods, MOOD_VECTOR_WEIGHT)
|
||||||
|
film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), ERA_VECTOR_WEIGHT) }
|
||||||
|
distribute(vector, "person", people, PEOPLE_VECTOR_WEIGHT)
|
||||||
|
|
||||||
|
return vector.toSparseVector()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contextScore(
|
||||||
|
film: Film,
|
||||||
|
query: RecommendationQuery,
|
||||||
|
preferences: UserPreferences?,
|
||||||
|
): Double {
|
||||||
|
var score = 0.0
|
||||||
|
var checks = 0
|
||||||
|
|
||||||
|
query.mood?.let {
|
||||||
|
checks += 1
|
||||||
|
if (inferredMoods(film).contains(normalize(it))) {
|
||||||
|
score += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preferences?.contentTypes?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
checks += 1
|
||||||
|
if (film.contentType in it) {
|
||||||
|
score += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preferences?.eras?.takeIf { it.isNotEmpty() }?.let { eras ->
|
||||||
|
film.releaseYear?.let {
|
||||||
|
checks += 1
|
||||||
|
if (eras.any { era -> normalize(era) == normalize(decadeOf(it)) }) {
|
||||||
|
score += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (checks == 0) BASE_CONTEXT_SCORE else score / checks
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun qualityScore(film: Film): Double {
|
||||||
|
val normalizedRatings =
|
||||||
|
listOfNotNull(
|
||||||
|
film.imdbRating?.let { normalizeRating(it) },
|
||||||
|
film.platformRating?.let { normalizeRating(it) },
|
||||||
|
)
|
||||||
|
return normalizedRatings.averageOrNull() ?: BASE_QUALITY_SCORE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun diversityScore(
|
||||||
|
film: Film,
|
||||||
|
preferences: UserPreferences?,
|
||||||
|
): Double {
|
||||||
|
val preferredGenres = preferences?.weightedGenres.orEmpty().keys.map(::normalize).toSet()
|
||||||
|
val filmGenres = film.genres.map(::normalize).toSet()
|
||||||
|
return when {
|
||||||
|
preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE
|
||||||
|
filmGenres.none { it in preferredGenres } -> HIGH_DIVERSITY_SCORE
|
||||||
|
filmGenres.size > 1 -> MEDIUM_DIVERSITY_SCORE
|
||||||
|
else -> LOW_DIVERSITY_SCORE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inferredMoods(film: Film): Set<String> {
|
||||||
|
val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}")
|
||||||
|
return moodLexicon
|
||||||
|
.filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } }
|
||||||
|
.keys
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchingGenres(
|
||||||
|
film: Film,
|
||||||
|
preferences: UserPreferences?,
|
||||||
|
): List<String> {
|
||||||
|
val filmGenres = film.genres.associateBy { normalize(it) }
|
||||||
|
return preferences
|
||||||
|
?.weightedGenres
|
||||||
|
.orEmpty()
|
||||||
|
.keys
|
||||||
|
.map(::normalize)
|
||||||
|
.mapNotNull { filmGenres[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchingPeople(
|
||||||
|
film: Film,
|
||||||
|
preferences: UserPreferences?,
|
||||||
|
): List<String> {
|
||||||
|
val people = (film.cast + film.directors).associateBy { normalize(it) }
|
||||||
|
return preferences
|
||||||
|
?.castAndDirectors
|
||||||
|
.orEmpty()
|
||||||
|
.map(::normalize)
|
||||||
|
.mapNotNull { people[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun distribute(
|
||||||
|
vector: MutableSparseVector,
|
||||||
|
namespace: String,
|
||||||
|
values: Collection<String>,
|
||||||
|
totalWeight: Double,
|
||||||
|
) {
|
||||||
|
val uniqueValues = values.map(::normalize).filter { it.isNotBlank() }.distinct()
|
||||||
|
if (uniqueValues.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val itemWeight = totalWeight / uniqueValues.size
|
||||||
|
uniqueValues.forEach { vector.add(feature(namespace, it), itemWeight) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ratingSignal(score: Int): Double =
|
||||||
|
when (score.coerceIn(MIN_USER_RATING, MAX_USER_RATING)) {
|
||||||
|
10 -> 1.0
|
||||||
|
9 -> 0.9
|
||||||
|
8 -> 0.7
|
||||||
|
7 -> 0.4
|
||||||
|
6 -> 0.1
|
||||||
|
5 -> 0.0
|
||||||
|
4 -> -0.3
|
||||||
|
3 -> -0.5
|
||||||
|
2 -> -0.8
|
||||||
|
else -> -1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeRating(rating: Double): Double = (rating / MAX_RATING_VALUE).coerceIn(0.0, 1.0)
|
||||||
|
|
||||||
|
private fun decadeOf(year: Int): String = "${year / 10 * 10}s"
|
||||||
|
|
||||||
|
private fun tokenize(text: String): List<String> =
|
||||||
|
normalize(text)
|
||||||
|
.split(tokenSeparatorRegex)
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.length >= MIN_TOKEN_LENGTH }
|
||||||
|
.filterNot { it in stopWords }
|
||||||
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
private fun feature(
|
||||||
|
namespace: String,
|
||||||
|
value: String,
|
||||||
|
): String = "$namespace:${normalize(value)}"
|
||||||
|
|
||||||
|
private fun normalize(value: String): String =
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.lowercase(Locale.getDefault())
|
||||||
|
|
||||||
|
private fun cosineSimilarity(
|
||||||
|
left: SparseVector,
|
||||||
|
right: SparseVector,
|
||||||
|
): Double {
|
||||||
|
if (left.values.isEmpty() || right.values.isEmpty()) {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
val dot =
|
||||||
|
left.values
|
||||||
|
.entries
|
||||||
|
.sumOf { (feature, weight) -> weight * (right.values[feature] ?: 0.0) }
|
||||||
|
val leftNorm = sqrt(left.values.values.sumOf { it * it })
|
||||||
|
val rightNorm = sqrt(right.values.values.sumOf { it * it })
|
||||||
|
if (leftNorm == 0.0 || rightNorm == 0.0) {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return dot / (leftNorm * rightNorm)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
return values.takeIf { it.isNotEmpty() }?.average()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class SparseVector(
|
||||||
|
val values: Map<String, Double>,
|
||||||
|
) {
|
||||||
|
fun scale(weight: Double): SparseVector = SparseVector(values.mapValues { it.value * weight })
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MutableSparseVector {
|
||||||
|
private val values = mutableMapOf<String, Double>()
|
||||||
|
|
||||||
|
fun add(
|
||||||
|
feature: String,
|
||||||
|
weight: Double,
|
||||||
|
) {
|
||||||
|
if (weight == 0.0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values[feature] = (values[feature] ?: 0.0) + weight
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(vector: SparseVector) {
|
||||||
|
vector.values.forEach { (feature, weight) -> add(feature, weight) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toSparseVector(): SparseVector = SparseVector(values.filterValues { it != 0.0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val RECOMMENDATION_COMPLETED_LOG =
|
||||||
|
"Recommendation request completed: userId='{}', contentType='{}', moodPresent={}, " +
|
||||||
|
"libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}"
|
||||||
|
private const val RECOMMENDATION_FEEDBACK_SAVED_LOG =
|
||||||
|
"Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'"
|
||||||
|
|
||||||
|
private const val MAX_PREFERENCE_WEIGHT = 5.0
|
||||||
|
private const val MAX_RATING_VALUE = 10.0
|
||||||
|
private const val MIN_USER_RATING = 1
|
||||||
|
private const val MAX_USER_RATING = 10
|
||||||
|
private const val MIN_TOKEN_LENGTH = 3
|
||||||
|
private const val MAX_REASON_ITEMS = 2
|
||||||
|
private const val SCORE_ROUNDING_FACTOR = 1000.0
|
||||||
|
|
||||||
|
private const val CONTENT_TYPE_VECTOR_WEIGHT = 0.05
|
||||||
|
private const val GENRE_VECTOR_WEIGHT = 0.25
|
||||||
|
private const val PLOT_VECTOR_WEIGHT = 0.35
|
||||||
|
private const val MOOD_VECTOR_WEIGHT = 0.15
|
||||||
|
private const val ERA_VECTOR_WEIGHT = 0.10
|
||||||
|
private const val PEOPLE_VECTOR_WEIGHT = 0.10
|
||||||
|
|
||||||
|
private const val PREFERENCE_PLOT_WEIGHT = 0.6
|
||||||
|
private const val PREFERENCE_ERA_WEIGHT = 0.7
|
||||||
|
private const val PREFERENCE_PERSON_WEIGHT = 0.8
|
||||||
|
private const val PREFERENCE_MOOD_WEIGHT = 0.8
|
||||||
|
private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5
|
||||||
|
private const val LIBRARY_SIGNAL_WEIGHT = 0.25
|
||||||
|
|
||||||
|
private const val RELEVANCE_WEIGHT = 0.55
|
||||||
|
private const val QUALITY_WEIGHT = 0.15
|
||||||
|
private const val CONTEXT_WEIGHT = 0.10
|
||||||
|
private const val NOVELTY_WEIGHT = 0.10
|
||||||
|
private const val DIVERSITY_WEIGHT = 0.10
|
||||||
|
|
||||||
|
private const val LIBRARY_NOVELTY_SCORE = 0.85
|
||||||
|
private const val CATALOG_NOVELTY_SCORE = 0.65
|
||||||
|
private const val BASE_CONTEXT_SCORE = 0.5
|
||||||
|
private const val BASE_QUALITY_SCORE = 0.5
|
||||||
|
private const val BASE_DIVERSITY_SCORE = 0.5
|
||||||
|
private const val HIGH_DIVERSITY_SCORE = 1.0
|
||||||
|
private const val MEDIUM_DIVERSITY_SCORE = 0.6
|
||||||
|
private const val LOW_DIVERSITY_SCORE = 0.3
|
||||||
|
private const val STRONG_REASON_THRESHOLD = 0.15
|
||||||
|
private const val QUALITY_REASON_THRESHOLD = 0.75
|
||||||
|
|
||||||
|
private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+")
|
||||||
|
private val stopWords =
|
||||||
|
setOf(
|
||||||
|
"and",
|
||||||
|
"the",
|
||||||
|
"for",
|
||||||
|
"with",
|
||||||
|
"about",
|
||||||
|
"into",
|
||||||
|
"from",
|
||||||
|
)
|
||||||
|
private val moodLexicon =
|
||||||
|
mapOf(
|
||||||
|
"tense" to listOf("thriller", "suspense", "tension", "rescue", "crime"),
|
||||||
|
"slow-burn" to listOf("slow", "meditative", "grounded"),
|
||||||
|
"feel-good" to listOf("comedy", "family", "summer", "kind", "warm"),
|
||||||
|
"dark" to listOf("dark", "noir", "horror", "murder", "crime"),
|
||||||
|
"romantic" to listOf("romance", "love", "relationship"),
|
||||||
|
"focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties
|
|||||||
data class JellyfinIntegrationProperties(
|
data class JellyfinIntegrationProperties(
|
||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
val baseUrl: String = "",
|
val baseUrl: String = "",
|
||||||
|
val webUrl: String = "",
|
||||||
val apiKey: String = "",
|
val apiKey: String = "",
|
||||||
val syncIntervalMs: Long = 1_800_000,
|
val syncIntervalMs: Long = 1_800_000,
|
||||||
val requestTimeoutMs: Long = 20_000,
|
val requestTimeoutMs: Long = 20_000,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ data class RecommendationContext(
|
|||||||
val userId: UUID,
|
val userId: UUID,
|
||||||
val contentType: ContentType? = null,
|
val contentType: ContentType? = null,
|
||||||
val mood: String? = null,
|
val mood: String? = null,
|
||||||
|
val libraryOnly: Boolean = false,
|
||||||
val limit: Int = 10,
|
val limit: Int = 10,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.project.movienight.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class RecommendationEvent(
|
||||||
|
val id: UUID,
|
||||||
|
val userId: UUID,
|
||||||
|
val filmId: UUID,
|
||||||
|
val eventType: RecommendationEventType,
|
||||||
|
val score: Double? = null,
|
||||||
|
val createdAt: LocalDateTime = LocalDateTime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class RecommendationEventType {
|
||||||
|
RECOMMENDED,
|
||||||
|
ACCEPTED,
|
||||||
|
REJECTED,
|
||||||
|
}
|
||||||
@@ -99,6 +99,7 @@ integrations:
|
|||||||
jellyfin:
|
jellyfin:
|
||||||
enabled: ${JELLYFIN_SYNC_ENABLED:false}
|
enabled: ${JELLYFIN_SYNC_ENABLED:false}
|
||||||
base-url: ${JELLYFIN_BASE_URL:}
|
base-url: ${JELLYFIN_BASE_URL:}
|
||||||
|
web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}}
|
||||||
api-key: ${JELLYFIN_API_KEY:}
|
api-key: ${JELLYFIN_API_KEY:}
|
||||||
sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000}
|
sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000}
|
||||||
request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000}
|
request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS public.recommendation_events (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
film_id UUID NOT NULL,
|
||||||
|
event_type VARCHAR(64) NOT NULL,
|
||||||
|
score DOUBLE PRECISION,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT recommendation_events_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT recommendation_events_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recommendation_events_user_created
|
||||||
|
ON public.recommendation_events(user_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recommendation_events_film
|
||||||
|
ON public.recommendation_events(film_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recommendation_events_type
|
||||||
|
ON public.recommendation_events(event_type);
|
||||||
@@ -76,6 +76,7 @@ class RecommendationSmokeTest {
|
|||||||
imdbRating = 8.7,
|
imdbRating = 8.7,
|
||||||
platformRating = 9.0,
|
platformRating = 9.0,
|
||||||
externalUrl = "https://example.com/orbital-drift",
|
externalUrl = "https://example.com/orbital-drift",
|
||||||
|
jellyfinItemId = "orbital-drift-item",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}.andExpect {
|
}.andExpect {
|
||||||
@@ -154,11 +155,43 @@ class RecommendationSmokeTest {
|
|||||||
param("limit", "2")
|
param("limit", "2")
|
||||||
}.andExpect {
|
}.andExpect {
|
||||||
status { isOk() }
|
status { isOk() }
|
||||||
|
jsonPath("$[0].filmId") { value(firstFilmId.toString()) }
|
||||||
jsonPath("$[0].film.id") { value(firstFilmId.toString()) }
|
jsonPath("$[0].film.id") { value(firstFilmId.toString()) }
|
||||||
|
jsonPath("$[0].watchUrl") {
|
||||||
|
value("https://jellyfin.example.test/web/#/details?id=orbital-drift-item")
|
||||||
|
}
|
||||||
|
jsonPath("$[0].reasons[0]") { exists() }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.post("/api/users/$userId/recommendations/$firstFilmId/accept")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.filmId") { value(firstFilmId.toString()) }
|
||||||
|
jsonPath("$.eventType") { value("ACCEPTED") }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.post("/api/users/$userId/recommendations/$firstFilmId/reject")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.filmId") { value(firstFilmId.toString()) }
|
||||||
|
jsonPath("$.eventType") { value("REJECTED") }
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.get("/api/users/$userId/recommendations") {
|
||||||
|
param("contentType", "FILM")
|
||||||
|
param("libraryOnly", "true")
|
||||||
|
param("limit", "2")
|
||||||
|
}.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$") { isEmpty() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cleanDatabase() {
|
private fun cleanDatabase() {
|
||||||
|
jdbcTemplate.execute("DELETE FROM recommendation_events")
|
||||||
jdbcTemplate.execute("DELETE FROM film_ratings")
|
jdbcTemplate.execute("DELETE FROM film_ratings")
|
||||||
jdbcTemplate.execute("DELETE FROM user_preferences")
|
jdbcTemplate.execute("DELETE FROM user_preferences")
|
||||||
jdbcTemplate.execute("DELETE FROM favorites")
|
jdbcTemplate.execute("DELETE FROM favorites")
|
||||||
|
|||||||
@@ -26,3 +26,7 @@ services:
|
|||||||
- censored
|
- censored
|
||||||
- epstein
|
- epstein
|
||||||
- python
|
- python
|
||||||
|
|
||||||
|
integrations:
|
||||||
|
jellyfin:
|
||||||
|
web-url: https://jellyfin.example.test
|
||||||
|
|||||||
Reference in New Issue
Block a user