Files
movienight-backend/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt
T

180 lines
6.5 KiB
Kotlin

package com.project.movienight.application.services
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
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.JellyfinCatalogPort
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
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.Film
import com.project.movienight.domain.model.FilmLibraryEntry
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
import java.util.UUID
@Service
class JellyfinSyncService(
private val properties: JellyfinIntegrationProperties,
private val jellyfinCatalog: JellyfinCatalogPort,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsPort,
) : JellyfinSyncUseCase {
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
fun scheduledSync() {
if (properties.enabled) {
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 (
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
) {
businessMetricsService.recordJellyfinSyncFailure()
throw ex
}
}
override fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
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(
userId: UUID,
filmId: UUID,
watchedAt: LocalDateTime,
) {
val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId)
filmLibraryEntryRepository.save(
existingEntry?.copy(
isViewed = true,
watchedAt = watchedAt,
) ?: FilmLibraryEntry(
id = idGenerator.generateId(),
userId = userId,
filmId = filmId,
comment = null,
isViewed = true,
watchedAt = watchedAt,
),
)
}
}