diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 6db897a..39274c8 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -3,10 +3,8 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication -import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication -@EnableScheduling @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt deleted file mode 100644 index 2aea71c..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt +++ /dev/null @@ -1,125 +0,0 @@ -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.JellyfinCatalogPort -import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot -import com.project.movienight.application.ports.output.JellyfinRemoteUser -import com.project.movienight.config.JellyfinIntegrationProperties -import com.project.movienight.domain.model.ContentType -import org.springframework.stereotype.Service -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse -import java.time.Duration - -@Service -class JellyfinApiClient( - private val properties: JellyfinIntegrationProperties, - private val objectMapper: ObjectMapper, -) : JellyfinCatalogPort { - private val httpClient: HttpClient = - HttpClient - .newBuilder() - .connectTimeout(Duration.ofMillis(properties.requestTimeoutMs)) - .build() - - override fun fetchUsers(): List = - request("Users") - .asItems() - .mapNotNull { node -> - val id = node.fieldText("Id") ?: return@mapNotNull null - JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id) - } - - 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", - ).asItems().mapNotNull { node -> - val itemId = node.fieldText("Id") ?: return@mapNotNull null - val providerIds = node["ProviderIds"] - val imdbId = providerIds?.fieldText("Imdb") - val people = node["People"] - val cast = people?.peopleByType("Actor", "GuestStar") ?: emptyList() - val directors = people?.peopleByType("Director") ?: emptyList() - JellyfinLibraryItemSnapshot( - jellyfinItemId = itemId, - title = node.fieldText("Name") ?: itemId, - description = node.fieldText("Overview") ?: "", - contentType = mapContentType(node.fieldText("Type")), - releaseYear = node["ProductionYear"]?.takeUnless { it.isNull }?.asInt(), - genres = node["Genres"]?.textList() ?: emptyList(), - cast = cast, - directors = directors, - platformRating = node["CommunityRating"]?.takeUnless { it.isNull }?.asDouble(), - imdbRating = null, - externalUrl = imdbId?.let { "https://www.imdb.com/title/$it/" }, - jellyfinLibraryId = node.fieldText("ParentId"), - isPlayed = - node["UserData"]?.booleanField("Played") ?: node["UserData"]?.booleanField("IsPlayed") ?: false, - ) - } - - private fun request(path: String): JsonNode { - val uri = URI.create("${properties.baseUrl.trimEnd('/')}/$path") - val request = - HttpRequest - .newBuilder(uri) - .timeout(Duration.ofMillis(properties.requestTimeoutMs)) - .header("Accept", "application/json") - .header("X-Emby-Token", properties.apiKey) - .GET() - .build() - - val response = - try { - httpClient.send(request, HttpResponse.BodyHandlers.ofString()) - } catch ( - @Suppress("TooGenericExceptionCaught") exception: Exception, - ) { - throw IllegalStateException("Failed to call Jellyfin at $uri", exception) - } - - check(response.statusCode() in 200..299) { - "Jellyfin request failed with status ${response.statusCode()} for $uri" - } - - return objectMapper.readTree(response.body()) - } - - private fun JsonNode.asItems(): List = - when { - isArray -> map { it } - has("Items") && this["Items"].isArray -> this["Items"].map { it } - else -> emptyList() - } - - private fun JsonNode.fieldText(name: String): String? = - get(name)?.takeUnless { it.isNull }?.asText()?.takeIf { it.isNotBlank() } - - private fun JsonNode.booleanField(name: String): Boolean? = get(name)?.takeUnless { it.isNull }?.asBoolean() - - private fun JsonNode.textList(): List = - takeIf { it.isArray }?.mapNotNull { item -> - item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() } - } - ?: emptyList() - - private fun JsonNode.peopleByType(vararg types: String): List { - if (!isArray) return emptyList() - return mapNotNull { person -> - val type = person.fieldText("Type") ?: return@mapNotNull null - if (types.any { it.equals(type, ignoreCase = true) }) person.fieldText("Name") else null - } - } - - private fun mapContentType(value: String?): ContentType = - when (value?.lowercase()) { - "movie" -> ContentType.FILM - "series" -> ContentType.SERIES - "episode" -> ContentType.EPISODE - else -> ContentType.OTHER - } -} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt index fa3d3c4..f3279c8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -20,18 +20,15 @@ class JellyfinSyncController( private val properties: JellyfinIntegrationProperties, ) { @PostMapping("/sync") - fun syncFromPlugin( + fun pushSync( @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, @Valid @RequestBody request: JellyfinSyncRequest, ): JellyfinSyncSummary { requireJellyfinIntegrationEnabled(properties) requireJellyfinPluginToken(properties, token) - return jellyfinSyncUseCase.syncFromPlugin(request.toCommand()) + return jellyfinSyncUseCase.sync(request.toCommand()) } - @PostMapping("/pull-sync") - fun pullSyncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow() - @GetMapping("/sync-state") fun syncState( @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt index ffa559a..c37d589 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt @@ -1,8 +1,12 @@ package com.project.movienight.adapters.web.dto.request -import com.project.movienight.application.ports.input.JellyfinPluginSyncCommand -import com.project.movienight.application.ports.input.JellyfinPluginSyncItem -import com.project.movienight.application.ports.input.JellyfinPluginUserState +import com.fasterxml.jackson.annotation.JsonAlias +import com.fasterxml.jackson.annotation.JsonProperty +import com.project.movienight.application.ports.input.PushJellyfinCatalogCommand +import com.project.movienight.application.ports.input.PushedJellyfinItem +import com.project.movienight.application.ports.input.PushedJellyfinUserState +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.model.ContentType import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank import java.time.OffsetDateTime @@ -11,45 +15,96 @@ data class JellyfinSyncRequest( @field:Valid val items: List = emptyList(), ) { - fun toCommand(): JellyfinPluginSyncCommand = - JellyfinPluginSyncCommand( + fun toCommand(): PushJellyfinCatalogCommand = + PushJellyfinCatalogCommand( items = items.map { it.toCommand() }, ) } data class JellyfinSyncItemRequest( + @JsonProperty("jellyfin_item_id") + @JsonAlias("jellyfinItemId", "itemId", "Id") @field:NotBlank val jellyfinItemId: String, - val title: String?, - val description: String?, - val year: Int?, + @JsonProperty("jellyfin_library_id") + @JsonAlias("jellyfinLibraryId", "libraryId", "ParentId") + val jellyfinLibraryId: String? = null, + @JsonAlias("Name") + val title: String? = null, + @JsonAlias("Overview") + val description: String? = null, + @JsonProperty("content_type") + @JsonAlias("contentType", "type", "Type") + val contentType: String? = null, + @JsonProperty("release_year") + @JsonAlias("releaseYear", "ProductionYear") + val releaseYear: Int? = null, + val year: Int? = null, + @JsonAlias("Genres") val genres: List = emptyList(), - val imdbId: String?, + val cast: List = emptyList(), + val directors: List = emptyList(), + @JsonProperty("platform_rating") + @JsonAlias("platformRating", "communityRating", "CommunityRating") + val platformRating: Double? = null, + @JsonProperty("imdb_rating") + @JsonAlias("imdbRating") + val imdbRating: Double? = null, + @JsonProperty("external_url") + @JsonAlias("externalUrl") + val externalUrl: String? = null, + @JsonProperty("imdb_id") + @JsonAlias("imdbId") + val imdbId: String? = null, + @JsonProperty("user_states") + @JsonAlias("userStates") @field:Valid val userStates: List = emptyList(), ) { - fun toCommand(): JellyfinPluginSyncItem = - JellyfinPluginSyncItem( + fun toCommand(): PushedJellyfinItem = + PushedJellyfinItem( jellyfinItemId = jellyfinItemId, + jellyfinLibraryId = jellyfinLibraryId, title = title?.takeIf { it.isNotBlank() } ?: jellyfinItemId, description = description, - year = year, + contentType = parseContentType(contentType), + releaseYear = releaseYear ?: year, genres = genres, + cast = cast, + directors = directors, + platformRating = platformRating, + imdbRating = imdbRating, + externalUrl = externalUrl, imdbId = imdbId, userStates = userStates.map { it.toCommand() }, ) } data class JellyfinUserStateRequest( + @JsonProperty("jellyfin_user_id") + @JsonAlias("jellyfinUserId", "userId", "UserId") @field:NotBlank val jellyfinUserId: String, + @JsonProperty("is_viewed") + @JsonAlias("isViewed", "played", "Played", "IsPlayed") val isViewed: Boolean = false, + @JsonProperty("last_played_at") + @JsonAlias("lastPlayedAt", "LastPlayedDate") val lastPlayedAt: OffsetDateTime? = null, ) { - fun toCommand(): JellyfinPluginUserState = - JellyfinPluginUserState( + fun toCommand(): PushedJellyfinUserState = + PushedJellyfinUserState( jellyfinUserId = jellyfinUserId, isViewed = isViewed, lastPlayedAt = lastPlayedAt, ) } + +private fun parseContentType(value: String?): ContentType = + when (value?.trim()?.lowercase()) { + null, "", "movie", "film" -> ContentType.FILM + "series" -> ContentType.SERIES + "episode" -> ContentType.EPISODE + "other" -> ContentType.OTHER + else -> throw DomainException("Unsupported Jellyfin content type: $value") + } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt index e8081b0..f024ae5 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt @@ -1,5 +1,6 @@ package com.project.movienight.application.ports.input +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncSummary import java.time.OffsetDateTime @@ -19,28 +20,33 @@ data class HandleJellyfinEventCommand( ) interface JellyfinSyncUseCase { - fun syncNow(): JellyfinSyncSummary - - fun syncFromPlugin(command: JellyfinPluginSyncCommand): JellyfinSyncSummary + fun sync(command: PushJellyfinCatalogCommand): JellyfinSyncSummary fun getSyncStates(): List } -data class JellyfinPluginSyncCommand( - val items: List, +data class PushJellyfinCatalogCommand( + val items: List, ) -data class JellyfinPluginSyncItem( +data class PushedJellyfinItem( val jellyfinItemId: String, + val jellyfinLibraryId: String?, val title: String, val description: String?, - val year: Int?, + 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 imdbId: String?, - val userStates: List, + val userStates: List, ) -data class JellyfinPluginUserState( +data class PushedJellyfinUserState( val jellyfinUserId: String, val isViewed: Boolean, val lastPlayedAt: OffsetDateTime?, diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt deleted file mode 100644 index 0e48699..0000000 --- a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.project.movienight.application.ports.output - -import com.project.movienight.domain.model.ContentType - -interface JellyfinCatalogPort { - fun fetchUsers(): List - - fun fetchLibraryItems(userId: String): List -} - -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, -) 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 dbcbe6f..7b04f7f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -1,22 +1,17 @@ package com.project.movienight.application.services -import com.project.movienight.application.ports.input.JellyfinPluginSyncCommand import com.project.movienight.application.ports.input.JellyfinSyncUseCase +import com.project.movienight.application.ports.input.PushJellyfinCatalogCommand import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator -import com.project.movienight.application.ports.output.JellyfinCatalogPort -import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort -import com.project.movienight.config.JellyfinIntegrationProperties -import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncSummary -import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import java.time.Duration import java.time.Instant @@ -25,8 +20,6 @@ import java.util.UUID @Service class JellyfinSyncService( - private val properties: JellyfinIntegrationProperties, - private val jellyfinCatalog: JellyfinCatalogPort, private val userRepository: UserRepositoryPort, private val filmRepository: FilmRepositoryPort, private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, @@ -34,31 +27,19 @@ class JellyfinSyncService( private val idGenerator: IdGenerator, private val businessMetricsService: BusinessMetricsPort, ) : JellyfinSyncUseCase { - @Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}") - fun scheduledSync() { - if (properties.enabled) { - syncNow() - } - } - - override fun syncNow(): JellyfinSyncSummary { - if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) { - return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0) - } - - return try { - runSync() + override fun sync(command: PushJellyfinCatalogCommand): JellyfinSyncSummary = + try { + syncPushedCatalog(command) } catch ( @Suppress("TooGenericExceptionCaught") ex: RuntimeException, ) { businessMetricsService.recordJellyfinSyncFailure() throw ex } - } override fun getSyncStates(): List = syncStateRepository.findAll() - override fun syncFromPlugin(command: JellyfinPluginSyncCommand): JellyfinSyncSummary { + private fun syncPushedCatalog(command: PushJellyfinCatalogCommand): JellyfinSyncSummary { val startedAt = Instant.now() val localUsersByJellyfinId = userRepository @@ -71,23 +52,34 @@ class JellyfinSyncService( command.items.forEach { item -> val savedFilm = - upsertFilm( - JellyfinLibraryItemSnapshot( - jellyfinItemId = item.jellyfinItemId, - title = item.title, - description = item.description.orEmpty(), - contentType = ContentType.FILM, - releaseYear = item.year, - genres = item.genres, - cast = emptyList(), - directors = emptyList(), - platformRating = null, - imdbRating = null, - externalUrl = item.imdbId?.let { imdbUrl(it) }, - jellyfinLibraryId = null, - isPlayed = false, - ), - ) + filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( + title = item.title, + description = item.description.orEmpty(), + contentType = item.contentType, + releaseYear = item.releaseYear, + genres = item.genres, + cast = item.cast, + directors = item.directors, + imdbRating = item.imdbRating, + platformRating = item.platformRating, + externalUrl = item.externalUrl ?: item.imdbId?.let { imdbUrl(it) }, + jellyfinItemId = item.jellyfinItemId, + jellyfinLibraryId = item.jellyfinLibraryId, + ) ?: Film( + id = idGenerator.generateId(), + title = item.title, + description = item.description.orEmpty(), + contentType = item.contentType, + releaseYear = item.releaseYear, + genres = item.genres, + cast = item.cast, + directors = item.directors, + imdbRating = item.imdbRating, + platformRating = item.platformRating, + externalUrl = item.externalUrl ?: item.imdbId?.let { imdbUrl(it) }, + jellyfinItemId = item.jellyfinItemId, + jellyfinLibraryId = item.jellyfinLibraryId, + ).let { filmRepository.save(it) } item.userStates.forEach { state -> val localUser = localUsersByJellyfinId[state.jellyfinUserId] @@ -131,106 +123,6 @@ class JellyfinSyncService( return summary } - private fun runSync(): JellyfinSyncSummary { - val startedAt = Instant.now() - val remoteUsers = jellyfinCatalog.fetchUsers() - val localUsersByJellyfinId = - userRepository - .findAll() - .mapNotNull { user -> - user.jellyfinUserId?.let { it to user } - }.toMap() - - var syncedUsers = 0 - var skippedUsers = 0 - var syncedItems = 0 - - remoteUsers.forEach { remoteUser -> - val localUser = localUsersByJellyfinId[remoteUser.id] - if (localUser == null) { - skippedUsers += 1 - return@forEach - } - - val items = jellyfinCatalog.fetchLibraryItems(remoteUser.id) - items.forEach { item -> - syncItem(localUser.id, item) - syncedItems += 1 - } - - val now = LocalDateTime.now() - syncStateRepository.save( - JellyfinSyncState( - userId = localUser.id, - lastSyncedAt = now, - lastSuccessfulSyncAt = now, - lastError = null, - syncedItemCount = items.size, - ), - ) - syncedUsers += 1 - } - - val summary = - JellyfinSyncSummary( - syncedUsers = syncedUsers, - skippedUsers = skippedUsers, - syncedItems = syncedItems, - durationMs = Duration.between(startedAt, Instant.now()).toMillis(), - ) - businessMetricsService.recordJellyfinSync(summary) - return summary - } - - private fun syncItem( - userId: UUID, - item: JellyfinLibraryItemSnapshot, - ) { - val savedFilm = upsertFilm(item) - - if (item.isPlayed) { - markFilmViewed( - userId = userId, - filmId = savedFilm.id, - watchedAt = LocalDateTime.now(), - ) - } - } - - private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film { - val film = - filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( - title = item.title, - description = item.description, - contentType = item.contentType, - releaseYear = item.releaseYear, - genres = item.genres, - cast = item.cast, - directors = item.directors, - imdbRating = item.imdbRating, - platformRating = item.platformRating, - externalUrl = item.externalUrl, - jellyfinItemId = item.jellyfinItemId, - jellyfinLibraryId = item.jellyfinLibraryId, - ) ?: Film( - id = idGenerator.generateId(), - title = item.title, - description = item.description, - contentType = item.contentType, - releaseYear = item.releaseYear, - genres = item.genres, - cast = item.cast, - directors = item.directors, - imdbRating = item.imdbRating, - platformRating = item.platformRating, - externalUrl = item.externalUrl, - jellyfinItemId = item.jellyfinItemId, - jellyfinLibraryId = item.jellyfinLibraryId, - ) - - return filmRepository.save(film) - } - private fun markFilmViewed( userId: UUID, filmId: UUID, diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt index efb1f22..a990700 100644 --- a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -5,10 +5,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "integrations.jellyfin") data class JellyfinIntegrationProperties( val enabled: Boolean = false, - val baseUrl: String = "", - val webUrl: String = baseUrl, - val apiKey: String = "", - val syncIntervalMs: Long = 1_800_000, - val requestTimeoutMs: Long = 20_000, + val webUrl: String = "", val pluginToken: String = "", ) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index bf2cba0..626a3f5 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -98,12 +98,8 @@ info: integrations: jellyfin: enabled: ${JELLYFIN_SYNC_ENABLED:false} - base-url: ${JELLYFIN_BASE_URL:} - web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}} - api-key: ${JELLYFIN_API_KEY:} + web-url: ${JELLYFIN_WEB_URL:} plugin-token: ${JELLYFIN_PLUGIN_TOKEN:} - sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000} - request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000} services: user: diff --git a/src/test/kotlin/com/project/movienight/application/services/JellyfinSyncServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/JellyfinSyncServiceTest.kt new file mode 100644 index 0000000..43ae821 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/JellyfinSyncServiceTest.kt @@ -0,0 +1,119 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.PushJellyfinCatalogCommand +import com.project.movienight.application.ports.input.PushedJellyfinItem +import com.project.movienight.application.ports.input.PushedJellyfinUserState +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +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.UserRepositoryPort +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry +import com.project.movienight.domain.model.JellyfinSyncState +import com.project.movienight.domain.model.User +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.OffsetDateTime +import java.util.UUID + +class JellyfinSyncServiceTest { + private lateinit var userRepository: UserRepositoryPort + private lateinit var filmRepository: FilmRepositoryPort + private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort + private lateinit var syncStateRepository: JellyfinSyncStateRepositoryPort + private lateinit var idGenerator: IdGenerator + private lateinit var businessMetricsService: BusinessMetricsPort + private lateinit var service: JellyfinSyncService + + @BeforeEach + fun setup() { + userRepository = mockk() + filmRepository = mockk() + filmLibraryEntryRepository = mockk() + syncStateRepository = mockk() + idGenerator = mockk() + businessMetricsService = mockk(relaxed = true) + service = + JellyfinSyncService( + userRepository, + filmRepository, + filmLibraryEntryRepository, + syncStateRepository, + idGenerator, + businessMetricsService, + ) + } + + @Test + fun `sync should upsert pushed Jellyfin item and mark viewed state`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val entryId = UUID.randomUUID() + val watchedAt = OffsetDateTime.parse("2026-05-22T12:00:00Z") + val savedFilmSlot = slot() + val savedEntrySlot = slot() + val savedStateSlot = slot() + + every { userRepository.findAll() } returns + listOf(User(userId, "Jellyfin User", "jellyfin@example.com", jellyfinUserId = "jf-user")) + every { filmRepository.findByJellyfinItemId("jf-item") } returns null + every { idGenerator.generateId() } returnsMany listOf(filmId, entryId) + every { filmRepository.save(capture(savedFilmSlot)) } answers { savedFilmSlot.captured } + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null + every { filmLibraryEntryRepository.save(capture(savedEntrySlot)) } answers { savedEntrySlot.captured } + every { syncStateRepository.save(capture(savedStateSlot)) } answers { savedStateSlot.captured } + + val summary = + service.sync( + PushJellyfinCatalogCommand( + items = + listOf( + PushedJellyfinItem( + jellyfinItemId = "jf-item", + jellyfinLibraryId = "library-1", + title = "Pushed Movie", + description = "From Jellyfin", + contentType = ContentType.FILM, + releaseYear = 2026, + genres = listOf("Drama"), + cast = listOf("Actor One"), + directors = listOf("Director One"), + platformRating = 8.1, + imdbRating = 7.9, + externalUrl = null, + imdbId = "tt1234567", + userStates = + listOf( + PushedJellyfinUserState( + jellyfinUserId = "jf-user", + isViewed = true, + lastPlayedAt = watchedAt, + ), + ), + ), + ), + ), + ) + + assertEquals(1, summary.syncedUsers) + assertEquals(0, summary.skippedUsers) + assertEquals(1, summary.syncedItems) + assertEquals("Pushed Movie", savedFilmSlot.captured.title) + assertEquals("library-1", savedFilmSlot.captured.jellyfinLibraryId) + assertEquals("https://www.imdb.com/title/tt1234567/", savedFilmSlot.captured.externalUrl) + assertEquals(userId, savedEntrySlot.captured.userId) + assertEquals(filmId, savedEntrySlot.captured.filmId) + assertEquals(watchedAt.toLocalDateTime(), savedEntrySlot.captured.watchedAt) + assertEquals(1, savedStateSlot.captured.syncedItemCount) + + verify(exactly = 1) { businessMetricsService.recordJellyfinSync(summary) } + } +}