diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt new file mode 100644 index 0000000..a21d103 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -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, + val cast: List, + val directors: List, + 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 = + 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 = + @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 = + 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 = + takeIf { it.isArray }?.mapNotNull { item -> + item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() } + } + ?: emptyList() + + private fun JsonNode.peopleByType(vararg types: String): List { + 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 + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt new file mode 100644 index 0000000..3708530 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -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, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt new file mode 100644 index 0000000..74aac90 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -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 = jellyfinSyncService.getSyncStates() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt new file mode 100644 index 0000000..68abfe8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt @@ -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? = null, +) diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt new file mode 100644 index 0000000..8ff3f6b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -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?, + ) { + 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 + } + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt new file mode 100644 index 0000000..46faa88 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -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 = 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, + ), + ) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt new file mode 100644 index 0000000..5a59400 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -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 = "", +) diff --git a/src/main/resources/db/migration/V2__jellyfin_events.sql b/src/main/resources/db/migration/V3__jellyfin_events.sql similarity index 100% rename from src/main/resources/db/migration/V2__jellyfin_events.sql rename to src/main/resources/db/migration/V3__jellyfin_events.sql