diff --git a/app/src/main/kotlin/org/gameyfin/app/core/config/JpaConfiguration.kt b/app/src/main/kotlin/org/gameyfin/app/core/config/JpaConfiguration.kt new file mode 100644 index 0000000..a6f86e4 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/core/config/JpaConfiguration.kt @@ -0,0 +1,18 @@ +package org.gameyfin.app.core.config + +import org.gameyfin.app.core.interceptors.EntityUpdateInterceptor +import org.hibernate.cfg.AvailableSettings +import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class JpaConfiguration { + + @Bean + fun hibernatePropertiesCustomizer(entityUpdateInterceptor: EntityUpdateInterceptor): HibernatePropertiesCustomizer { + return HibernatePropertiesCustomizer { hibernateProperties -> + hibernateProperties[AvailableSettings.INTERCEPTOR] = entityUpdateInterceptor + } + } +} diff --git a/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt b/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt index f201ef6..d30eaa7 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt @@ -22,8 +22,12 @@ class RegistrationAttemptWithExistingEmailEvent(source: Any, val existingUser: U class PasswordResetRequestEvent(source: Any, val token: Token, val baseUrl: String) : ApplicationEvent(source) -class AccountDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source) - class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source) -class GameCreatedEvent(source: Any, val game: Game) : ApplicationEvent(source) \ No newline at end of file +class UserDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source) +class UserUpdatedEvent(source: Any, val previousState: User, val currentState: User) : ApplicationEvent(source) + +class GameCreatedEvent(source: Any, val game: Game) : ApplicationEvent(source) +class GameUpdatedEvent(source: Any, val previousState: Game, val currentState: Game) : ApplicationEvent(source) +class GameDeletedEvent(source: Any, val game: Game) : ApplicationEvent(source) + diff --git a/app/src/main/kotlin/org/gameyfin/app/core/interceptors/EntityUpdateInterceptor.kt b/app/src/main/kotlin/org/gameyfin/app/core/interceptors/EntityUpdateInterceptor.kt new file mode 100644 index 0000000..fbb3034 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/core/interceptors/EntityUpdateInterceptor.kt @@ -0,0 +1,97 @@ +package org.gameyfin.app.core.interceptors + +import org.gameyfin.app.core.events.GameUpdatedEvent +import org.gameyfin.app.core.events.UserUpdatedEvent +import org.gameyfin.app.games.entities.Game +import org.gameyfin.app.games.entities.Image +import org.gameyfin.app.users.entities.User +import org.gameyfin.app.util.EventPublisherHolder +import org.hibernate.Interceptor +import org.hibernate.type.Type +import org.springframework.stereotype.Component + +@Component +class EntityUpdateInterceptor() : Interceptor { + + override fun onFlushDirty( + entity: Any?, + id: Any?, + currentState: Array?, + previousState: Array?, + propertyNames: Array?, + types: Array? + ): Boolean { + if (entity == null || currentState == null || previousState == null || propertyNames == null) { + return false + } + + when (entity) { + is Game -> { + val previousGame = reconstructGame(entity, previousState, propertyNames) + val currentGame = reconstructGame(entity, currentState, propertyNames) + EventPublisherHolder.publish(GameUpdatedEvent(this, previousGame, currentGame)) + } + + is User -> { + val previousUser = reconstructUser(entity, previousState, propertyNames) + val currentUser = reconstructUser(entity, currentState, propertyNames) + EventPublisherHolder.publish(UserUpdatedEvent(this, previousUser, currentUser)) + } + } + + return false + } + + private fun reconstructGame(originalGame: Game, state: Array, propertyNames: Array): Game { + val reconstructed = Game( + library = originalGame.library, + metadata = originalGame.metadata + ) + + for (i in propertyNames.indices) { + when (propertyNames[i]) { + "id" -> reconstructed.id = state[i] as? Long + "createdAt" -> reconstructed.createdAt = state[i] as? java.time.Instant + "updatedAt" -> reconstructed.updatedAt = state[i] as? java.time.Instant + "title" -> reconstructed.title = state[i] as? String + "coverImage" -> reconstructed.coverImage = state[i] as? Image + "headerImage" -> reconstructed.headerImage = state[i] as? Image + "comment" -> reconstructed.comment = state[i] as? String + "summary" -> reconstructed.summary = state[i] as? String + "release" -> reconstructed.release = state[i] as? java.time.Instant + "userRating" -> reconstructed.userRating = state[i] as? Int + "criticRating" -> reconstructed.criticRating = state[i] as? Int + "images" -> { + @Suppress("UNCHECKED_CAST") + (state[i] as? MutableList)?.let { reconstructed.images = it } + } + } + } + + return reconstructed + } + + private fun reconstructUser(originalUser: User, state: Array, propertyNames: Array): User { + val reconstructed = User( + username = originalUser.username, + email = originalUser.email + ) + + for (i in propertyNames.indices) { + when (propertyNames[i]) { + "id" -> reconstructed.id = state[i] as? Long + "password" -> reconstructed.password = state[i] as? String + "oidcProviderId" -> reconstructed.oidcProviderId = state[i] as? String + "emailConfirmed" -> reconstructed.emailConfirmed = state[i] as? Boolean ?: false + "enabled" -> reconstructed.enabled = state[i] as? Boolean ?: false + "avatar" -> reconstructed.avatar = state[i] as? Image + "roles" -> { + @Suppress("UNCHECKED_CAST") + (state[i] as? List)?.let { reconstructed.roles = it } + } + } + } + + return reconstructed + } +} diff --git a/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt b/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt index b6d3d97..027c83e 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt @@ -33,7 +33,6 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import reactor.core.publisher.Flux import reactor.core.publisher.Sinks -import java.net.URI import java.nio.file.Path import java.time.ZoneId import java.time.ZoneOffset @@ -105,10 +104,9 @@ class GameService( return entities.toDtos() } - @Transactional - fun create(game: Game): Game? { - game.publishers = game.publishers.map { companyService.createOrGet(it) } - game.developers = game.developers.map { companyService.createOrGet(it) } + private fun create(game: Game): Game { + game.publishers = game.publishers.map { companyService.createOrGet(it) }.toMutableList() + game.developers = game.developers.map { companyService.createOrGet(it) }.toMutableList() try { game.coverImage?.let { @@ -125,7 +123,6 @@ class GameService( } catch (e: Exception) { log.error { "Error downloading images for game '${game.title}' (${game.id}): ${e.message}" } log.debug(e) {} - null } game.metadata.fileSize = filesystemService.calculateFileSize(game.metadata.path) @@ -138,9 +135,11 @@ class GameService( val gamesToBePersisted = games.filter { it.id == null } gamesToBePersisted.forEach { game -> - game.publishers = game.publishers.map { companyService.createOrGet(it) } - game.developers = game.developers.map { companyService.createOrGet(it) } - game + game.publishers = game.publishers.map { companyService.createOrGet(it) }.toMutableList() + game.developers = game.developers.map { companyService.createOrGet(it) }.toMutableList() + game.coverImage?.let { game.coverImage = imageService.createOrGet(it) } + game.headerImage?.let { game.headerImage = imageService.createOrGet(it) } + game.images = game.images.map { imageService.createOrGet(it) }.toMutableList() } return gameRepository.saveAll(gamesToBePersisted) @@ -166,14 +165,18 @@ class GameService( existingGame.metadata.fields["release"]?.source = GameFieldUserSource(user = user) } gameUpdateDto.coverUrl?.let { - val newCoverImage = Image(originalUrl = URI.create(it).toURL(), type = ImageType.COVER) + val newCoverImage = imageService.createOrGet( + Image(originalUrl = it, type = ImageType.COVER) + ) imageService.downloadIfNew(newCoverImage) existingGame.coverImage = newCoverImage existingGame.metadata.fields["coverImage"]?.source = GameFieldUserSource(user = user) } gameUpdateDto.headerUrl?.let { - val newHeaderImage = Image(originalUrl = URI.create(it).toURL(), type = ImageType.HEADER) + val newHeaderImage = imageService.createOrGet( + Image(originalUrl = it, type = ImageType.HEADER) + ) imageService.downloadIfNew(newHeaderImage) existingGame.headerImage = newHeaderImage @@ -190,11 +193,13 @@ class GameService( gameUpdateDto.developers?.let { existingGame.developers = it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.DEVELOPER)) } + .toMutableList() existingGame.metadata.fields["developers"]?.source = GameFieldUserSource(user = user) } gameUpdateDto.publishers?.let { existingGame.publishers = it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.PUBLISHER)) } + .toMutableList() existingGame.metadata.fields["publishers"]?.source = GameFieldUserSource(user = user) } gameUpdateDto.genres?.let { @@ -378,7 +383,7 @@ class GameService( "publishers", game.publishers, updatedGame.publishers, - { game.publishers = it ?: emptyList() }, + { game.publishers = it ?: mutableListOf() }, updatedGame.metadata.fields["publishers"] ) @@ -387,7 +392,7 @@ class GameService( "developers", game.developers, updatedGame.developers, - { game.developers = it ?: emptyList() }, + { game.developers = it ?: mutableListOf() }, updatedGame.metadata.fields["developers"] ) @@ -441,7 +446,7 @@ class GameService( "images", game.images, updatedGame.images, - { game.images = it ?: emptyList() }, + { game.images = it ?: mutableListOf() }, updatedGame.metadata.fields["images"] ) @@ -758,14 +763,18 @@ class GameService( } metadata.coverUrls?.firstOrNull()?.let { coverUrl -> if (!metadataMap.containsKey("coverImage")) { - mergedGame.coverImage = Image(originalUrl = coverUrl.toURL(), type = ImageType.COVER) + mergedGame.coverImage = imageService.createOrGet( + Image(originalUrl = coverUrl.toString(), type = ImageType.COVER) + ) metadataMap["coverImage"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) } } metadata.headerUrls?.firstOrNull()?.let { headerUrl -> if (!metadataMap.containsKey("headerImage")) { - mergedGame.headerImage = Image(originalUrl = headerUrl.toURL(), type = ImageType.HEADER) + mergedGame.headerImage = imageService.createOrGet( + Image(originalUrl = headerUrl.toString(), type = ImageType.HEADER) + ) metadataMap["headerImage"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) } @@ -794,7 +803,7 @@ class GameService( metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy -> if (!metadataMap.containsKey("publishers")) { mergedGame.publishers = - publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) } + publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }.toMutableList() metadataMap["publishers"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) } @@ -802,7 +811,7 @@ class GameService( metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy -> if (!metadataMap.containsKey("developers")) { mergedGame.developers = - developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) } + developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }.toMutableList() metadataMap["developers"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) } @@ -843,7 +852,11 @@ class GameService( metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls -> if (!metadataMap.containsKey("images")) { mergedGame.images = runBlocking { - screenshotUrls.map { Image(originalUrl = it.toURL(), type = ImageType.SCREENSHOT) } + screenshotUrls.map { + imageService.createOrGet( + Image(originalUrl = it.toString(), type = ImageType.SCREENSHOT) + ) + }.toMutableList() } metadataMap["images"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) } diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt index 2cdbcdd..04c52ef 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt @@ -1,6 +1,7 @@ package org.gameyfin.app.games.entities import jakarta.persistence.* +import jakarta.persistence.CascadeType.* import org.gameyfin.app.libraries.entities.Library import org.gameyfin.pluginapi.gamemetadata.GameFeature import org.gameyfin.pluginapi.gamemetadata.Genre @@ -28,15 +29,14 @@ class Game( var updatedAt: Instant? = null, @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "library_id") val library: Library, var title: String? = null, - @OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true) + @ManyToOne(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER) var coverImage: Image? = null, - @OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true) + @ManyToOne(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER) var headerImage: Image? = null, @Lob @@ -53,11 +53,11 @@ class Game( var criticRating: Int? = null, - @ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH]) - var publishers: List = emptyList(), + @ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER) + var publishers: MutableList = mutableListOf(), - @ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH]) - var developers: List = emptyList(), + @ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER) + var developers: MutableList = mutableListOf(), @ElementCollection(targetClass = Genre::class) var genres: List = emptyList(), @@ -74,16 +74,14 @@ class Game( @ElementCollection(targetClass = PlayerPerspective::class) var perspectives: List = emptyList(), - @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true) - var images: List = emptyList(), + @ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER) + var images: MutableList = mutableListOf(), @ElementCollection var videoUrls: List = emptyList(), @Embedded var metadata: GameMetadata - - ) { constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString())) } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt index b400493..93fbf10 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt @@ -4,6 +4,7 @@ import jakarta.persistence.PostPersist import jakarta.persistence.PostRemove import jakarta.persistence.PostUpdate import org.gameyfin.app.core.events.GameCreatedEvent +import org.gameyfin.app.core.events.GameDeletedEvent import org.gameyfin.app.games.GameService import org.gameyfin.app.games.dto.GameAdminEvent import org.gameyfin.app.games.dto.GameUserEvent @@ -24,11 +25,13 @@ class GameEntityListener { fun updated(game: Game) { GameService.emitUser(GameUserEvent.Updated(game.toUserDto())) GameService.emitAdmin(GameAdminEvent.Updated(game.toAdminDto())) + // GameUpdateEvent triggered via {@link org.gameyfin.app.core.interceptors.EntityUpdateInterceptor#onFlushDirty} } @PostRemove fun deleted(game: Game) { GameService.emitUser(GameUserEvent.Deleted(game.id!!)) GameService.emitAdmin(GameAdminEvent.Deleted(game.id!!)) + EventPublisherHolder.publish(GameDeletedEvent(this, game)) } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt index 3bc44cf..cb407d9 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt @@ -1,19 +1,20 @@ package org.gameyfin.app.games.entities -import jakarta.persistence.* +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id import org.springframework.content.commons.annotations.ContentId import org.springframework.content.commons.annotations.ContentLength import org.springframework.content.commons.annotations.MimeType -import java.net.URL @Entity -@EntityListeners(ImageEntityListener::class) class Image( @Id @GeneratedValue(strategy = GenerationType.AUTO) var id: Long? = null, - val originalUrl: URL? = null, + val originalUrl: String? = null, val type: ImageType, @@ -25,19 +26,7 @@ class Image( @MimeType var mimeType: String? = null -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Image) return false - return originalUrl.toString() == other.originalUrl.toString() - } - - override fun hashCode(): Int { - var result = id?.hashCode() ?: 0 - result = 31 * result + originalUrl?.toString().hashCode() - return result - } -} +) enum class ImageType { COVER, diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/ImageEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/ImageEntityListener.kt deleted file mode 100644 index 0486bfc..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/ImageEntityListener.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.gameyfin.app.games.entities - -import jakarta.persistence.PostRemove -import org.gameyfin.app.media.ImageService -import org.springframework.context.ApplicationContext -import org.springframework.context.ApplicationContextAware -import org.springframework.stereotype.Component - -@Component -class ImageEntityListener : ApplicationContextAware { - companion object { - private lateinit var applicationContext: ApplicationContext - } - - override fun setApplicationContext(context: ApplicationContext) { - applicationContext = context - } - - private fun getImageService(): ImageService { - return applicationContext.getBean(ImageService::class.java) - } - - @PostRemove - fun deleted(image: Image) { - getImageService().deleteFile(image) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/repositories/GameRepository.kt b/app/src/main/kotlin/org/gameyfin/app/games/repositories/GameRepository.kt index 0713ba3..c2a3f8c 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/repositories/GameRepository.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/repositories/GameRepository.kt @@ -12,4 +12,7 @@ interface GameRepository : JpaRepository { @Param("title") title: String, @Param("release") release: Instant? ): List + + @Query("SELECT CASE WHEN COUNT(g) > 0 THEN true ELSE false END FROM Game g WHERE g.coverImage.id = :imageId OR g.headerImage.id = :imageId OR :imageId IN (SELECT i.id FROM g.images i)") + fun existsByImage(@Param("imageId") imageId: Long): Boolean } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageRepository.kt b/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageRepository.kt index 942c2ff..ea211bc 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageRepository.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/repositories/ImageRepository.kt @@ -2,8 +2,7 @@ package org.gameyfin.app.games.repositories import org.gameyfin.app.games.entities.Image import org.springframework.data.jpa.repository.JpaRepository -import java.net.URL interface ImageRepository : JpaRepository { - fun findByOriginalUrl(originalUrl: URL): Image? + fun findByOriginalUrl(originalUrl: String): Image? } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt index 62a53c7..0bb6e12 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt @@ -13,7 +13,6 @@ import org.gameyfin.app.media.ImageService import org.springframework.stereotype.Service import reactor.core.publisher.Flux import reactor.core.publisher.Sinks -import java.net.URL import java.nio.file.Path import java.time.Instant import java.util.concurrent.Callable @@ -344,19 +343,13 @@ class LibraryScanService( // Deduplicate by originalUrl val uniqueImages = allImages .filter { it.originalUrl != null } - .distinctBy { it.originalUrl } - - // Map to track which Image entity was used for download per originalUrl - val downloadedImageMap = ConcurrentHashMap() + .distinctBy { it.originalUrl.toString() } // Download each unique image in parallel val imageDownloadTasks = uniqueImages.map { image -> Callable { try { imageService.downloadIfNew(image) - image.originalUrl?.let { url -> - downloadedImageMap[url] = image - } } catch (e: Exception) { log.error { "Error downloading image '${image.originalUrl}': ${e.message}" } log.debug(e) {} @@ -368,18 +361,20 @@ class LibraryScanService( } executor.invokeAll(imageDownloadTasks) - // After downloads, associate the contentId with all other Image entities in the batch with the same originalUrl - for ((url, downloadedImage) in downloadedImageMap) { - val contentId = downloadedImage.contentId - if (contentId != null) { - allImages.filter { it.originalUrl.toString() == url.toString() && it !== downloadedImage } - .forEach { image -> - imageService.downloadIfNew(image) - progress.currentStep.current = completedImageDownload.incrementAndGet() - emit(progress) - } + // For remaining duplicate images, just copy the content metadata from the downloaded unique image + val uniqueImagesByUrl = uniqueImages.associateBy { it.originalUrl.toString() } + + allImages.filter { it.originalUrl != null && it !in uniqueImages } + .forEach { duplicateImage -> + val downloadedImage = uniqueImagesByUrl[duplicateImage.originalUrl.toString()] + if (downloadedImage != null && downloadedImage.contentId != null) { + duplicateImage.contentId = downloadedImage.contentId + duplicateImage.contentLength = downloadedImage.contentLength + duplicateImage.mimeType = downloadedImage.mimeType + } + progress.currentStep.current = completedImageDownload.incrementAndGet() + emit(progress) } - } return DownloadImagesResult(gamesWithImages = games) } diff --git a/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt index df451b2..8b7fa57 100644 --- a/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt @@ -47,7 +47,7 @@ class ImageEndpoint( @GetMapping("/plugins/{id}/logo") fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity? { val logo = pluginService.getLogo(pluginId) - return Utils.Companion.inputStreamToResponseEntity(logo) + return Utils.inputStreamToResponseEntity(logo) } @GetMapping("/avatar") @@ -63,7 +63,7 @@ class ImageEndpoint( val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val image: Image = if (!userService.hasAvatar(auth.name)) { - imageService.createFile(ImageType.AVATAR, file.inputStream, file.contentType!!) + imageService.createFromInputStream(ImageType.AVATAR, file.inputStream, file.contentType!!) } else { val existingAvatar = userService.getAvatar(auth.name)!! imageService.updateFileContent(existingAvatar, file.inputStream, file.contentType!!) @@ -85,7 +85,7 @@ class ImageEndpoint( userService.deleteAvatar(name) } - private fun getImageContent(id: Long): ResponseEntity? { + private fun getImageContent(id: Long): ResponseEntity { val image = imageService.getImage(id) ?: return ResponseEntity.notFound().build() val file = image.let { imageService.getFileContent(it) } diff --git a/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt b/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt index 2ff041d..2a21715 100644 --- a/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt @@ -2,40 +2,119 @@ package org.gameyfin.app.media import org.apache.tika.Tika import org.apache.tika.io.TikaInputStream +import org.gameyfin.app.core.events.GameDeletedEvent +import org.gameyfin.app.core.events.GameUpdatedEvent +import org.gameyfin.app.core.events.UserDeletedEvent +import org.gameyfin.app.core.events.UserUpdatedEvent import org.gameyfin.app.games.entities.Image import org.gameyfin.app.games.entities.ImageType +import org.gameyfin.app.games.repositories.GameRepository import org.gameyfin.app.games.repositories.ImageContentStore import org.gameyfin.app.games.repositories.ImageRepository +import org.gameyfin.app.users.persistence.UserRepository import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener import java.io.InputStream +import java.net.URI @Service class ImageService( private val imageRepository: ImageRepository, - private val imageContentStore: ImageContentStore + private val imageContentStore: ImageContentStore, + private val gameRepository: GameRepository, + private val userRepository: UserRepository ) { companion object { - private val tika = Tika(); + private val tika = Tika() + } + + @TransactionalEventListener( + classes = [GameDeletedEvent::class], + phase = TransactionPhase.AFTER_COMPLETION + ) + fun onGameDeleted(event: GameDeletedEvent) { + val imagesToDelete = listOfNotNull(event.game.coverImage, event.game.headerImage) + .toMutableList() + .apply { addAll(event.game.images) } + + imagesToDelete.forEach { deleteImageIfUnused(it) } + } + + @TransactionalEventListener( + classes = [GameUpdatedEvent::class], + phase = TransactionPhase.AFTER_COMPLETION + ) + fun onGameUpdated(event: GameUpdatedEvent) { + val imagesBeforeUpdate = listOfNotNull(event.previousState.coverImage, event.previousState.headerImage) + .toMutableList() + .apply { addAll(event.previousState.images) } + .toSet() + + val imagesStillInUse = listOfNotNull(event.currentState.coverImage, event.currentState.headerImage) + .toMutableList() + .apply { addAll(event.currentState.images) } + .toSet() + + imagesBeforeUpdate.minus(imagesStillInUse).forEach { deleteImageIfUnused(it) } + } + + @TransactionalEventListener( + classes = [UserDeletedEvent::class], + phase = TransactionPhase.AFTER_COMPLETION + ) + fun onAccountDeleted(event: UserDeletedEvent) { + event.user.avatar?.let { deleteImageIfUnused(it) } + } + + @TransactionalEventListener( + classes = [UserUpdatedEvent::class], + phase = TransactionPhase.AFTER_COMPLETION + ) + fun onUserUpdated(event: UserUpdatedEvent) { + event.previousState.avatar?.let { previousAvatar -> + if (previousAvatar != event.currentState.avatar) { + deleteImageIfUnused(previousAvatar) + } + } + } + + fun createOrGet(image: Image): Image { + if (image.originalUrl != null) { + imageRepository.findByOriginalUrl(image.originalUrl)?.let { return it } + } + + return imageRepository.save(image) } fun downloadIfNew(image: Image) { if (image.originalUrl == null) throw IllegalArgumentException("Image must have an original URL") + // Always try to get existing image first to avoid detached entity issues val existingImage = imageRepository.findByOriginalUrl(image.originalUrl) if (existingImage != null && existingImage.contentId != null) { + // If we have an existing image with content, associate it with the current image imageContentStore.associate(image, existingImage.contentId) + // Update the current image's content metadata + image.contentId = existingImage.contentId + image.contentLength = existingImage.contentLength + image.mimeType = existingImage.mimeType return } - TikaInputStream.get { image.originalUrl.openStream() }.use { input -> + // If no existing image or existing image has no content, download it + TikaInputStream.get { URI.create(image.originalUrl).toURL().openStream() }.use { input -> image.mimeType = tika.detect(input) imageContentStore.setContent(image, input) } + + // Save the image to ensure it's persisted + imageRepository.save(image) } - fun createFile(type: ImageType, content: InputStream, mimeType: String): Image { + fun createFromInputStream(type: ImageType, content: InputStream, mimeType: String): Image { val image = Image(type = type, mimeType = mimeType) imageRepository.save(image) return imageContentStore.setContent(image, content) @@ -45,19 +124,20 @@ class ImageService( return imageRepository.findByIdOrNull(id) } - fun getFileContent(id: Long): InputStream? { - val image = getImage(id) ?: return null - return getFileContent(image) - } - fun getFileContent(image: Image): InputStream? { return imageContentStore.getContent(image) } - fun deleteFile(image: Image) { - imageContentStore.unsetContent(image) - imageRepository.delete(image) + fun deleteImageIfUnused(image: Image) { + val imageId = image.id ?: return + + val isImageStillInUse = gameRepository.existsByImage(imageId) || userRepository.existsByAvatar(imageId) + + if (!isImageStillInUse) { + imageContentStore.unsetContent(image) + imageRepository.delete(image) + } } fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image { diff --git a/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt b/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt index 530c879..bf277ea 100644 --- a/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt @@ -206,8 +206,8 @@ class MessageService( } @Async - @EventListener(AccountDeletedEvent::class) - fun onAccountDeletion(event: AccountDeletedEvent) { + @EventListener(UserDeletedEvent::class) + fun onAccountDeletion(event: UserDeletedEvent) { if (!enabled) { log.error { "No message provider available, can't send account deletion message" } diff --git a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt index c3d58a4..a062aca 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt @@ -5,7 +5,10 @@ import org.gameyfin.app.config.ConfigProperties import org.gameyfin.app.config.ConfigService import org.gameyfin.app.core.Role import org.gameyfin.app.core.Utils -import org.gameyfin.app.core.events.* +import org.gameyfin.app.core.events.AccountStatusChangedEvent +import org.gameyfin.app.core.events.EmailNeedsConfirmationEvent +import org.gameyfin.app.core.events.RegistrationAttemptWithExistingEmailEvent +import org.gameyfin.app.core.events.UserRegistrationWaitingForApprovalEvent import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.games.entities.Image import org.gameyfin.app.media.ImageService @@ -126,7 +129,7 @@ class UserService( val user = getByUsernameNonNull(username) if (user.avatar == null) return - imageService.deleteFile(user.avatar!!) + imageService.deleteImageIfUnused(user.avatar!!) user.avatar = null userRepository.save(user) @@ -284,6 +287,5 @@ class UserService( fun deleteUser(username: String) { val user = getByUsernameNonNull(username) userRepository.delete(user) - eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.getBaseUrl())) } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/users/entities/UserEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/users/entities/UserEntityListener.kt index 9779485..5fbba23 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/entities/UserEntityListener.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/entities/UserEntityListener.kt @@ -1,9 +1,13 @@ package org.gameyfin.app.users.entities import jakarta.persistence.EntityManager +import jakarta.persistence.PostRemove import jakarta.persistence.PreRemove +import org.gameyfin.app.core.Utils +import org.gameyfin.app.core.events.UserDeletedEvent import org.gameyfin.app.requests.entities.GameRequest import org.gameyfin.app.util.EntityManagerHolder +import org.gameyfin.app.util.EventPublisherHolder class UserEntityListener { @@ -22,4 +26,11 @@ class UserEntityListener { entityManager.merge(gr) } } + + // UserUpdateEvent triggered via {@link org.gameyfin.app.core.interceptors.EntityUpdateInterceptor#onFlushDirty} + + @PostRemove + fun postRemove(user: User) { + EventPublisherHolder.publish(UserDeletedEvent(this, user, Utils.getBaseUrl())) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/users/persistence/UserRepository.kt b/app/src/main/kotlin/org/gameyfin/app/users/persistence/UserRepository.kt index 847aa8d..8b9d730 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/persistence/UserRepository.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/persistence/UserRepository.kt @@ -3,6 +3,8 @@ package org.gameyfin.app.users.persistence import org.gameyfin.app.core.Role import org.gameyfin.app.users.entities.User import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface UserRepository : JpaRepository { fun existsByUsername(userName: String): Boolean @@ -11,4 +13,7 @@ interface UserRepository : JpaRepository { fun findByEmail(email: String): User? fun findByOidcProviderId(oidcProviderId: String): User? fun countUserByRolesContains(role: Role): Int + + @Query("SELECT CASE WHEN COUNT(u) > 0 THEN true ELSE false END FROM User u WHERE u.avatar.id = :imageId") + fun existsByAvatar(@Param("imageId") imageId: Long): Boolean } \ No newline at end of file