refactor: перевести Jellyfin на push-модель

Jellyfin-интеграция переведена на модель, где плагин отправляет данные на backend через защищённый sync endpoint.

Удалены backend pull-компоненты: HTTP-клиент Jellyfin, каталоговый output port, scheduled sync и pull-sync endpoint.

Расширен sync payload для полного снимка каталога и пользовательских состояний просмотра, обновлены настройки интеграции.

Добавлен unit-тест push sync, который проверяет создание фильма, отметку просмотра и обновление sync state.
This commit is contained in:
skettiks
2026-05-22 15:13:15 +03:00
parent 8ad2521a9d
commit 9f4537a319
10 changed files with 240 additions and 336 deletions
@@ -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
@@ -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<JellyfinRemoteUser> =
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<JellyfinLibraryItemSnapshot> =
@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<JsonNode> =
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<String> =
takeIf { it.isArray }?.mapNotNull { item ->
item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() }
}
?: emptyList()
private fun JsonNode.peopleByType(vararg types: String): List<String> {
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
}
}
@@ -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?,
@@ -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<JellyfinSyncItemRequest> = 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<String> = emptyList(),
val imdbId: String?,
val cast: List<String> = emptyList(),
val directors: List<String> = 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<JellyfinUserStateRequest> = 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")
}
@@ -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<JellyfinSyncState>
}
data class JellyfinPluginSyncCommand(
val items: List<JellyfinPluginSyncItem>,
data class PushJellyfinCatalogCommand(
val items: List<PushedJellyfinItem>,
)
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<String>,
val cast: List<String>,
val directors: List<String>,
val platformRating: Double?,
val imdbRating: Double?,
val externalUrl: String?,
val imdbId: String?,
val userStates: List<JellyfinPluginUserState>,
val userStates: List<PushedJellyfinUserState>,
)
data class JellyfinPluginUserState(
data class PushedJellyfinUserState(
val jellyfinUserId: String,
val isViewed: Boolean,
val lastPlayedAt: OffsetDateTime?,
@@ -1,30 +0,0 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.ContentType
interface JellyfinCatalogPort {
fun fetchUsers(): List<JellyfinRemoteUser>
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot>
}
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,
)
@@ -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<JellyfinSyncState> = 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,
@@ -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 = "",
)
+1 -5
View File
@@ -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:
@@ -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<Film>()
val savedEntrySlot = slot<FilmLibraryEntry>()
val savedStateSlot = slot<JellyfinSyncState>()
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) }
}
}