refactor(): overall refactoring and improvements

This commit is contained in:
ITQ
2026-05-22 16:48:10 +03:00
parent a2bb41cdd4
commit 3d5f48aada
14 changed files with 186 additions and 64 deletions
@@ -2,6 +2,9 @@ package com.project.movienight.adapters.jellyfin
import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper 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.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.ContentType
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -11,39 +14,18 @@ import java.net.http.HttpRequest
import java.net.http.HttpResponse import java.net.http.HttpResponse
import java.time.Duration 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 @Service
class JellyfinApiClient( class JellyfinApiClient(
private val properties: JellyfinIntegrationProperties, private val properties: JellyfinIntegrationProperties,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
) { ) : JellyfinSyncSourcePort {
private val httpClient: HttpClient = private val httpClient: HttpClient =
HttpClient HttpClient
.newBuilder() .newBuilder()
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs)) .connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
.build() .build()
fun fetchUsers(): List<JellyfinRemoteUser> = override fun fetchUsers(): List<JellyfinRemoteUser> =
request("Users") request("Users")
.asItems() .asItems()
.mapNotNull { node -> .mapNotNull { node ->
@@ -51,7 +33,7 @@ class JellyfinApiClient(
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id) JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
} }
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> = override fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
request( request(
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData", "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 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.JellyfinSyncSummary
import com.project.movienight.domain.model.RecommendationEventType import com.project.movienight.domain.model.RecommendationEventType
import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.Counter
@@ -33,7 +34,7 @@ class BusinessMetricsService(
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total") private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
fun recordRecommendationRequest() { override fun recordRecommendationRequest() {
recommendationRequests.increment() recommendationRequests.increment()
} }
@@ -49,11 +50,11 @@ class BusinessMetricsService(
ratingsSubmitted.increment() ratingsSubmitted.increment()
} }
fun recordLibraryEvent() { override fun recordLibraryEvent() {
libraryEvents.increment() libraryEvents.increment()
} }
fun recordJellyfinSync(summary: JellyfinSyncSummary) { override fun recordJellyfinSync(summary: JellyfinSyncSummary) {
jellyfinSyncRuns.increment() jellyfinSyncRuns.increment()
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble()) jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble()) jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
@@ -61,15 +62,15 @@ class BusinessMetricsService(
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS) jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
} }
fun recordJellyfinSyncFailure() { override fun recordJellyfinSyncFailure() {
jellyfinSyncFailures.increment() jellyfinSyncFailures.increment()
} }
fun recordJellyfinUnmappedUser() { override fun recordJellyfinUnmappedUser() {
jellyfinUnmappedUsersGaugeValue.incrementAndGet() jellyfinUnmappedUsersGaugeValue.incrementAndGet()
} }
fun recordBackendWriteFailure() { override fun recordBackendWriteFailure() {
backendWriteFailures.increment() backendWriteFailures.increment()
} }
} }
@@ -1,5 +1,6 @@
package com.project.movienight.adapters.persistence.jdbc 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.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -7,8 +8,8 @@ import org.springframework.stereotype.Repository
@Repository @Repository
class JellyfinEventRepository( class JellyfinEventRepository(
private val jdbc: NamedParameterJdbcTemplate, private val jdbc: NamedParameterJdbcTemplate,
) { ) : JellyfinEventRepositoryPort {
fun save( override fun save(
eventId: String, eventId: String,
serverId: String?, serverId: String?,
eventType: String, eventType: String,
@@ -37,7 +38,7 @@ class JellyfinEventRepository(
return jdbc.update(sql, params) 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 sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId"
val params = MapSqlParameterSource().addValue("eventId", eventId) val params = MapSqlParameterSource().addValue("eventId", eventId)
jdbc.update(sql, params) 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 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.AddFilmToLibraryCommand
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand 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.MarkFilmViewedUseCase
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase 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.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.DomainException
@@ -25,7 +25,7 @@ import java.util.UUID
class FilmLibraryService( class FilmLibraryService(
private val filmLibraryRepository: FilmLibraryRepositoryPort, private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val idGenerator: IdGenerator, private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService, private val businessMetricsService: BusinessMetricsPort,
) : CreateFilmLibraryUseCase, ) : CreateFilmLibraryUseCase,
AddFilmToLibraryUseCase, AddFilmToLibraryUseCase,
MarkFilmViewedUseCase, MarkFilmViewedUseCase,
@@ -1,9 +1,9 @@
package com.project.movienight.application.services 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.GetFilmRatingsUseCase
import com.project.movienight.application.ports.input.RateFilmCommand import com.project.movienight.application.ports.input.RateFilmCommand
import com.project.movienight.application.ports.input.RateFilmUseCase 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.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.IdGenerator
@@ -19,7 +19,7 @@ class FilmRatingService(
private val filmRepository: FilmRepositoryPort, private val filmRepository: FilmRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort,
private val idGenerator: IdGenerator, private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService, private val businessMetricsService: BusinessMetricsPort,
) : RateFilmUseCase, ) : RateFilmUseCase,
GetFilmRatingsUseCase { GetFilmRatingsUseCase {
override fun rate(command: RateFilmCommand): FilmRating { override fun rate(command: RateFilmCommand): FilmRating {
@@ -1,10 +1,10 @@
package com.project.movienight.application.services package com.project.movienight.application.services
import com.fasterxml.jackson.databind.ObjectMapper 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.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase 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.FilmRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -12,12 +12,12 @@ import java.time.OffsetDateTime
@Service @Service
class JellyfinEventService( class JellyfinEventService(
private val jellyfinEventRepository: JellyfinEventRepository, private val jellyfinEventRepository: JellyfinEventRepositoryPort,
private val userRepository: UserRepositoryPort, private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort, private val filmRepository: FilmRepositoryPort,
private val markFilmViewedUseCase: MarkFilmViewedUseCase, private val markFilmViewedUseCase: MarkFilmViewedUseCase,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val businessMetricsService: BusinessMetricsService, private val businessMetricsService: BusinessMetricsPort,
) { ) {
private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed") private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed")
@@ -1,13 +1,13 @@
package com.project.movienight.application.services package com.project.movienight.application.services
import com.project.movienight.adapters.jellyfin.JellyfinApiClient import com.project.movienight.application.ports.output.BusinessMetricsPort
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.FilmLibraryRepositoryPort import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort 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.application.ports.output.UserRepositoryPort
import com.project.movienight.config.JellyfinIntegrationProperties import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.ContentType
@@ -24,13 +24,13 @@ import java.time.LocalDateTime
@Service @Service
class JellyfinSyncService( class JellyfinSyncService(
private val properties: JellyfinIntegrationProperties, private val properties: JellyfinIntegrationProperties,
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinSyncSourcePort,
private val userRepository: UserRepositoryPort, private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort, private val filmRepository: FilmRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort, private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val syncStateRepository: JellyfinSyncStateRepositoryPort, private val syncStateRepository: JellyfinSyncStateRepositoryPort,
private val idGenerator: IdGenerator, private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService, private val businessMetricsService: BusinessMetricsPort,
) { ) {
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}") @Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
fun scheduledSync() { 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)
}
}