diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt index 71670be..1a107da 100644 --- a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -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, - val cast: List, - val directors: List, - 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 = + override fun fetchUsers(): List = request("Users") .asItems() .mapNotNull { node -> @@ -51,7 +33,7 @@ class JellyfinApiClient( JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id) } - fun fetchLibraryItems(userId: String): List = + override fun fetchLibraryItems(userId: String): List = @Suppress("MaxLineLength") request( "Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData", diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt index b3912f1..d06d815 100644 --- a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -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() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 153eba7..80334de 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -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) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt new file mode 100644 index 0000000..34766f7 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt @@ -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() +} \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt new file mode 100644 index 0000000..eb084f3 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt @@ -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) +} \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt new file mode 100644 index 0000000..4113a7d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt @@ -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, + val cast: List, + val directors: List, + val platformRating: Double?, + val imdbRating: Double?, + val externalUrl: String?, + val jellyfinLibraryId: String?, + val isPlayed: Boolean, +) + +interface JellyfinSyncSourcePort { + fun fetchUsers(): List + + fun fetchLibraryItems(userId: String): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 381f3bf..af4ed13 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt index 738bada..54f7d72 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt @@ -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 { diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index 64be033..d4fe6a0 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -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") diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt index 46faa88..a6776d1 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -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() { diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt deleted file mode 100644 index 142754a..0000000 --- a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt +++ /dev/null @@ -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, -) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt new file mode 100644 index 0000000..5b5b0a4 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt @@ -0,0 +1,7 @@ +package com.project.movienight.domain.model + +data class RecommendationResult( + val film: Film, + val score: Double, + val reasons: List, +) \ No newline at end of file diff --git a/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt new file mode 100644 index 0000000..75e222e --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt @@ -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() } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt new file mode 100644 index 0000000..ea8e84a --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt @@ -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) + } +} \ No newline at end of file