fix(jellyfin): fixes in jellyfin integration

This commit is contained in:
ITQ
2026-05-22 18:04:28 +03:00
parent 5b9b4ce03a
commit 92add56cf7
16 changed files with 785 additions and 67 deletions
@@ -24,9 +24,7 @@ class SecurityConfiguration(
.requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health")
.permitAll()
.requestMatchers(
"/api/integrations/jellyfin/events",
"/api/integrations/jellyfin/sync",
"/api/integrations/jellyfin/sync-state",
"/api/integrations/jellyfin/**",
).permitAll()
.requestMatchers("/api/users/me")
.authenticated()
@@ -3,7 +3,6 @@ package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
import com.project.movienight.application.ports.input.HandleJellyfinEventCommand
import com.project.movienight.application.ports.input.JellyfinEventUseCase
import com.project.movienight.config.JellyfinIntegrationProperties
import jakarta.validation.Valid
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
@@ -13,13 +12,12 @@ import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
@RestController
@RequestMapping("/api/integrations/jellyfin")
class JellyfinEventsController(
private val jellyfinEventUseCase: JellyfinEventUseCase,
private val properties: JellyfinIntegrationProperties,
private val authenticator: JellyfinPluginAuthenticator,
) {
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
@@ -29,15 +27,7 @@ class JellyfinEventsController(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@Valid @RequestBody request: JellyfinEventRequest,
) {
if (!properties.enabled) {
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
}
if (properties.pluginToken.isNotBlank()) {
if (token == null || token != properties.pluginToken) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
}
}
authenticator.authenticate(token)
log.debug(
"Received Jellyfin event {} for user {} item {}",
@@ -0,0 +1,21 @@
package com.project.movienight.adapters.web
import com.project.movienight.config.JellyfinIntegrationProperties
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException
@Component
class JellyfinPluginAuthenticator(
private val properties: JellyfinIntegrationProperties,
) {
fun authenticate(token: String?) {
if (!properties.enabled) {
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
}
if (properties.pluginToken.isNotBlank() && token != properties.pluginToken) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
}
}
}
@@ -0,0 +1,227 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
import com.project.movienight.application.ports.input.FilmLibraryUseCase
import com.project.movienight.application.ports.input.FilmRatingUseCase
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.RateFilmCommand
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.UserPreferencesUseCase
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.RecommendationStyle
import com.project.movienight.domain.model.User
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.OffsetDateTime
import java.util.Locale
import java.util.UUID
@RestController
@RequestMapping("/api/integrations/jellyfin")
class JellyfinPluginController(
private val authenticator: JellyfinPluginAuthenticator,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val idGenerator: IdGenerator,
private val getRecommendationsUseCase: GetRecommendationsUseCase,
private val filmRatingUseCase: FilmRatingUseCase,
private val filmLibraryUseCase: FilmLibraryUseCase,
private val userPreferencesUseCase: UserPreferencesUseCase,
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
private val jellyfinProperties: JellyfinIntegrationProperties,
) {
@GetMapping("/users/{jellyfinUserId}/recommendations")
fun recommend(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@RequestParam(required = false) contentType: String?,
@RequestParam(required = false) mood: String?,
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
@RequestParam(required = false, defaultValue = "10") limit: Int,
): List<RecommendationResponse> {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return getRecommendationsUseCase
.recommend(
RecommendationQuery(
userId = user.id,
contentType = parseOptionalContentType(contentType),
mood = mood,
libraryOnly = libraryOnly,
limit = limit,
),
).map { recommendation ->
RecommendationResponse.fromDomain(
recommendation = recommendation,
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
)
}
}
@PostMapping("/users/{jellyfinUserId}/ratings/items/{jellyfinItemId}")
fun rate(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@PathVariable jellyfinItemId: String,
@Valid @RequestBody request: RateFilmRequest,
): FilmRatingResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
val film = resolveFilm(jellyfinItemId)
return FilmRatingResponse.fromDomain(
filmRatingUseCase.rate(
RateFilmCommand(
userId = user.id,
filmId = film.id,
score = request.score,
note = request.note,
),
),
)
}
@GetMapping("/users/{jellyfinUserId}/ratings")
fun ratings(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
): List<FilmRatingResponse> {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return filmRatingUseCase.getRatings(user.id).map { FilmRatingResponse.fromDomain(it) }
}
@PostMapping("/users/{jellyfinUserId}/library/items/{jellyfinItemId}/viewed")
fun markViewed(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@PathVariable jellyfinItemId: String,
@RequestBody(required = false) request: JellyfinViewedRequest?,
): FilmLibraryEntryResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
val film = resolveFilm(jellyfinItemId)
return FilmLibraryEntryResponse.fromDomain(
filmLibraryUseCase.markViewed(
MarkFilmViewedCommand(
userId = user.id,
filmId = film.id,
watchedAt = request?.watchedAt?.toLocalDateTime(),
),
),
)
}
@GetMapping("/users/{jellyfinUserId}/preferences")
fun preferences(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
): UserPreferencesResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return userPreferencesUseCase.get(user.id)?.let { UserPreferencesResponse.fromDomain(it) }
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User preferences not found")
}
@PostMapping("/users/{jellyfinUserId}/recommendation-onboarding")
fun completeOnboarding(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@RequestBody request: RecommendationOnboardingRequest,
): RecommendationOnboardingResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return RecommendationOnboardingResponse.fromApplication(
completeRecommendationOnboardingUseCase.complete(
CompleteRecommendationOnboardingCommand(
userId = user.id,
weightedGenres = request.weightedGenres,
plotTypes = request.plotTypes,
eras = request.eras,
castAndDirectors = request.castAndDirectors,
moods = request.moods,
contentTypes = request.contentTypes.mapNotNull { runCatching { parseContentType(it) }.getOrNull() },
likedFilmIds = request.likedFilmIds,
dislikedFilmIds = request.dislikedFilmIds,
libraryFilmIds = request.libraryFilmIds,
watchedFilmIds = request.watchedFilmIds,
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
),
),
)
}
private fun resolveOrCreateUser(jellyfinUserId: String): User =
normalizeJellyfinId(jellyfinUserId).let { normalizedId ->
userRepository.findByJellyfinUserId(normalizedId)
?: userRepository.save(
User(
id = idGenerator.generateId(),
name = "Jellyfin User",
email = syntheticJellyfinEmail(normalizedId),
jellyfinUserId = normalizedId,
),
)
}
private fun resolveFilm(jellyfinItemId: String): Film =
normalizeJellyfinId(jellyfinItemId).let { normalizedId ->
filmRepository.findByJellyfinItemId(normalizedId)
?: throw EntityNotFoundException(entity = "Jellyfin item", id = jellyfinItemId)
}
private fun buildWatchUrl(jellyfinItemId: String?): String? {
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
return null
}
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
return "$baseUrl/web/#/details?id=$encodedItemId"
}
private fun parseRecommendationStyle(value: String): RecommendationStyle =
runCatching { RecommendationStyle.valueOf(value.uppercase()) }
.getOrDefault(RecommendationStyle.BALANCED)
private fun normalizeJellyfinId(value: String): String =
runCatching { UUID.fromString(value).toString().replace("-", "") }
.getOrDefault(value)
private fun syntheticJellyfinEmail(jellyfinUserId: String): String {
val safeId =
jellyfinUserId
.lowercase(Locale.getDefault())
.replace(Regex("[^a-z0-9._%+-]"), "-")
.take(240)
return "jellyfin-$safeId@movienight.local"
}
}
data class JellyfinViewedRequest(
val watchedAt: OffsetDateTime? = null,
)
@@ -1,10 +1,18 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.JellyfinSyncRequest
import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand
import com.project.movienight.application.ports.input.JellyfinSyncItemCommand
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
import com.project.movienight.application.ports.input.JellyfinSyncUserCommand
import com.project.movienight.application.ports.input.JellyfinSyncUserStateCommand
import com.project.movienight.domain.model.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@@ -12,10 +20,61 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/integrations/jellyfin")
class JellyfinSyncController(
private val jellyfinSyncUseCase: JellyfinSyncUseCase,
private val authenticator: JellyfinPluginAuthenticator,
) {
@PostMapping("/sync")
fun syncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow()
fun syncNow(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@Valid @RequestBody(required = false) request: JellyfinSyncRequest?,
): JellyfinSyncSummary {
authenticator.authenticate(token)
return if (request == null) {
jellyfinSyncUseCase.syncNow()
} else {
jellyfinSyncUseCase.ingest(request.toCommand())
}
}
@GetMapping("/sync-state")
fun syncState(): List<JellyfinSyncState> = jellyfinSyncUseCase.getSyncStates()
fun syncState(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
): List<JellyfinSyncState> {
authenticator.authenticate(token)
return jellyfinSyncUseCase.getSyncStates()
}
private fun JellyfinSyncRequest.toCommand(): IngestJellyfinSyncCommand =
IngestJellyfinSyncCommand(
users =
users.map { user ->
JellyfinSyncUserCommand(
jellyfinUserId = user.jellyfinUserId,
name = user.name,
)
},
items =
items.map { item ->
JellyfinSyncItemCommand(
jellyfinItemId = item.jellyfinItemId,
title = item.title,
originalTitle = item.originalTitle,
description = item.description,
year = item.year,
genres = item.genres,
imdbId = item.imdbId,
tmdbId = item.tmdbId,
jellyfinLibraryId = item.jellyfinLibraryId,
userStates =
item.userStates.map { state ->
JellyfinSyncUserStateCommand(
jellyfinUserId = state.jellyfinUserId,
isViewed = state.isViewed,
playCount = state.playCount,
lastPlayedAt = state.lastPlayedAt,
userRating = state.userRating,
)
},
)
},
)
}
@@ -0,0 +1,43 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import java.time.OffsetDateTime
data class JellyfinSyncRequest(
@field:Valid
val users: List<JellyfinSyncUserRequest> = emptyList(),
@field:Valid
val items: List<JellyfinSyncItemRequest> = emptyList(),
)
data class JellyfinSyncUserRequest(
@field:NotBlank
val jellyfinUserId: String,
val name: String? = null,
)
data class JellyfinSyncItemRequest(
@field:NotBlank
val jellyfinItemId: String,
@field:NotBlank
val title: String,
val originalTitle: String? = null,
val description: String? = null,
val year: Int? = null,
val genres: List<String> = emptyList(),
val imdbId: String? = null,
val tmdbId: String? = null,
val jellyfinLibraryId: String? = null,
@field:Valid
val userStates: List<JellyfinSyncUserStateRequest> = emptyList(),
)
data class JellyfinSyncUserStateRequest(
@field:NotBlank
val jellyfinUserId: String,
val isViewed: Boolean = false,
val playCount: Int = 0,
val lastPlayedAt: OffsetDateTime? = null,
val userRating: Double? = null,
)
@@ -21,5 +21,38 @@ data class HandleJellyfinEventCommand(
interface JellyfinSyncUseCase {
fun syncNow(): JellyfinSyncSummary
fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary
fun getSyncStates(): List<JellyfinSyncState>
}
data class IngestJellyfinSyncCommand(
val users: List<JellyfinSyncUserCommand> = emptyList(),
val items: List<JellyfinSyncItemCommand> = emptyList(),
)
data class JellyfinSyncUserCommand(
val jellyfinUserId: String,
val name: String?,
)
data class JellyfinSyncItemCommand(
val jellyfinItemId: String,
val title: String,
val originalTitle: String?,
val description: String?,
val year: Int?,
val genres: List<String>,
val imdbId: String?,
val tmdbId: String?,
val jellyfinLibraryId: String?,
val userStates: List<JellyfinSyncUserStateCommand>,
)
data class JellyfinSyncUserStateCommand(
val jellyfinUserId: String,
val isViewed: Boolean,
val playCount: Int,
val lastPlayedAt: OffsetDateTime?,
val userRating: Double?,
)
@@ -12,6 +12,7 @@ import com.project.movienight.application.ports.output.JellyfinEventStorePort
import com.project.movienight.application.ports.output.UserRepositoryPort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
@Service
class JellyfinEventService(
@@ -26,6 +27,8 @@ class JellyfinEventService(
@Transactional
override fun handle(command: HandleJellyfinEventCommand) {
val jellyfinUserId = normalizeJellyfinId(command.jellyfinUserId)
val jellyfinItemId = normalizeJellyfinId(command.itemId)
val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) }
val inserted =
jellyfinEventStore.save(
@@ -34,8 +37,8 @@ class JellyfinEventService(
serverId = command.serverId,
eventType = command.eventType,
occurredAt = command.occurredAt,
jellyfinUserId = command.jellyfinUserId,
jellyfinItemId = command.itemId,
jellyfinUserId = jellyfinUserId,
jellyfinItemId = jellyfinItemId,
payload = payloadJson,
),
)
@@ -45,13 +48,13 @@ class JellyfinEventService(
try {
if (playbackEventTypes.contains(command.eventType)) {
val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId)
val localUser = userRepository.findByJellyfinUserId(jellyfinUserId)
if (localUser == null) {
businessMetricsService.recordJellyfinUnmappedUser()
return
}
val film = filmRepository.findByJellyfinItemId(command.itemId)
val film = filmRepository.findByJellyfinItemId(jellyfinItemId)
if (film == null) {
businessMetricsService.recordBackendWriteFailure()
return
@@ -72,4 +75,8 @@ class JellyfinEventService(
throw ex
}
}
private fun normalizeJellyfinId(value: String): String =
runCatching { UUID.fromString(value).toString().replace("-", "") }
.getOrDefault(value)
}
@@ -1,5 +1,6 @@
package com.project.movienight.application.services
import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
@@ -10,15 +11,18 @@ import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapsh
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 com.project.movienight.domain.model.User
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.time.Duration
import java.time.Instant
import java.time.LocalDateTime
import java.util.Locale
import java.util.UUID
@Service
@@ -56,6 +60,21 @@ class JellyfinSyncService(
override fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
override fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary {
if (!properties.enabled) {
return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0)
}
return try {
ingestPluginSync(command)
} catch (
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
) {
businessMetricsService.recordJellyfinSyncFailure()
throw ex
}
}
private fun runSync(): JellyfinSyncSummary {
val startedAt = Instant.now()
val remoteUsers = jellyfinCatalog.fetchUsers()
@@ -63,7 +82,7 @@ class JellyfinSyncService(
userRepository
.findAll()
.mapNotNull { user ->
user.jellyfinUserId?.let { it to user }
user.jellyfinUserId?.let { normalizeJellyfinId(it) to user }
}.toMap()
var syncedUsers = 0
@@ -71,7 +90,7 @@ class JellyfinSyncService(
var syncedItems = 0
remoteUsers.forEach { remoteUser ->
val localUser = localUsersByJellyfinId[remoteUser.id]
val localUser = localUsersByJellyfinId[normalizeJellyfinId(remoteUser.id)]
if (localUser == null) {
skippedUsers += 1
return@forEach
@@ -123,34 +142,39 @@ class JellyfinSyncService(
}
private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film {
val normalizedItem =
item.copy(
jellyfinItemId = normalizeJellyfinId(item.jellyfinItemId),
jellyfinLibraryId = item.jellyfinLibraryId?.let(::normalizeJellyfinId),
)
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,
filmRepository.findByJellyfinItemId(normalizedItem.jellyfinItemId)?.copy(
title = normalizedItem.title,
description = normalizedItem.description,
contentType = normalizedItem.contentType,
releaseYear = normalizedItem.releaseYear,
genres = normalizedItem.genres,
cast = normalizedItem.cast,
directors = normalizedItem.directors,
imdbRating = normalizedItem.imdbRating,
platformRating = normalizedItem.platformRating,
externalUrl = normalizedItem.externalUrl,
jellyfinItemId = normalizedItem.jellyfinItemId,
jellyfinLibraryId = normalizedItem.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,
title = normalizedItem.title,
description = normalizedItem.description,
contentType = normalizedItem.contentType,
releaseYear = normalizedItem.releaseYear,
genres = normalizedItem.genres,
cast = normalizedItem.cast,
directors = normalizedItem.directors,
imdbRating = normalizedItem.imdbRating,
platformRating = normalizedItem.platformRating,
externalUrl = normalizedItem.externalUrl,
jellyfinItemId = normalizedItem.jellyfinItemId,
jellyfinLibraryId = normalizedItem.jellyfinLibraryId,
)
return filmRepository.save(film)
@@ -176,4 +200,114 @@ class JellyfinSyncService(
),
)
}
private fun ingestPluginSync(command: IngestJellyfinSyncCommand): JellyfinSyncSummary {
val startedAt = Instant.now()
upsertPluginUsers(command)
val localUsersByJellyfinId =
userRepository
.findAll()
.mapNotNull { user -> user.jellyfinUserId?.let { normalizeJellyfinId(it) to user } }
.toMap()
val skippedUserIds = mutableSetOf<String>()
val syncedCountsByUserId = mutableMapOf<UUID, Int>()
command.items.forEach { item ->
val savedFilm =
upsertFilm(
JellyfinLibraryItemSnapshot(
jellyfinItemId = item.jellyfinItemId,
title = item.title,
description = item.description ?: item.originalTitle ?: "",
contentType = ContentType.FILM,
releaseYear = item.year,
genres = item.genres,
cast = emptyList(),
directors = emptyList(),
platformRating = null,
imdbRating = null,
externalUrl = item.imdbId?.let { "https://www.imdb.com/title/$it/" },
jellyfinLibraryId = item.jellyfinLibraryId,
isPlayed = false,
),
)
item.userStates.forEach { state ->
val stateUserId = normalizeJellyfinId(state.jellyfinUserId)
val localUser = localUsersByJellyfinId[stateUserId]
if (localUser == null) {
skippedUserIds += stateUserId
return@forEach
}
syncedCountsByUserId[localUser.id] = syncedCountsByUserId.getOrDefault(localUser.id, 0) + 1
if (state.isViewed || state.playCount > 0) {
markFilmViewed(
userId = localUser.id,
filmId = savedFilm.id,
watchedAt = state.lastPlayedAt?.toLocalDateTime() ?: LocalDateTime.now(),
)
}
}
}
val now = LocalDateTime.now()
syncedCountsByUserId.forEach { (userId, itemCount) ->
syncStateRepository.save(
JellyfinSyncState(
userId = userId,
lastSyncedAt = now,
lastSuccessfulSyncAt = now,
lastError = null,
syncedItemCount = itemCount,
),
)
}
val summary =
JellyfinSyncSummary(
syncedUsers = syncedCountsByUserId.size,
skippedUsers = skippedUserIds.size,
syncedItems = command.items.size,
durationMs = Duration.between(startedAt, Instant.now()).toMillis(),
)
businessMetricsService.recordJellyfinSync(summary)
return summary
}
private fun upsertPluginUsers(command: IngestJellyfinSyncCommand) {
command.users.forEach { remoteUser ->
val jellyfinUserId =
remoteUser.jellyfinUserId
.takeIf { it.isNotBlank() }
?.let(::normalizeJellyfinId)
?: return@forEach
if (userRepository.findByJellyfinUserId(jellyfinUserId) != null) {
return@forEach
}
userRepository.save(
User(
id = idGenerator.generateId(),
name = remoteUser.name?.takeIf { it.isNotBlank() } ?: "Jellyfin User",
email = syntheticJellyfinEmail(jellyfinUserId),
jellyfinUserId = jellyfinUserId,
),
)
}
}
private fun syntheticJellyfinEmail(jellyfinUserId: String): String {
val safeId =
jellyfinUserId
.lowercase(Locale.getDefault())
.replace(Regex("[^a-z0-9._%+-]"), "-")
.take(240)
return "jellyfin-$safeId@movienight.local"
}
private fun normalizeJellyfinId(value: String): String =
runCatching { UUID.fromString(value).toString().replace("-", "") }
.getOrDefault(value)
}