Hotfix/merge conflicts #59

Closed
devitq wants to merge 3 commits from hotfix/merge-conflicts into main
14 changed files with 186 additions and 64 deletions
Showing only changes of commit 3d5f48aada - Show all commits
@@ -2,6 +2,9 @@ package com.project.movienight.adapters.jellyfin
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
import com.project.movienight.application.ports.output.JellyfinRemoteUser
import com.project.movienight.application.ports.output.JellyfinSyncSourcePort
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
import org.springframework.stereotype.Service
@@ -11,39 +14,18 @@ import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
data class JellyfinRemoteUser(
val id: String,
val name: String,
)
data class JellyfinLibraryItemSnapshot(
val jellyfinItemId: String,
val title: String,
val description: String,
val contentType: ContentType,
val releaseYear: Int?,
val genres: List<String>,
val cast: List<String>,
val directors: List<String>,
val platformRating: Double?,
val imdbRating: Double?,
val externalUrl: String?,
val jellyfinLibraryId: String?,
val isPlayed: Boolean,
)
@Service
class JellyfinApiClient(
private val properties: JellyfinIntegrationProperties,
private val objectMapper: ObjectMapper,
) {
) : JellyfinSyncSourcePort {
private val httpClient: HttpClient =
HttpClient
.newBuilder()
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
.build()
fun fetchUsers(): List<JellyfinRemoteUser> =
override fun fetchUsers(): List<JellyfinRemoteUser> =
request("Users")
.asItems()
.mapNotNull { node ->
@@ -51,7 +33,7 @@ class JellyfinApiClient(
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
}
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
override fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
@Suppress("MaxLineLength")
request(
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
@@ -1,5 +1,6 @@
package com.project.movienight.adapters.metrics
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.domain.model.JellyfinSyncSummary
import com.project.movienight.domain.model.RecommendationEventType
import io.micrometer.core.instrument.Counter
@@ -33,7 +34,7 @@ class BusinessMetricsService(
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
fun recordRecommendationRequest() {
override fun recordRecommendationRequest() {
recommendationRequests.increment()
}
@@ -49,11 +50,11 @@ class BusinessMetricsService(
ratingsSubmitted.increment()
}
fun recordLibraryEvent() {
override fun recordLibraryEvent() {
libraryEvents.increment()
}
fun recordJellyfinSync(summary: JellyfinSyncSummary) {
override fun recordJellyfinSync(summary: JellyfinSyncSummary) {
jellyfinSyncRuns.increment()
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
@@ -61,15 +62,15 @@ class BusinessMetricsService(
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
}
fun recordJellyfinSyncFailure() {
override fun recordJellyfinSyncFailure() {
jellyfinSyncFailures.increment()
}
fun recordJellyfinUnmappedUser() {
override fun recordJellyfinUnmappedUser() {
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
}
fun recordBackendWriteFailure() {
override fun recordBackendWriteFailure() {
backendWriteFailures.increment()
}
}
@@ -1,5 +1,6 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.JellyfinEventRepositoryPort
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
@@ -7,8 +8,8 @@ import org.springframework.stereotype.Repository
@Repository
class JellyfinEventRepository(
private val jdbc: NamedParameterJdbcTemplate,
) {
fun save(
) : JellyfinEventRepositoryPort {
override fun save(
eventId: String,
serverId: String?,
eventType: String,
@@ -37,7 +38,7 @@ class JellyfinEventRepository(
return jdbc.update(sql, params)
}
fun delete(eventId: String) {
override fun delete(eventId: String) {
val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId"
val params = MapSqlParameterSource().addValue("eventId", eventId)
jdbc.update(sql, params)
@@ -0,0 +1,19 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.JellyfinSyncSummary
interface BusinessMetricsPort {
fun recordRecommendationRequest()
fun recordRatingSubmitted()
fun recordLibraryEvent()
fun recordJellyfinSync(summary: JellyfinSyncSummary)
fun recordJellyfinSyncFailure()
fun recordJellyfinUnmappedUser()
fun recordBackendWriteFailure()
}
@@ -0,0 +1,17 @@
package com.project.movienight.application.ports.output
import java.time.OffsetDateTime
interface JellyfinEventRepositoryPort {
fun save(
eventId: String,
serverId: String?,
eventType: String,
occurredAt: OffsetDateTime?,
jellyfinUserId: String?,
jellyfinItemId: String?,
payload: String?,
): Int
fun delete(eventId: String)
}
@@ -0,0 +1,30 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.ContentType
data class JellyfinRemoteUser(
val id: String,
val name: String,
)
data class JellyfinLibraryItemSnapshot(
val jellyfinItemId: String,
val title: String,
val description: String,
val contentType: ContentType,
val releaseYear: Int?,
val genres: List<String>,
val cast: List<String>,
val directors: List<String>,
val platformRating: Double?,
val imdbRating: Double?,
val externalUrl: String?,
val jellyfinLibraryId: String?,
val isPlayed: Boolean,
)
interface JellyfinSyncSourcePort {
fun fetchUsers(): List<JellyfinRemoteUser>
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot>
}
@@ -1,6 +1,5 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
@@ -12,6 +11,7 @@ import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.domain.exception.DomainException
@@ -25,7 +25,7 @@ import java.util.UUID
class FilmLibraryService(
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
private val businessMetricsService: BusinessMetricsPort,
) : CreateFilmLibraryUseCase,
AddFilmToLibraryUseCase,
MarkFilmViewedUseCase,
@@ -1,9 +1,9 @@
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.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
@@ -19,7 +19,7 @@ class FilmRatingService(
private val filmRepository: FilmRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
private val businessMetricsService: BusinessMetricsPort,
) : RateFilmUseCase,
GetFilmRatingsUseCase {
override fun rate(command: RateFilmCommand): FilmRating {
@@ -1,10 +1,10 @@
package com.project.movienight.application.services
import com.fasterxml.jackson.databind.ObjectMapper
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.adapters.persistence.jdbc.JellyfinEventRepository
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.JellyfinEventRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort
import org.springframework.stereotype.Service
@@ -12,12 +12,12 @@ import java.time.OffsetDateTime
@Service
class JellyfinEventService(
private val jellyfinEventRepository: JellyfinEventRepository,
private val jellyfinEventRepository: JellyfinEventRepositoryPort,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
private val objectMapper: ObjectMapper,
private val businessMetricsService: BusinessMetricsService,
private val businessMetricsService: BusinessMetricsPort,
) {
private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed")
@@ -1,13 +1,13 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.jellyfin.JellyfinApiClient
import com.project.movienight.adapters.jellyfin.JellyfinLibraryItemSnapshot
import com.project.movienight.adapters.jellyfin.JellyfinRemoteUser
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
import com.project.movienight.application.ports.output.JellyfinRemoteUser
import com.project.movienight.application.ports.output.JellyfinSyncSourcePort
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
@@ -24,13 +24,13 @@ import java.time.LocalDateTime
@Service
class JellyfinSyncService(
private val properties: JellyfinIntegrationProperties,
private val jellyfinApiClient: JellyfinApiClient,
private val jellyfinApiClient: JellyfinSyncSourcePort,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
private val businessMetricsService: BusinessMetricsPort,
) {
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
fun scheduledSync() {
@@ -1,17 +0,0 @@
package com.project.movienight.domain.model
import java.util.UUID
data class RecommendationContext(
val userId: UUID,
val contentType: ContentType? = null,
val mood: String? = null,
val libraryOnly: Boolean = false,
val limit: Int = 10,
)
data class RecommendationResult(
val film: Film,
val score: Double,
val reasons: List<String>,
)
@@ -0,0 +1,7 @@
package com.project.movienight.domain.model
data class RecommendationResult(
val film: Film,
val score: Double,
val reasons: List<String>,
)
@@ -0,0 +1,26 @@
package com.project.movienight.adapters.web
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.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import java.util.UUID
@SpringBootTest
@AutoConfigureMockMvc(addFilters = false)
class RecommendationControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Test
fun `invalid content type should return bad request`() {
mockMvc
.get("/api/users/${UUID.randomUUID()}/recommendations") {
param("contentType", "INVALID")
}.andExpect {
status { isBadRequest() }
}
}
}
@@ -0,0 +1,56 @@
package com.project.movienight.application.services
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.output.BusinessMetricsPort
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.Film
import com.project.movienight.domain.model.FilmRating
import com.project.movienight.domain.model.UserPreferences
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.util.UUID
class RecommendationServiceTest {
private val filmRepository: FilmRepositoryPort = mockk()
private val filmLibraryRepository: FilmLibraryRepositoryPort = mockk()
private val filmRatingRepository: FilmRatingRepositoryPort = mockk()
private val userPreferencesRepository: UserPreferencesRepositoryPort = mockk()
private val businessMetricsPort: BusinessMetricsPort = mockk(relaxed = true)
private val recommendationService =
RecommendationService(
filmRepository = filmRepository,
filmLibraryRepository = filmLibraryRepository,
filmRatingRepository = filmRatingRepository,
userPreferencesRepository = userPreferencesRepository,
businessMetricsService = businessMetricsPort,
)
@Test
fun `should rank highly rated films above poorly rated films`() {
val userId = UUID.randomUUID()
val lowRatedFilmId = UUID.randomUUID()
val highRatedFilmId = UUID.randomUUID()
val lowRatedFilm = Film(id = lowRatedFilmId, title = "Low", description = "Low")
val highRatedFilm = Film(id = highRatedFilmId, title = "High", description = "High")
every { userPreferencesRepository.findByUserId(userId) } returns UserPreferences(userId = userId)
every { filmRatingRepository.findByUserId(userId) } returns
listOf(
FilmRating(id = UUID.randomUUID(), userId = userId, filmId = lowRatedFilmId, score = 1),
FilmRating(id = UUID.randomUUID(), userId = userId, filmId = highRatedFilmId, score = 10),
)
every { filmLibraryRepository.findAll() } returns emptyList()
every { filmRepository.findAll() } returns listOf(lowRatedFilm, highRatedFilm)
val result = recommendationService.recommend(RecommendationQuery(userId = userId))
assertEquals(highRatedFilmId, result.first().film.id)
}
}