Реализована рекомендательная система: добавлены гибридный скоринг фильмов, события рекомендаций, accept/reject endpoints, watchUrl для Jellyfin, API DTO ответа и логирование выдачи рекомендаций.

This commit is contained in:
skettiks
2026-05-21 13:58:14 +03:00
parent 6e2e110bba
commit 00f53bc949
14 changed files with 778 additions and 80 deletions
@@ -1,34 +1,91 @@
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.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 com.project.movienight.domain.model.RecommendationResult
import org.springframework.web.bind.annotation.GetMapping
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.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendations")
class RecommendationController(
private val getRecommendationsUseCase: GetRecommendationsUseCase,
private val acceptRecommendationUseCase: AcceptRecommendationUseCase,
private val rejectRecommendationUseCase: RejectRecommendationUseCase,
private val jellyfinProperties: JellyfinIntegrationProperties,
) {
@GetMapping
fun recommend(
@PathVariable userId: UUID,
@RequestParam(required = false) contentType: String?,
@RequestParam(required = false) mood: String?,
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
@RequestParam(required = false, defaultValue = "10") limit: Int,
): List<RecommendationResult> =
): List<RecommendationResponse> =
getRecommendationsUseCase.recommend(
RecommendationQuery(
userId = userId,
contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() },
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(
@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"
}
}
@@ -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,
)
}
}
@@ -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),
)
}
}
}