Реализована рекомендательная система: добавлены гибридный скоринг фильмов, события рекомендаций, 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"
}
}