core: restore compilation and tests (ports, DTOs, repo fixes, metrics)
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package com.project.movienight.adapters.metrics
|
||||
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import io.micrometer.core.instrument.Counter
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import io.micrometer.core.instrument.Timer
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@Service
|
||||
class BusinessMetricsService(
|
||||
meterRegistry: MeterRegistry,
|
||||
) {
|
||||
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
|
||||
private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total")
|
||||
private val libraryEvents: Counter = meterRegistry.counter("business_library_events_total")
|
||||
private val jellyfinSyncRuns: Counter = meterRegistry.counter("business_jellyfin_sync_runs_total")
|
||||
private val jellyfinSyncedUsers: Counter = meterRegistry.counter("business_jellyfin_synced_users_total")
|
||||
private val jellyfinSkippedUsers: Counter = meterRegistry.counter("business_jellyfin_skipped_users_total")
|
||||
private val jellyfinSyncedItems: Counter = meterRegistry.counter("business_jellyfin_synced_items_total")
|
||||
private val jellyfinSyncDuration: Timer =
|
||||
Timer
|
||||
.builder("business_jellyfin_sync_duration_seconds")
|
||||
.publishPercentileHistogram()
|
||||
.register(meterRegistry)
|
||||
private val jellyfinSyncFailures: Counter = meterRegistry.counter("business_jellyfin_sync_failures_total")
|
||||
private val jellyfinUnmappedUsersGaugeValue = AtomicInteger(0)
|
||||
|
||||
init {
|
||||
meterRegistry.gauge("business_jellyfin_unmapped_users", jellyfinUnmappedUsersGaugeValue)
|
||||
}
|
||||
|
||||
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
|
||||
|
||||
fun recordRecommendationRequest() {
|
||||
recommendationRequests.increment()
|
||||
}
|
||||
|
||||
fun recordRatingSubmitted() {
|
||||
ratingsSubmitted.increment()
|
||||
}
|
||||
|
||||
fun recordLibraryEvent() {
|
||||
libraryEvents.increment()
|
||||
}
|
||||
|
||||
fun recordJellyfinSync(summary: JellyfinSyncSummary) {
|
||||
jellyfinSyncRuns.increment()
|
||||
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
|
||||
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
|
||||
jellyfinSyncedItems.increment(summary.syncedItems.toDouble())
|
||||
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
fun recordJellyfinSyncFailure() {
|
||||
jellyfinSyncFailures.increment()
|
||||
}
|
||||
|
||||
fun recordJellyfinUnmappedUser() {
|
||||
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
|
||||
}
|
||||
|
||||
fun recordBackendWriteFailure() {
|
||||
backendWriteFailures.increment()
|
||||
}
|
||||
}
|
||||
@@ -209,7 +209,25 @@ class FilmRepository(
|
||||
override fun findByTitle(title: String): Film? {
|
||||
val films =
|
||||
jdbc.query(
|
||||
"SELECT id, title, description FROM films WHERE title = ? ORDER BY id LIMIT 1",
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
FROM films
|
||||
WHERE title = ?
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""".trimIndent(),
|
||||
filmRowMapper,
|
||||
title,
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ class FilmLibraryController(
|
||||
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
|
||||
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
|
||||
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
|
||||
private val getAllFilmsUseCase: GetAllFilmsUseCase,
|
||||
private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
|
||||
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
|
||||
import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
|
||||
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||
import com.project.movienight.application.ports.input.RateFilmUseCase
|
||||
import org.springframework.http.HttpStatus
|
||||
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.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/ratings")
|
||||
class FilmRatingController(
|
||||
private val rateFilmUseCase: RateFilmUseCase,
|
||||
private val getFilmRatingsUseCase: GetFilmRatingsUseCase,
|
||||
) {
|
||||
@PostMapping("/films/{filmId}")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun rate(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
@RequestBody request: RateFilmRequest,
|
||||
): FilmRatingResponse =
|
||||
FilmRatingResponse.fromDomain(
|
||||
rateFilmUseCase.rate(
|
||||
RateFilmCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
score = request.score,
|
||||
note = request.note,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun list(
|
||||
@PathVariable userId: UUID,
|
||||
): List<FilmRatingResponse> = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
|
||||
import com.project.movienight.application.ports.input.RecommendationQuery
|
||||
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.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/recommendations")
|
||||
class RecommendationController(
|
||||
private val getRecommendationsUseCase: GetRecommendationsUseCase,
|
||||
) {
|
||||
@GetMapping
|
||||
fun recommend(
|
||||
@PathVariable userId: UUID,
|
||||
@RequestParam(required = false) contentType: String?,
|
||||
@RequestParam(required = false) mood: String?,
|
||||
@RequestParam(required = false, defaultValue = "10") limit: Int,
|
||||
): List<RecommendationResult> =
|
||||
getRecommendationsUseCase.recommend(
|
||||
RecommendationQuery(
|
||||
userId = userId,
|
||||
contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() },
|
||||
mood = mood,
|
||||
limit = limit,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest
|
||||
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
|
||||
import com.project.movienight.application.ports.input.GetUserPreferencesUseCase
|
||||
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
|
||||
import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/preferences")
|
||||
class UserPreferencesController(
|
||||
private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase,
|
||||
private val getUserPreferencesUseCase: GetUserPreferencesUseCase,
|
||||
) {
|
||||
@PutMapping
|
||||
fun upsert(
|
||||
@PathVariable userId: UUID,
|
||||
@RequestBody request: UpsertUserPreferencesRequest,
|
||||
): UserPreferencesResponse =
|
||||
UserPreferencesResponse.fromDomain(
|
||||
upsertUserPreferencesUseCase.upsert(
|
||||
UpsertUserPreferencesCommand(
|
||||
userId = userId,
|
||||
weightedGenres = request.weightedGenres,
|
||||
plotTypes = request.plotTypes,
|
||||
eras = request.eras,
|
||||
castAndDirectors = request.castAndDirectors,
|
||||
moods = request.moods,
|
||||
contentTypes =
|
||||
request.contentTypes.mapNotNull {
|
||||
runCatching {
|
||||
ContentType.valueOf(
|
||||
it,
|
||||
)
|
||||
}.getOrNull()
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun get(
|
||||
@PathVariable userId: UUID,
|
||||
): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
data class RateFilmRequest(
|
||||
val score: Int,
|
||||
val note: String? = null,
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
data class UpsertUserPreferencesRequest(
|
||||
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||
val plotTypes: List<String> = emptyList(),
|
||||
val eras: List<String> = emptyList(),
|
||||
val castAndDirectors: List<String> = emptyList(),
|
||||
val moods: List<String> = emptyList(),
|
||||
val contentTypes: List<String> = emptyList(),
|
||||
)
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.project.movienight.adapters.web.dto.response
|
||||
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class FilmRatingResponse(
|
||||
val id: UUID,
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
val score: Int,
|
||||
val note: String?,
|
||||
val createdAt: LocalDateTime,
|
||||
val updatedAt: LocalDateTime,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(rating: FilmRating): FilmRatingResponse =
|
||||
FilmRatingResponse(
|
||||
id = rating.id,
|
||||
userId = rating.userId,
|
||||
filmId = rating.filmId,
|
||||
score = rating.score,
|
||||
note = rating.note,
|
||||
createdAt = rating.createdAt,
|
||||
updatedAt = rating.updatedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.project.movienight.adapters.web.dto.response
|
||||
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import java.util.UUID
|
||||
|
||||
data class UserPreferencesResponse(
|
||||
val userId: UUID,
|
||||
val weightedGenres: Map<String, Int>,
|
||||
val plotTypes: List<String>,
|
||||
val eras: List<String>,
|
||||
val castAndDirectors: List<String>,
|
||||
val moods: List<String>,
|
||||
val contentTypes: List<ContentType>,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(preferences: UserPreferences): UserPreferencesResponse =
|
||||
UserPreferencesResponse(
|
||||
userId = preferences.userId,
|
||||
weightedGenres = preferences.weightedGenres,
|
||||
plotTypes = preferences.plotTypes,
|
||||
eras = preferences.eras,
|
||||
castAndDirectors = preferences.castAndDirectors,
|
||||
moods = preferences.moods,
|
||||
contentTypes = preferences.contentTypes,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import java.util.UUID
|
||||
|
||||
interface RateFilmUseCase {
|
||||
fun rate(command: RateFilmCommand): FilmRating
|
||||
}
|
||||
|
||||
data class RateFilmCommand(
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
val score: Int,
|
||||
val note: String? = null,
|
||||
)
|
||||
|
||||
interface GetFilmRatingsUseCase {
|
||||
fun getRatings(userId: UUID): List<FilmRating>
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.RecommendationResult
|
||||
import java.util.UUID
|
||||
|
||||
interface GetRecommendationsUseCase {
|
||||
fun recommend(query: RecommendationQuery): List<RecommendationResult>
|
||||
}
|
||||
|
||||
data class RecommendationQuery(
|
||||
val userId: UUID,
|
||||
val contentType: ContentType? = null,
|
||||
val mood: String? = null,
|
||||
val limit: Int = 10,
|
||||
)
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import java.util.UUID
|
||||
|
||||
interface UpsertUserPreferencesUseCase {
|
||||
fun upsert(command: UpsertUserPreferencesCommand): UserPreferences
|
||||
}
|
||||
|
||||
data class UpsertUserPreferencesCommand(
|
||||
val userId: UUID,
|
||||
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||
val plotTypes: List<String> = emptyList(),
|
||||
val eras: List<String> = emptyList(),
|
||||
val castAndDirectors: List<String> = emptyList(),
|
||||
val moods: List<String> = emptyList(),
|
||||
val contentTypes: List<ContentType> = emptyList(),
|
||||
)
|
||||
|
||||
interface GetUserPreferencesUseCase {
|
||||
fun get(userId: UUID): UserPreferences?
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||
import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
|
||||
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||
import com.project.movienight.application.ports.input.RateFilmUseCase
|
||||
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.domain.exception.DomainException
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class FilmRatingService(
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val businessMetricsService: BusinessMetricsService,
|
||||
) : RateFilmUseCase,
|
||||
GetFilmRatingsUseCase {
|
||||
override fun rate(command: RateFilmCommand): FilmRating {
|
||||
if (command.score !in 1..10) {
|
||||
throw DomainException("Film rating score must be between 1 and 10")
|
||||
}
|
||||
|
||||
filmRepository.findById(command.filmId)
|
||||
?: throw EntityNotFoundException(entity = "Film", id = command.filmId.toString())
|
||||
|
||||
val existingRating = filmRatingRepository.findByUserIdAndFilmId(command.userId, command.filmId)
|
||||
val now = LocalDateTime.now()
|
||||
|
||||
val rating =
|
||||
if (existingRating == null) {
|
||||
FilmRating(
|
||||
id = idGenerator.generateId(),
|
||||
userId = command.userId,
|
||||
filmId = command.filmId,
|
||||
score = command.score,
|
||||
note = command.note,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
)
|
||||
} else {
|
||||
existingRating.copy(score = command.score, note = command.note, updatedAt = now)
|
||||
}
|
||||
|
||||
val savedRating = filmRatingRepository.save(rating)
|
||||
businessMetricsService.recordRatingSubmitted()
|
||||
return savedRating
|
||||
}
|
||||
|
||||
override fun getRatings(userId: UUID): List<FilmRating> = filmRatingRepository.findByUserId(userId)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class FilmService(
|
||||
|
||||
try {
|
||||
log.debug(
|
||||
"Create film request received: title='{}', descriptionLength={}",
|
||||
"Create film request received: title='{}', descriptionLength={}'",
|
||||
command.title,
|
||||
command.description.length,
|
||||
)
|
||||
@@ -56,23 +56,29 @@ class FilmService(
|
||||
throw BlockedValueException(target = "Film", field = "description")
|
||||
}
|
||||
|
||||
val film =
|
||||
Film(
|
||||
id = idGenerator.generateId(),
|
||||
title = command.title,
|
||||
description = command.description,
|
||||
contentType = command.contentType,
|
||||
releaseYear = command.releaseYear,
|
||||
genres = command.genres,
|
||||
cast = command.cast,
|
||||
directors = command.directors,
|
||||
imdbRating = command.imdbRating,
|
||||
platformRating = command.platformRating,
|
||||
externalUrl = command.externalUrl,
|
||||
jellyfinItemId = command.jellyfinItemId,
|
||||
jellyfinLibraryId = command.jellyfinLibraryId,
|
||||
)
|
||||
return filmRepository.save(film)
|
||||
val film =
|
||||
Film(
|
||||
id = idGenerator.generateId(),
|
||||
title = command.title,
|
||||
description = command.description,
|
||||
contentType = command.contentType,
|
||||
releaseYear = command.releaseYear,
|
||||
genres = command.genres,
|
||||
cast = command.cast,
|
||||
directors = command.directors,
|
||||
imdbRating = command.imdbRating,
|
||||
platformRating = command.platformRating,
|
||||
externalUrl = command.externalUrl,
|
||||
jellyfinItemId = command.jellyfinItemId,
|
||||
jellyfinLibraryId = command.jellyfinLibraryId,
|
||||
)
|
||||
|
||||
val saved = filmRepository.save(film)
|
||||
filmCreatedCounter.increment()
|
||||
return saved
|
||||
} finally {
|
||||
sample.stop(createFilmTimer)
|
||||
}
|
||||
}
|
||||
|
||||
override fun edit(
|
||||
@@ -95,30 +101,35 @@ class FilmService(
|
||||
throw BlockedValueException(target = "Film", field = "description")
|
||||
}
|
||||
|
||||
val film = filmRepository.findById(id)
|
||||
var film = filmRepository.findById(id)
|
||||
|
||||
if (film == null) {
|
||||
log.debug("Film not found for edit: id='{}'", id)
|
||||
throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||
}
|
||||
|
||||
film =
|
||||
film.copy(
|
||||
title = command.title,
|
||||
description = command.description,
|
||||
contentType = command.contentType,
|
||||
releaseYear = command.releaseYear,
|
||||
genres = command.genres,
|
||||
cast = command.cast,
|
||||
directors = command.directors,
|
||||
imdbRating = command.imdbRating,
|
||||
platformRating = command.platformRating,
|
||||
externalUrl = command.externalUrl,
|
||||
jellyfinItemId = command.jellyfinItemId,
|
||||
jellyfinLibraryId = command.jellyfinLibraryId,
|
||||
)
|
||||
film =
|
||||
film.copy(
|
||||
title = command.title,
|
||||
description = command.description,
|
||||
contentType = command.contentType,
|
||||
releaseYear = command.releaseYear,
|
||||
genres = command.genres,
|
||||
cast = command.cast,
|
||||
directors = command.directors,
|
||||
imdbRating = command.imdbRating,
|
||||
platformRating = command.platformRating,
|
||||
externalUrl = command.externalUrl,
|
||||
jellyfinItemId = command.jellyfinItemId,
|
||||
jellyfinLibraryId = command.jellyfinLibraryId,
|
||||
)
|
||||
|
||||
return filmRepository.save(film)
|
||||
val saved = filmRepository.save(film)
|
||||
filmEditedCounter.increment()
|
||||
return saved
|
||||
} finally {
|
||||
sample.stop(editFilmTimer)
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(id: UUID) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
|
||||
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
|
||||
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.Film
|
||||
import com.project.movienight.domain.model.RecommendationResult
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class RecommendationService(
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||
private val businessMetricsService: BusinessMetricsService,
|
||||
) : GetRecommendationsUseCase {
|
||||
override fun recommend(query: RecommendationQuery): List<RecommendationResult> {
|
||||
businessMetricsService.recordRecommendationRequest()
|
||||
val preferences = userPreferencesRepository.findByUserId(query.userId)
|
||||
val ratings = filmRatingRepository.findByUserId(query.userId).associateBy { it.filmId }
|
||||
val watchedFilmIds =
|
||||
filmLibraryRepository
|
||||
.findAll()
|
||||
.filter {
|
||||
it.userId == query.userId && it.isViewed
|
||||
}.map { it.filmId }
|
||||
.toSet()
|
||||
|
||||
return filmRepository
|
||||
.findAll()
|
||||
.asSequence()
|
||||
.filter { film -> query.contentType == null || film.contentType == query.contentType }
|
||||
.map { film ->
|
||||
scoreFilm(film, query.mood, preferences, ratings[film.id] != null, watchedFilmIds.contains(film.id))
|
||||
}.sortedByDescending { it.score }
|
||||
.take(query.limit.coerceAtLeast(1))
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun scoreFilm(
|
||||
film: Film,
|
||||
mood: String?,
|
||||
preferences: com.project.movienight.domain.model.UserPreferences?,
|
||||
hasUserRating: Boolean,
|
||||
watched: Boolean,
|
||||
): RecommendationResult {
|
||||
var score = 0.0
|
||||
val reasons = mutableListOf<String>()
|
||||
|
||||
preferences?.contentTypes?.let {
|
||||
if (it.isEmpty() || it.contains(film.contentType)) {
|
||||
score += 2.0
|
||||
reasons += "Matches content preference"
|
||||
}
|
||||
}
|
||||
|
||||
preferences?.weightedGenres?.forEach { (genre, weight) ->
|
||||
if (film.genres.any { it.equals(genre, ignoreCase = true) }) {
|
||||
score += weight
|
||||
reasons += "Matches genre $genre"
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
reasons += "Baseline recommendation from library catalog"
|
||||
}
|
||||
|
||||
return RecommendationResult(film = film, score = score, reasons = reasons)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.application.ports.input.GetUserPreferencesUseCase
|
||||
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
|
||||
import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase
|
||||
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class UserPreferencesService(
|
||||
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||
) : UpsertUserPreferencesUseCase,
|
||||
GetUserPreferencesUseCase {
|
||||
override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences =
|
||||
userPreferencesRepository.save(
|
||||
UserPreferences(
|
||||
userId = command.userId,
|
||||
weightedGenres = command.weightedGenres,
|
||||
plotTypes = command.plotTypes,
|
||||
eras = command.eras,
|
||||
castAndDirectors = command.castAndDirectors,
|
||||
moods = command.moods,
|
||||
contentTypes = command.contentTypes,
|
||||
),
|
||||
)
|
||||
|
||||
override fun get(userId: java.util.UUID): UserPreferences? = userPreferencesRepository.findByUserId(userId)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.project.movienight
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.project.movienight.adapters.web.dto.request.CreateFilmRequest
|
||||
import com.project.movienight.adapters.web.dto.request.CreateUserRequest
|
||||
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
|
||||
import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.get
|
||||
import org.springframework.test.web.servlet.post
|
||||
import org.springframework.test.web.servlet.put
|
||||
import java.util.UUID
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
@ActiveProfiles("test")
|
||||
class RecommendationSmokeTest {
|
||||
@Autowired
|
||||
private lateinit var mockMvc: MockMvc
|
||||
|
||||
@Autowired
|
||||
private lateinit var objectMapper: ObjectMapper
|
||||
|
||||
@Autowired
|
||||
private lateinit var jdbcTemplate: JdbcTemplate
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
cleanDatabase()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun cleanup() {
|
||||
cleanDatabase()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create data and return a ranked recommendation`() {
|
||||
mockMvc
|
||||
.post("/api/users") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = objectMapper.writeValueAsString(CreateUserRequest(name = "Jane", email = "jane@example.com"))
|
||||
}.andExpect {
|
||||
status { isCreated() }
|
||||
}
|
||||
|
||||
val userId =
|
||||
UUID.fromString(
|
||||
jdbcTemplate.queryForObject(
|
||||
"SELECT id FROM users WHERE email = ?",
|
||||
String::class.java,
|
||||
"jane@example.com",
|
||||
),
|
||||
)
|
||||
|
||||
mockMvc
|
||||
.post("/api/films") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content =
|
||||
objectMapper.writeValueAsString(
|
||||
CreateFilmRequest(
|
||||
title = "Orbital Drift",
|
||||
description = "A science-fiction rescue mission",
|
||||
contentType = "FILM",
|
||||
genres = listOf("SCI-FI", "THRILLER"),
|
||||
directors = listOf("Nora Finch"),
|
||||
imdbRating = 8.7,
|
||||
platformRating = 9.0,
|
||||
externalUrl = "https://example.com/orbital-drift",
|
||||
),
|
||||
)
|
||||
}.andExpect {
|
||||
status { isCreated() }
|
||||
}
|
||||
|
||||
mockMvc
|
||||
.post("/api/films") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content =
|
||||
objectMapper.writeValueAsString(
|
||||
CreateFilmRequest(
|
||||
title = "Small Town Summer",
|
||||
description = "A grounded family drama",
|
||||
contentType = "FILM",
|
||||
genres = listOf("DRAMA"),
|
||||
directors = listOf("Ava Reed"),
|
||||
imdbRating = 7.1,
|
||||
platformRating = 6.8,
|
||||
),
|
||||
)
|
||||
}.andExpect {
|
||||
status { isCreated() }
|
||||
}
|
||||
|
||||
val createdFilms = jdbcTemplate.queryForList("SELECT id, title FROM films ORDER BY title")
|
||||
val filmIdByTitle =
|
||||
createdFilms.associate { row ->
|
||||
row["title"].toString() to UUID.fromString(row["id"].toString())
|
||||
}
|
||||
val firstFilmId = filmIdByTitle.getValue("Orbital Drift")
|
||||
val secondFilmId = filmIdByTitle.getValue("Small Town Summer")
|
||||
|
||||
mockMvc
|
||||
.put("/api/users/$userId/preferences") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content =
|
||||
objectMapper.writeValueAsString(
|
||||
UpsertUserPreferencesRequest(
|
||||
weightedGenres = mapOf("SCI-FI" to 5),
|
||||
moods = listOf("focused"),
|
||||
contentTypes = listOf("FILM"),
|
||||
),
|
||||
)
|
||||
}.andExpect {
|
||||
status { isOk() }
|
||||
jsonPath("$.weightedGenres['SCI-FI']") { value(5) }
|
||||
}
|
||||
|
||||
mockMvc
|
||||
.post("/api/users/$userId/ratings/films/$firstFilmId") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = objectMapper.writeValueAsString(RateFilmRequest(score = 10, note = "Great fit"))
|
||||
}.andExpect {
|
||||
status { isCreated() }
|
||||
jsonPath("$.score") { value(10) }
|
||||
}
|
||||
|
||||
mockMvc
|
||||
.post("/api/users/$userId/library/films/$secondFilmId/viewed")
|
||||
.andExpect {
|
||||
status { isOk() }
|
||||
jsonPath("$.viewed") { value(true) }
|
||||
}
|
||||
|
||||
mockMvc
|
||||
.get("/api/users/$userId/ratings")
|
||||
.andExpect {
|
||||
status { isOk() }
|
||||
jsonPath("$[0].filmId") { value(firstFilmId.toString()) }
|
||||
}
|
||||
|
||||
mockMvc
|
||||
.get("/api/users/$userId/recommendations") {
|
||||
param("contentType", "FILM")
|
||||
param("limit", "2")
|
||||
}.andExpect {
|
||||
status { isOk() }
|
||||
jsonPath("$[0].film.id") { value(firstFilmId.toString()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanDatabase() {
|
||||
jdbcTemplate.execute("DELETE FROM film_ratings")
|
||||
jdbcTemplate.execute("DELETE FROM user_preferences")
|
||||
jdbcTemplate.execute("DELETE FROM favorites")
|
||||
jdbcTemplate.execute("DELETE FROM films")
|
||||
jdbcTemplate.execute("DELETE FROM users")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user