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? {
|
override fun findByTitle(title: String): Film? {
|
||||||
val films =
|
val films =
|
||||||
jdbc.query(
|
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,
|
filmRowMapper,
|
||||||
title,
|
title,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class FilmLibraryController(
|
|||||||
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
|
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
|
||||||
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
|
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
|
||||||
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
|
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
|
||||||
|
private val getAllFilmsUseCase: GetAllFilmsUseCase,
|
||||||
private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase,
|
private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase,
|
||||||
) {
|
) {
|
||||||
@PostMapping
|
@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 {
|
try {
|
||||||
log.debug(
|
log.debug(
|
||||||
"Create film request received: title='{}', descriptionLength={}",
|
"Create film request received: title='{}', descriptionLength={}'",
|
||||||
command.title,
|
command.title,
|
||||||
command.description.length,
|
command.description.length,
|
||||||
)
|
)
|
||||||
@@ -56,23 +56,29 @@ class FilmService(
|
|||||||
throw BlockedValueException(target = "Film", field = "description")
|
throw BlockedValueException(target = "Film", field = "description")
|
||||||
}
|
}
|
||||||
|
|
||||||
val film =
|
val film =
|
||||||
Film(
|
Film(
|
||||||
id = idGenerator.generateId(),
|
id = idGenerator.generateId(),
|
||||||
title = command.title,
|
title = command.title,
|
||||||
description = command.description,
|
description = command.description,
|
||||||
contentType = command.contentType,
|
contentType = command.contentType,
|
||||||
releaseYear = command.releaseYear,
|
releaseYear = command.releaseYear,
|
||||||
genres = command.genres,
|
genres = command.genres,
|
||||||
cast = command.cast,
|
cast = command.cast,
|
||||||
directors = command.directors,
|
directors = command.directors,
|
||||||
imdbRating = command.imdbRating,
|
imdbRating = command.imdbRating,
|
||||||
platformRating = command.platformRating,
|
platformRating = command.platformRating,
|
||||||
externalUrl = command.externalUrl,
|
externalUrl = command.externalUrl,
|
||||||
jellyfinItemId = command.jellyfinItemId,
|
jellyfinItemId = command.jellyfinItemId,
|
||||||
jellyfinLibraryId = command.jellyfinLibraryId,
|
jellyfinLibraryId = command.jellyfinLibraryId,
|
||||||
)
|
)
|
||||||
return filmRepository.save(film)
|
|
||||||
|
val saved = filmRepository.save(film)
|
||||||
|
filmCreatedCounter.increment()
|
||||||
|
return saved
|
||||||
|
} finally {
|
||||||
|
sample.stop(createFilmTimer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun edit(
|
override fun edit(
|
||||||
@@ -95,30 +101,35 @@ class FilmService(
|
|||||||
throw BlockedValueException(target = "Film", field = "description")
|
throw BlockedValueException(target = "Film", field = "description")
|
||||||
}
|
}
|
||||||
|
|
||||||
val film = filmRepository.findById(id)
|
var film = filmRepository.findById(id)
|
||||||
|
|
||||||
if (film == null) {
|
if (film == null) {
|
||||||
log.debug("Film not found for edit: id='{}'", id)
|
log.debug("Film not found for edit: id='{}'", id)
|
||||||
throw EntityNotFoundException(entity = "Film", id = id.toString())
|
throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
film =
|
film =
|
||||||
film.copy(
|
film.copy(
|
||||||
title = command.title,
|
title = command.title,
|
||||||
description = command.description,
|
description = command.description,
|
||||||
contentType = command.contentType,
|
contentType = command.contentType,
|
||||||
releaseYear = command.releaseYear,
|
releaseYear = command.releaseYear,
|
||||||
genres = command.genres,
|
genres = command.genres,
|
||||||
cast = command.cast,
|
cast = command.cast,
|
||||||
directors = command.directors,
|
directors = command.directors,
|
||||||
imdbRating = command.imdbRating,
|
imdbRating = command.imdbRating,
|
||||||
platformRating = command.platformRating,
|
platformRating = command.platformRating,
|
||||||
externalUrl = command.externalUrl,
|
externalUrl = command.externalUrl,
|
||||||
jellyfinItemId = command.jellyfinItemId,
|
jellyfinItemId = command.jellyfinItemId,
|
||||||
jellyfinLibraryId = command.jellyfinLibraryId,
|
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) {
|
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