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:
@@ -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?,
|
||||||
|
|||||||
+69
-14
@@ -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?,
|
||||||
|
|||||||
-30
@@ -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,
|
|
||||||
)
|
|
||||||
+30
-138
@@ -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 = "",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+119
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user