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.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication @SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan("com.project.movienight.config") @ConfigurationPropertiesScan("com.project.movienight.config")
class MovieNightApplication 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, private val properties: JellyfinIntegrationProperties,
) { ) {
@PostMapping("/sync") @PostMapping("/sync")
fun syncFromPlugin( fun pushSync(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@Valid @RequestBody request: JellyfinSyncRequest, @Valid @RequestBody request: JellyfinSyncRequest,
): JellyfinSyncSummary { ): JellyfinSyncSummary {
requireJellyfinIntegrationEnabled(properties) requireJellyfinIntegrationEnabled(properties)
requireJellyfinPluginToken(properties, token) requireJellyfinPluginToken(properties, token)
return jellyfinSyncUseCase.syncFromPlugin(request.toCommand()) return jellyfinSyncUseCase.sync(request.toCommand())
} }
@PostMapping("/pull-sync")
fun pullSyncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow()
@GetMapping("/sync-state") @GetMapping("/sync-state")
fun syncState( fun syncState(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@@ -1,8 +1,12 @@
package com.project.movienight.adapters.web.dto.request package com.project.movienight.adapters.web.dto.request
import com.project.movienight.application.ports.input.JellyfinPluginSyncCommand import com.fasterxml.jackson.annotation.JsonAlias
import com.project.movienight.application.ports.input.JellyfinPluginSyncItem import com.fasterxml.jackson.annotation.JsonProperty
import com.project.movienight.application.ports.input.JellyfinPluginUserState 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.Valid
import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotBlank
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -11,45 +15,96 @@ data class JellyfinSyncRequest(
@field:Valid @field:Valid
val items: List<JellyfinSyncItemRequest> = emptyList(), val items: List<JellyfinSyncItemRequest> = emptyList(),
) { ) {
fun toCommand(): JellyfinPluginSyncCommand = fun toCommand(): PushJellyfinCatalogCommand =
JellyfinPluginSyncCommand( PushJellyfinCatalogCommand(
items = items.map { it.toCommand() }, items = items.map { it.toCommand() },
) )
} }
data class JellyfinSyncItemRequest( data class JellyfinSyncItemRequest(
@JsonProperty("jellyfin_item_id")
@JsonAlias("jellyfinItemId", "itemId", "Id")
@field:NotBlank @field:NotBlank
val jellyfinItemId: String, val jellyfinItemId: String,
val title: String?, @JsonProperty("jellyfin_library_id")
val description: String?, @JsonAlias("jellyfinLibraryId", "libraryId", "ParentId")
val year: Int?, 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 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 @field:Valid
val userStates: List<JellyfinUserStateRequest> = emptyList(), val userStates: List<JellyfinUserStateRequest> = emptyList(),
) { ) {
fun toCommand(): JellyfinPluginSyncItem = fun toCommand(): PushedJellyfinItem =
JellyfinPluginSyncItem( PushedJellyfinItem(
jellyfinItemId = jellyfinItemId, jellyfinItemId = jellyfinItemId,
jellyfinLibraryId = jellyfinLibraryId,
title = title?.takeIf { it.isNotBlank() } ?: jellyfinItemId, title = title?.takeIf { it.isNotBlank() } ?: jellyfinItemId,
description = description, description = description,
year = year, contentType = parseContentType(contentType),
releaseYear = releaseYear ?: year,
genres = genres, genres = genres,
cast = cast,
directors = directors,
platformRating = platformRating,
imdbRating = imdbRating,
externalUrl = externalUrl,
imdbId = imdbId, imdbId = imdbId,
userStates = userStates.map { it.toCommand() }, userStates = userStates.map { it.toCommand() },
) )
} }
data class JellyfinUserStateRequest( data class JellyfinUserStateRequest(
@JsonProperty("jellyfin_user_id")
@JsonAlias("jellyfinUserId", "userId", "UserId")
@field:NotBlank @field:NotBlank
val jellyfinUserId: String, val jellyfinUserId: String,
@JsonProperty("is_viewed")
@JsonAlias("isViewed", "played", "Played", "IsPlayed")
val isViewed: Boolean = false, val isViewed: Boolean = false,
@JsonProperty("last_played_at")
@JsonAlias("lastPlayedAt", "LastPlayedDate")
val lastPlayedAt: OffsetDateTime? = null, val lastPlayedAt: OffsetDateTime? = null,
) { ) {
fun toCommand(): JellyfinPluginUserState = fun toCommand(): PushedJellyfinUserState =
JellyfinPluginUserState( PushedJellyfinUserState(
jellyfinUserId = jellyfinUserId, jellyfinUserId = jellyfinUserId,
isViewed = isViewed, isViewed = isViewed,
lastPlayedAt = lastPlayedAt, 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 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.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary import com.project.movienight.domain.model.JellyfinSyncSummary
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -19,28 +20,33 @@ data class HandleJellyfinEventCommand(
) )
interface JellyfinSyncUseCase { interface JellyfinSyncUseCase {
fun syncNow(): JellyfinSyncSummary fun sync(command: PushJellyfinCatalogCommand): JellyfinSyncSummary
fun syncFromPlugin(command: JellyfinPluginSyncCommand): JellyfinSyncSummary
fun getSyncStates(): List<JellyfinSyncState> fun getSyncStates(): List<JellyfinSyncState>
} }
data class JellyfinPluginSyncCommand( data class PushJellyfinCatalogCommand(
val items: List<JellyfinPluginSyncItem>, val items: List<PushedJellyfinItem>,
) )
data class JellyfinPluginSyncItem( data class PushedJellyfinItem(
val jellyfinItemId: String, val jellyfinItemId: String,
val jellyfinLibraryId: String?,
val title: String, val title: String,
val description: String?, val description: String?,
val year: Int?, val contentType: ContentType,
val releaseYear: Int?,
val genres: List<String>, 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 imdbId: String?,
val userStates: List<JellyfinPluginUserState>, val userStates: List<PushedJellyfinUserState>,
) )
data class JellyfinPluginUserState( data class PushedJellyfinUserState(
val jellyfinUserId: String, val jellyfinUserId: String,
val isViewed: Boolean, val isViewed: Boolean,
val lastPlayedAt: OffsetDateTime?, 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 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.JellyfinSyncUseCase
import com.project.movienight.application.ports.input.PushJellyfinCatalogCommand
import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
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.JellyfinCatalogPort
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
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.domain.model.ContentType
import com.project.movienight.domain.model.Film import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.FilmLibraryEntry
import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary import com.project.movienight.domain.model.JellyfinSyncSummary
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -25,8 +20,6 @@ import java.util.UUID
@Service @Service
class JellyfinSyncService( class JellyfinSyncService(
private val properties: JellyfinIntegrationProperties,
private val jellyfinCatalog: JellyfinCatalogPort,
private val userRepository: UserRepositoryPort, private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort, private val filmRepository: FilmRepositoryPort,
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
@@ -34,31 +27,19 @@ class JellyfinSyncService(
private val idGenerator: IdGenerator, private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsPort, private val businessMetricsService: BusinessMetricsPort,
) : JellyfinSyncUseCase { ) : JellyfinSyncUseCase {
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}") override fun sync(command: PushJellyfinCatalogCommand): JellyfinSyncSummary =
fun scheduledSync() { try {
if (properties.enabled) { syncPushedCatalog(command)
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()
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") ex: RuntimeException, @Suppress("TooGenericExceptionCaught") ex: RuntimeException,
) { ) {
businessMetricsService.recordJellyfinSyncFailure() businessMetricsService.recordJellyfinSyncFailure()
throw ex throw ex
} }
}
override fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll() override fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
override fun syncFromPlugin(command: JellyfinPluginSyncCommand): JellyfinSyncSummary { private fun syncPushedCatalog(command: PushJellyfinCatalogCommand): JellyfinSyncSummary {
val startedAt = Instant.now() val startedAt = Instant.now()
val localUsersByJellyfinId = val localUsersByJellyfinId =
userRepository userRepository
@@ -71,23 +52,34 @@ class JellyfinSyncService(
command.items.forEach { item -> command.items.forEach { item ->
val savedFilm = val savedFilm =
upsertFilm( filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy(
JellyfinLibraryItemSnapshot(
jellyfinItemId = item.jellyfinItemId,
title = item.title, title = item.title,
description = item.description.orEmpty(), description = item.description.orEmpty(),
contentType = ContentType.FILM, contentType = item.contentType,
releaseYear = item.year, releaseYear = item.releaseYear,
genres = item.genres, genres = item.genres,
cast = emptyList(), cast = item.cast,
directors = emptyList(), directors = item.directors,
platformRating = null, imdbRating = item.imdbRating,
imdbRating = null, platformRating = item.platformRating,
externalUrl = item.imdbId?.let { imdbUrl(it) }, externalUrl = item.externalUrl ?: item.imdbId?.let { imdbUrl(it) },
jellyfinLibraryId = null, jellyfinItemId = item.jellyfinItemId,
isPlayed = false, 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 -> item.userStates.forEach { state ->
val localUser = localUsersByJellyfinId[state.jellyfinUserId] val localUser = localUsersByJellyfinId[state.jellyfinUserId]
@@ -131,106 +123,6 @@ class JellyfinSyncService(
return summary 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( private fun markFilmViewed(
userId: UUID, userId: UUID,
filmId: UUID, filmId: UUID,
@@ -5,10 +5,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "integrations.jellyfin") @ConfigurationProperties(prefix = "integrations.jellyfin")
data class JellyfinIntegrationProperties( data class JellyfinIntegrationProperties(
val enabled: Boolean = false, val enabled: Boolean = false,
val baseUrl: String = "", val webUrl: String = "",
val webUrl: String = baseUrl,
val apiKey: String = "",
val syncIntervalMs: Long = 1_800_000,
val requestTimeoutMs: Long = 20_000,
val pluginToken: String = "", val pluginToken: String = "",
) )
+1 -5
View File
@@ -98,12 +98,8 @@ info:
integrations: integrations:
jellyfin: jellyfin:
enabled: ${JELLYFIN_SYNC_ENABLED:false} enabled: ${JELLYFIN_SYNC_ENABLED:false}
base-url: ${JELLYFIN_BASE_URL:} web-url: ${JELLYFIN_WEB_URL:}
web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}}
api-key: ${JELLYFIN_API_KEY:}
plugin-token: ${JELLYFIN_PLUGIN_TOKEN:} plugin-token: ${JELLYFIN_PLUGIN_TOKEN:}
sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000}
request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000}
services: services:
user: 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) }
}
}