Fix image handling

Fixes #703
This commit is contained in:
Simon
2025-09-09 21:11:17 +02:00
committed by GitHub
17 changed files with 308 additions and 118 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) :
ApplicationEvent(source)
class AccountDeletedEvent(source: Any, val user: User, val baseUrl: String) : 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 GameUpdatedEvent(source: Any, val previousState: Game, val currentState: Game) : ApplicationEvent(source)
class GameDeletedEvent(source: Any, val game: Game) : ApplicationEvent(source)
@@ -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<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
}
}
@@ -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))
}
@@ -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<Company> = emptyList(),
@ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var publishers: MutableList<Company> = mutableListOf(),
@ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH])
var developers: List<Company> = emptyList(),
@ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var developers: MutableList<Company> = mutableListOf(),
@ElementCollection(targetClass = Genre::class)
var genres: List<Genre> = emptyList(),
@@ -74,16 +74,14 @@ class Game(
@ElementCollection(targetClass = PlayerPerspective::class)
var perspectives: List<PlayerPerspective> = emptyList(),
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
var images: List<Image> = emptyList(),
@ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var images: MutableList<Image> = mutableListOf(),
@ElementCollection
var videoUrls: List<URI> = emptyList(),
@Embedded
var metadata: GameMetadata
) {
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.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))
}
}
@@ -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,
@@ -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("release") release: Instant?
): 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.springframework.data.jpa.repository.JpaRepository
import java.net.URL
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 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<URL, Image>()
.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)
}
@@ -47,7 +47,7 @@ class ImageEndpoint(
@GetMapping("/plugins/{id}/logo")
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? {
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<InputStreamResource>? {
private fun getImageContent(id: Long): ResponseEntity<InputStreamResource> {
val image = imageService.getImage(id) ?: return ResponseEntity.notFound().build()
val file = image.let { imageService.getFileContent(it) }
@@ -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 {
@@ -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" }
@@ -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()))
}
}
@@ -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()))
}
}
@@ -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<User, Long> {
fun existsByUsername(userName: String): Boolean
@@ -11,4 +13,7 @@ interface UserRepository : JpaRepository<User, Long> {
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
}