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 = 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, ), ) } }