Fix image handling and cleanup

This commit is contained in:
grimsi
2025-09-09 19:10:26 +02:00
parent 6c7bf4399e
commit 479259adc7
17 changed files with 317 additions and 113 deletions
@@ -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
}
}
}
@@ -22,8 +22,12 @@ class RegistrationAttemptWithExistingEmailEvent(source: Any, val existingUser: U
class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.PasswordReset>, val baseUrl: String) : class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.PasswordReset>, val baseUrl: String) :
ApplicationEvent(source) ApplicationEvent(source)
class AccountDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source)
class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source) class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source)
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 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)
@@ -0,0 +1,107 @@
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<out Any?>?,
previousState: Array<out Any?>?,
propertyNames: Array<out String>?,
types: Array<out Type>?
): 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<out Any?>, propertyNames: Array<out String>): 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<Image>)?.let { reconstructed.images = it }
}
}
}
return reconstructed
}
private fun reconstructUser(originalUser: User, state: Array<out Any?>, propertyNames: Array<out String>): 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<org.gameyfin.app.core.Role>)?.let { reconstructed.roles = it }
}
}
}
return reconstructed
}
private fun extractGameImages(game: Game): List<Image> {
val images = mutableListOf<Image>()
game.coverImage?.let { images.add(it) }
game.headerImage?.let { images.add(it) }
images.addAll(game.images)
return images
}
}
@@ -33,7 +33,6 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import java.net.URI
import java.nio.file.Path import java.nio.file.Path
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -107,8 +106,8 @@ class GameService(
@Transactional @Transactional
fun create(game: Game): Game? { fun create(game: Game): Game? {
game.publishers = game.publishers.map { companyService.createOrGet(it) } game.publishers = game.publishers.map { companyService.createOrGet(it) }.toMutableList()
game.developers = game.developers.map { companyService.createOrGet(it) } game.developers = game.developers.map { companyService.createOrGet(it) }.toMutableList()
try { try {
game.coverImage?.let { game.coverImage?.let {
@@ -138,9 +137,11 @@ class GameService(
val gamesToBePersisted = games.filter { it.id == null } val gamesToBePersisted = games.filter { it.id == null }
gamesToBePersisted.forEach { game -> gamesToBePersisted.forEach { game ->
game.publishers = game.publishers.map { companyService.createOrGet(it) } game.publishers = game.publishers.map { companyService.createOrGet(it) }.toMutableList()
game.developers = game.developers.map { companyService.createOrGet(it) } game.developers = game.developers.map { companyService.createOrGet(it) }.toMutableList()
game 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) return gameRepository.saveAll(gamesToBePersisted)
@@ -166,14 +167,18 @@ class GameService(
existingGame.metadata.fields["release"]?.source = GameFieldUserSource(user = user) existingGame.metadata.fields["release"]?.source = GameFieldUserSource(user = user)
} }
gameUpdateDto.coverUrl?.let { 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) imageService.downloadIfNew(newCoverImage)
existingGame.coverImage = newCoverImage existingGame.coverImage = newCoverImage
existingGame.metadata.fields["coverImage"]?.source = GameFieldUserSource(user = user) existingGame.metadata.fields["coverImage"]?.source = GameFieldUserSource(user = user)
} }
gameUpdateDto.headerUrl?.let { 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) imageService.downloadIfNew(newHeaderImage)
existingGame.headerImage = newHeaderImage existingGame.headerImage = newHeaderImage
@@ -190,11 +195,13 @@ class GameService(
gameUpdateDto.developers?.let { gameUpdateDto.developers?.let {
existingGame.developers = existingGame.developers =
it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.DEVELOPER)) } it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.DEVELOPER)) }
.toMutableList()
existingGame.metadata.fields["developers"]?.source = GameFieldUserSource(user = user) existingGame.metadata.fields["developers"]?.source = GameFieldUserSource(user = user)
} }
gameUpdateDto.publishers?.let { gameUpdateDto.publishers?.let {
existingGame.publishers = existingGame.publishers =
it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.PUBLISHER)) } it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.PUBLISHER)) }
.toMutableList()
existingGame.metadata.fields["publishers"]?.source = GameFieldUserSource(user = user) existingGame.metadata.fields["publishers"]?.source = GameFieldUserSource(user = user)
} }
gameUpdateDto.genres?.let { gameUpdateDto.genres?.let {
@@ -378,7 +385,7 @@ class GameService(
"publishers", "publishers",
game.publishers, game.publishers,
updatedGame.publishers, updatedGame.publishers,
{ game.publishers = it ?: emptyList() }, { game.publishers = it ?: mutableListOf() },
updatedGame.metadata.fields["publishers"] updatedGame.metadata.fields["publishers"]
) )
@@ -387,7 +394,7 @@ class GameService(
"developers", "developers",
game.developers, game.developers,
updatedGame.developers, updatedGame.developers,
{ game.developers = it ?: emptyList() }, { game.developers = it ?: mutableListOf() },
updatedGame.metadata.fields["developers"] updatedGame.metadata.fields["developers"]
) )
@@ -441,7 +448,7 @@ class GameService(
"images", "images",
game.images, game.images,
updatedGame.images, updatedGame.images,
{ game.images = it ?: emptyList() }, { game.images = it ?: mutableListOf() },
updatedGame.metadata.fields["images"] updatedGame.metadata.fields["images"]
) )
@@ -758,14 +765,18 @@ class GameService(
} }
metadata.coverUrls?.firstOrNull()?.let { coverUrl -> metadata.coverUrls?.firstOrNull()?.let { coverUrl ->
if (!metadataMap.containsKey("coverImage")) { 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"] = metadataMap["coverImage"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.headerUrls?.firstOrNull()?.let { headerUrl -> metadata.headerUrls?.firstOrNull()?.let { headerUrl ->
if (!metadataMap.containsKey("headerImage")) { 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"] = metadataMap["headerImage"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
@@ -794,7 +805,7 @@ class GameService(
metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy -> metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy ->
if (!metadataMap.containsKey("publishers")) { if (!metadataMap.containsKey("publishers")) {
mergedGame.publishers = mergedGame.publishers =
publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) } publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }.toMutableList()
metadataMap["publishers"] = metadataMap["publishers"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
@@ -802,7 +813,7 @@ class GameService(
metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy -> metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy ->
if (!metadataMap.containsKey("developers")) { if (!metadataMap.containsKey("developers")) {
mergedGame.developers = mergedGame.developers =
developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) } developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }.toMutableList()
metadataMap["developers"] = metadataMap["developers"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
@@ -843,7 +854,11 @@ class GameService(
metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls -> metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls ->
if (!metadataMap.containsKey("images")) { if (!metadataMap.containsKey("images")) {
mergedGame.images = runBlocking { 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)) metadataMap["images"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
@@ -1,6 +1,7 @@
package org.gameyfin.app.games.entities package org.gameyfin.app.games.entities
import jakarta.persistence.* import jakarta.persistence.*
import jakarta.persistence.CascadeType.*
import org.gameyfin.app.libraries.entities.Library import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.pluginapi.gamemetadata.GameFeature import org.gameyfin.pluginapi.gamemetadata.GameFeature
import org.gameyfin.pluginapi.gamemetadata.Genre import org.gameyfin.pluginapi.gamemetadata.Genre
@@ -28,15 +29,14 @@ class Game(
var updatedAt: Instant? = null, var updatedAt: Instant? = null,
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "library_id")
val library: Library, val library: Library,
var title: String? = null, var title: String? = null,
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true) @ManyToOne(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var coverImage: Image? = null, var coverImage: Image? = null,
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true) @ManyToOne(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var headerImage: Image? = null, var headerImage: Image? = null,
@Lob @Lob
@@ -53,11 +53,11 @@ class Game(
var criticRating: Int? = null, var criticRating: Int? = null,
@ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH]) @ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var publishers: List<Company> = emptyList(), var publishers: MutableList<Company> = mutableListOf(),
@ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH]) @ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var developers: List<Company> = emptyList(), var developers: MutableList<Company> = mutableListOf(),
@ElementCollection(targetClass = Genre::class) @ElementCollection(targetClass = Genre::class)
var genres: List<Genre> = emptyList(), var genres: List<Genre> = emptyList(),
@@ -74,16 +74,14 @@ class Game(
@ElementCollection(targetClass = PlayerPerspective::class) @ElementCollection(targetClass = PlayerPerspective::class)
var perspectives: List<PlayerPerspective> = emptyList(), var perspectives: List<PlayerPerspective> = emptyList(),
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true) @ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var images: List<Image> = emptyList(), var images: MutableList<Image> = mutableListOf(),
@ElementCollection @ElementCollection
var videoUrls: List<URI> = emptyList(), var videoUrls: List<URI> = emptyList(),
@Embedded @Embedded
var metadata: GameMetadata var metadata: GameMetadata
) { ) {
constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString())) constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString()))
} }
@@ -4,6 +4,7 @@ import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate import jakarta.persistence.PostUpdate
import org.gameyfin.app.core.events.GameCreatedEvent 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.GameService
import org.gameyfin.app.games.dto.GameAdminEvent import org.gameyfin.app.games.dto.GameAdminEvent
import org.gameyfin.app.games.dto.GameUserEvent import org.gameyfin.app.games.dto.GameUserEvent
@@ -24,11 +25,13 @@ class GameEntityListener {
fun updated(game: Game) { fun updated(game: Game) {
GameService.emitUser(GameUserEvent.Updated(game.toUserDto())) GameService.emitUser(GameUserEvent.Updated(game.toUserDto()))
GameService.emitAdmin(GameAdminEvent.Updated(game.toAdminDto())) GameService.emitAdmin(GameAdminEvent.Updated(game.toAdminDto()))
// GameUpdateEvent triggered via {@link org.gameyfin.app.core.interceptors.EntityUpdateInterceptor#onFlushDirty}
} }
@PostRemove @PostRemove
fun deleted(game: Game) { fun deleted(game: Game) {
GameService.emitUser(GameUserEvent.Deleted(game.id!!)) GameService.emitUser(GameUserEvent.Deleted(game.id!!))
GameService.emitAdmin(GameAdminEvent.Deleted(game.id!!)) GameService.emitAdmin(GameAdminEvent.Deleted(game.id!!))
EventPublisherHolder.publish(GameDeletedEvent(this, game))
} }
} }
@@ -1,19 +1,20 @@
package org.gameyfin.app.games.entities 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.ContentId
import org.springframework.content.commons.annotations.ContentLength import org.springframework.content.commons.annotations.ContentLength
import org.springframework.content.commons.annotations.MimeType import org.springframework.content.commons.annotations.MimeType
import java.net.URL
@Entity @Entity
@EntityListeners(ImageEntityListener::class)
class Image( class Image(
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null, var id: Long? = null,
val originalUrl: URL? = null, val originalUrl: String? = null,
val type: ImageType, val type: ImageType,
@@ -25,19 +26,7 @@ class Image(
@MimeType @MimeType
var mimeType: String? = null 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 { enum class ImageType {
COVER, COVER,
@@ -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)
}
}
@@ -12,4 +12,7 @@ interface GameRepository : JpaRepository<Game, Long> {
@Param("title") title: String, @Param("title") title: String,
@Param("release") release: Instant? @Param("release") release: Instant?
): List<Game> ): List<Game>
@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
} }
@@ -2,8 +2,7 @@ package org.gameyfin.app.games.repositories
import org.gameyfin.app.games.entities.Image import org.gameyfin.app.games.entities.Image
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import java.net.URL
interface ImageRepository : JpaRepository<Image, Long> { interface ImageRepository : JpaRepository<Image, Long> {
fun findByOriginalUrl(originalUrl: URL): Image? fun findByOriginalUrl(originalUrl: String): Image?
} }
@@ -13,7 +13,6 @@ import org.gameyfin.app.media.ImageService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import java.net.URL
import java.nio.file.Path import java.nio.file.Path
import java.time.Instant import java.time.Instant
import java.util.concurrent.Callable import java.util.concurrent.Callable
@@ -344,19 +343,13 @@ class LibraryScanService(
// Deduplicate by originalUrl // Deduplicate by originalUrl
val uniqueImages = allImages val uniqueImages = allImages
.filter { it.originalUrl != null } .filter { it.originalUrl != null }
.distinctBy { it.originalUrl } .distinctBy { it.originalUrl.toString() }
// Map to track which Image entity was used for download per originalUrl
val downloadedImageMap = ConcurrentHashMap<URL, Image>()
// Download each unique image in parallel // Download each unique image in parallel
val imageDownloadTasks = uniqueImages.map { image -> val imageDownloadTasks = uniqueImages.map { image ->
Callable { Callable {
try { try {
imageService.downloadIfNew(image) imageService.downloadIfNew(image)
image.originalUrl?.let { url ->
downloadedImageMap[url] = image
}
} catch (e: Exception) { } catch (e: Exception) {
log.error { "Error downloading image '${image.originalUrl}': ${e.message}" } log.error { "Error downloading image '${image.originalUrl}': ${e.message}" }
log.debug(e) {} log.debug(e) {}
@@ -368,18 +361,20 @@ class LibraryScanService(
} }
executor.invokeAll(imageDownloadTasks) executor.invokeAll(imageDownloadTasks)
// After downloads, associate the contentId with all other Image entities in the batch with the same originalUrl // For remaining duplicate images, just copy the content metadata from the downloaded unique image
for ((url, downloadedImage) in downloadedImageMap) { val uniqueImagesByUrl = uniqueImages.associateBy { it.originalUrl.toString() }
val contentId = downloadedImage.contentId
if (contentId != null) { allImages.filter { it.originalUrl != null && it !in uniqueImages }
allImages.filter { it.originalUrl.toString() == url.toString() && it !== downloadedImage } .forEach { duplicateImage ->
.forEach { image -> val downloadedImage = uniqueImagesByUrl[duplicateImage.originalUrl.toString()]
imageService.downloadIfNew(image) if (downloadedImage != null && downloadedImage.contentId != null) {
duplicateImage.contentId = downloadedImage.contentId
duplicateImage.contentLength = downloadedImage.contentLength
duplicateImage.mimeType = downloadedImage.mimeType
}
progress.currentStep.current = completedImageDownload.incrementAndGet() progress.currentStep.current = completedImageDownload.incrementAndGet()
emit(progress) emit(progress)
} }
}
}
return DownloadImagesResult(gamesWithImages = games) return DownloadImagesResult(gamesWithImages = games)
} }
@@ -63,7 +63,7 @@ class ImageEndpoint(
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val image: Image = if (!userService.hasAvatar(auth.name)) { 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 { } else {
val existingAvatar = userService.getAvatar(auth.name)!! val existingAvatar = userService.getAvatar(auth.name)!!
imageService.updateFileContent(existingAvatar, file.inputStream, file.contentType!!) imageService.updateFileContent(existingAvatar, file.inputStream, file.contentType!!)
@@ -1,41 +1,122 @@
package org.gameyfin.app.media package org.gameyfin.app.media
import io.github.oshai.kotlinlogging.KotlinLogging
import org.apache.tika.Tika import org.apache.tika.Tika
import org.apache.tika.io.TikaInputStream 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.Image
import org.gameyfin.app.games.entities.ImageType 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.ImageContentStore
import org.gameyfin.app.games.repositories.ImageRepository import org.gameyfin.app.games.repositories.ImageRepository
import org.gameyfin.app.users.persistence.UserRepository
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
import java.io.InputStream import java.io.InputStream
import java.net.URI
@Service @Service
class ImageService( class ImageService(
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
private val imageContentStore: ImageContentStore private val imageContentStore: ImageContentStore,
private val gameRepository: GameRepository,
private val userRepository: UserRepository
) { ) {
companion object { companion object {
private val tika = Tika(); private val tika = Tika()
private val log = KotlinLogging.logger {}
}
@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) { fun downloadIfNew(image: Image) {
if (image.originalUrl == null) throw IllegalArgumentException("Image must have an original URL") 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) val existingImage = imageRepository.findByOriginalUrl(image.originalUrl)
if (existingImage != null && existingImage.contentId != null) { 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) 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 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) image.mimeType = tika.detect(input)
imageContentStore.setContent(image, 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) val image = Image(type = type, mimeType = mimeType)
imageRepository.save(image) imageRepository.save(image)
return imageContentStore.setContent(image, content) return imageContentStore.setContent(image, content)
@@ -45,20 +126,21 @@ class ImageService(
return imageRepository.findByIdOrNull(id) return imageRepository.findByIdOrNull(id)
} }
fun getFileContent(id: Long): InputStream? {
val image = getImage(id) ?: return null
return getFileContent(image)
}
fun getFileContent(image: Image): InputStream? { fun getFileContent(image: Image): InputStream? {
return imageContentStore.getContent(image) return imageContentStore.getContent(image)
} }
fun deleteFile(image: Image) { fun deleteImageIfUnused(image: Image) {
val imageId = image.id ?: return
val isImageStillInUse = gameRepository.existsByImage(imageId) || userRepository.existsByAvatar(imageId)
if (!isImageStillInUse) {
imageContentStore.unsetContent(image) imageContentStore.unsetContent(image)
imageRepository.delete(image) imageRepository.delete(image)
} }
}
fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image { fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image {
mimeType?.let { image.mimeType = it } mimeType?.let { image.mimeType = it }
@@ -206,8 +206,8 @@ class MessageService(
} }
@Async @Async
@EventListener(AccountDeletedEvent::class) @EventListener(UserDeletedEvent::class)
fun onAccountDeletion(event: AccountDeletedEvent) { fun onAccountDeletion(event: UserDeletedEvent) {
if (!enabled) { if (!enabled) {
log.error { "No message provider available, can't send account deletion message" } log.error { "No message provider available, can't send account deletion message" }
@@ -5,7 +5,10 @@ import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService import org.gameyfin.app.config.ConfigService
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.core.Utils 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.core.security.getCurrentAuth
import org.gameyfin.app.games.entities.Image import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.media.ImageService import org.gameyfin.app.media.ImageService
@@ -126,7 +129,7 @@ class UserService(
val user = getByUsernameNonNull(username) val user = getByUsernameNonNull(username)
if (user.avatar == null) return if (user.avatar == null) return
imageService.deleteFile(user.avatar!!) imageService.deleteImageIfUnused(user.avatar!!)
user.avatar = null user.avatar = null
userRepository.save(user) userRepository.save(user)
@@ -284,6 +287,5 @@ class UserService(
fun deleteUser(username: String) { fun deleteUser(username: String) {
val user = getByUsernameNonNull(username) val user = getByUsernameNonNull(username)
userRepository.delete(user) userRepository.delete(user)
eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.getBaseUrl()))
} }
} }
@@ -1,9 +1,13 @@
package org.gameyfin.app.users.entities package org.gameyfin.app.users.entities
import jakarta.persistence.EntityManager import jakarta.persistence.EntityManager
import jakarta.persistence.PostRemove
import jakarta.persistence.PreRemove 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.requests.entities.GameRequest
import org.gameyfin.app.util.EntityManagerHolder import org.gameyfin.app.util.EntityManagerHolder
import org.gameyfin.app.util.EventPublisherHolder
class UserEntityListener { class UserEntityListener {
@@ -22,4 +26,11 @@ class UserEntityListener {
entityManager.merge(gr) 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()))
}
} }
@@ -3,6 +3,8 @@ package org.gameyfin.app.users.persistence
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.users.entities.User import org.gameyfin.app.users.entities.User
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
interface UserRepository : JpaRepository<User, Long> { interface UserRepository : JpaRepository<User, Long> {
fun existsByUsername(userName: String): Boolean fun existsByUsername(userName: String): Boolean
@@ -11,4 +13,7 @@ interface UserRepository : JpaRepository<User, Long> {
fun findByEmail(email: String): User? fun findByEmail(email: String): User?
fun findByOidcProviderId(oidcProviderId: String): User? fun findByOidcProviderId(oidcProviderId: String): User?
fun countUserByRolesContains(role: Role): Int 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
} }