fix(jellyfin): fixes in jellyfin integration
This commit is contained in:
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+43
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user