integrations(jellyfin): add event ingestion and sync scaffolding + migration
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
package com.project.movienight.adapters.jellyfin
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
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
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class JellyfinApiClient(
|
||||||
|
private val properties: JellyfinIntegrationProperties,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
) {
|
||||||
|
private val httpClient: HttpClient =
|
||||||
|
HttpClient
|
||||||
|
.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
|
||||||
|
import com.project.movienight.application.services.JellyfinEventService
|
||||||
|
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
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.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/integrations/jellyfin")
|
||||||
|
class JellyfinEventsController(
|
||||||
|
private val jellyfinEventService: JellyfinEventService,
|
||||||
|
private val properties: JellyfinIntegrationProperties,
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
|
||||||
|
|
||||||
|
@PostMapping("/events")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
fun receiveEvent(
|
||||||
|
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||||
|
@RequestBody request: JellyfinEventRequest,
|
||||||
|
) {
|
||||||
|
if (properties.pluginToken.isNotBlank()) {
|
||||||
|
if (token == null || token != properties.pluginToken) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
"Received Jellyfin event {} for user {} item {}",
|
||||||
|
request.eventId,
|
||||||
|
request.jellyfinUserId,
|
||||||
|
request.itemId,
|
||||||
|
)
|
||||||
|
jellyfinEventService.handleEvent(
|
||||||
|
eventId = request.eventId,
|
||||||
|
serverId = null,
|
||||||
|
eventType = request.eventType,
|
||||||
|
occurredAt = request.occurredAt,
|
||||||
|
jellyfinUserId = request.jellyfinUserId,
|
||||||
|
itemId = request.itemId,
|
||||||
|
payload = request.payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.project.movienight.adapters.web
|
||||||
|
|
||||||
|
import com.project.movienight.application.services.JellyfinSyncService
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncState
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/integrations/jellyfin")
|
||||||
|
class JellyfinSyncController(
|
||||||
|
private val jellyfinSyncService: JellyfinSyncService,
|
||||||
|
) {
|
||||||
|
@PostMapping("/sync")
|
||||||
|
fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow()
|
||||||
|
|
||||||
|
@GetMapping("/sync-state")
|
||||||
|
fun syncState(): List<JellyfinSyncState> = jellyfinSyncService.getSyncStates()
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.project.movienight.adapters.web.dto.request
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
data class JellyfinEventRequest(
|
||||||
|
@JsonProperty("event_id")
|
||||||
|
val eventId: String,
|
||||||
|
@JsonProperty("event_type")
|
||||||
|
val eventType: String,
|
||||||
|
@JsonProperty("occurred_at")
|
||||||
|
val occurredAt: OffsetDateTime,
|
||||||
|
@JsonProperty("jellyfin_user_id")
|
||||||
|
val jellyfinUserId: String,
|
||||||
|
@JsonProperty("item_id")
|
||||||
|
val itemId: String,
|
||||||
|
@JsonProperty("payload_version")
|
||||||
|
val payloadVersion: Int = 1,
|
||||||
|
@JsonProperty("payload")
|
||||||
|
val payload: Map<String, Any>? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||||
|
import com.project.movienight.adapters.persistence.jdbc.JellyfinEventRepository
|
||||||
|
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||||
|
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
|
||||||
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class JellyfinEventService(
|
||||||
|
private val jellyfinEventRepository: JellyfinEventRepository,
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
private val filmRepository: FilmRepositoryPort,
|
||||||
|
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val businessMetricsService: BusinessMetricsService,
|
||||||
|
) {
|
||||||
|
private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed")
|
||||||
|
|
||||||
|
fun handleEvent(
|
||||||
|
eventId: String,
|
||||||
|
serverId: String?,
|
||||||
|
eventType: String,
|
||||||
|
occurredAt: OffsetDateTime,
|
||||||
|
jellyfinUserId: String,
|
||||||
|
itemId: String,
|
||||||
|
payload: Map<String, Any>?,
|
||||||
|
) {
|
||||||
|
if (jellyfinEventRepository.exists(eventId = eventId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val payloadJson = payload?.let { objectMapper.writeValueAsString(it) }
|
||||||
|
jellyfinEventRepository.save(eventId, serverId, eventType, occurredAt, jellyfinUserId, itemId, payloadJson)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (playbackEventTypes.contains(eventType)) {
|
||||||
|
val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId }
|
||||||
|
if (localUser == null) {
|
||||||
|
businessMetricsService.recordJellyfinUnmappedUser()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val film = filmRepository.findByJellyfinItemId(itemId)
|
||||||
|
if (film == null) {
|
||||||
|
businessMetricsService.recordBackendWriteFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
markFilmViewedUseCase.markViewed(
|
||||||
|
MarkFilmViewedCommand(
|
||||||
|
userId = localUser.id,
|
||||||
|
filmId = film.id,
|
||||||
|
watchedAt = occurredAt.toLocalDateTime(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
businessMetricsService.recordLibraryEvent()
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
|
||||||
|
) {
|
||||||
|
businessMetricsService.recordBackendWriteFailure()
|
||||||
|
throw ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
import com.project.movienight.adapters.jellyfin.JellyfinApiClient
|
||||||
|
import com.project.movienight.adapters.jellyfin.JellyfinLibraryItemSnapshot
|
||||||
|
import com.project.movienight.adapters.jellyfin.JellyfinRemoteUser
|
||||||
|
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||||
|
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||||
|
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.config.JellyfinIntegrationProperties
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import com.project.movienight.domain.model.Film
|
||||||
|
import com.project.movienight.domain.model.FilmLibrary
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncState
|
||||||
|
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class JellyfinSyncService(
|
||||||
|
private val properties: JellyfinIntegrationProperties,
|
||||||
|
private val jellyfinApiClient: JellyfinApiClient,
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
private val filmRepository: FilmRepositoryPort,
|
||||||
|
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||||
|
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
|
||||||
|
private val idGenerator: IdGenerator,
|
||||||
|
private val businessMetricsService: BusinessMetricsService,
|
||||||
|
) {
|
||||||
|
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
|
||||||
|
fun scheduledSync() {
|
||||||
|
if (properties.enabled) {
|
||||||
|
syncNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncNow(): JellyfinSyncSummary {
|
||||||
|
if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) {
|
||||||
|
return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val startedAt = Instant.now()
|
||||||
|
val remoteUsers = jellyfinApiClient.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 = jellyfinApiClient.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
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
|
||||||
|
|
||||||
|
private fun syncItem(
|
||||||
|
userId: java.util.UUID,
|
||||||
|
item: JellyfinLibraryItemSnapshot,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
val savedFilm = filmRepository.save(film)
|
||||||
|
|
||||||
|
if (item.isPlayed) {
|
||||||
|
val watchedAt = LocalDateTime.now()
|
||||||
|
val existingEntry = filmLibraryRepository.findByUserIdAndFilmId(userId, savedFilm.id)
|
||||||
|
filmLibraryRepository.save(
|
||||||
|
existingEntry?.copy(
|
||||||
|
isViewed = true,
|
||||||
|
watchedAt = watchedAt,
|
||||||
|
) ?: FilmLibrary(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
userId = userId,
|
||||||
|
filmId = savedFilm.id,
|
||||||
|
comment = null,
|
||||||
|
isViewed = true,
|
||||||
|
watchedAt = watchedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.project.movienight.config
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "integrations.jellyfin")
|
||||||
|
data class JellyfinIntegrationProperties(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val baseUrl: String = "",
|
||||||
|
val apiKey: String = "",
|
||||||
|
val syncIntervalMs: Long = 1_800_000,
|
||||||
|
val requestTimeoutMs: Long = 20_000,
|
||||||
|
val pluginToken: String = "",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user