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) : 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 GameCreatedEvent(source: Any, val game: Game) : 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 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
@@ -105,10 +104,9 @@ class GameService(
return entities.toDtos() return entities.toDtos()
} }
@Transactional private fun create(game: Game): Game {
fun create(game: Game): Game? { game.publishers = game.publishers.map { companyService.createOrGet(it) }.toMutableList()
game.publishers = game.publishers.map { companyService.createOrGet(it) } game.developers = game.developers.map { companyService.createOrGet(it) }.toMutableList()
game.developers = game.developers.map { companyService.createOrGet(it) }
try { try {
game.coverImage?.let { game.coverImage?.let {
@@ -125,7 +123,6 @@ class GameService(
} catch (e: Exception) { } catch (e: Exception) {
log.error { "Error downloading images for game '${game.title}' (${game.id}): ${e.message}" } log.error { "Error downloading images for game '${game.title}' (${game.id}): ${e.message}" }
log.debug(e) {} log.debug(e) {}
null
} }
game.metadata.fileSize = filesystemService.calculateFileSize(game.metadata.path) game.metadata.fileSize = filesystemService.calculateFileSize(game.metadata.path)
@@ -138,9 +135,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 +165,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 +193,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 +383,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 +392,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 +446,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 +763,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 +803,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 +811,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 +852,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) {
progress.currentStep.current = completedImageDownload.incrementAndGet() duplicateImage.contentId = downloadedImage.contentId
emit(progress) duplicateImage.contentLength = downloadedImage.contentLength
} duplicateImage.mimeType = downloadedImage.mimeType
}
progress.currentStep.current = completedImageDownload.incrementAndGet()
emit(progress)
} }
}
return DownloadImagesResult(gamesWithImages = games) return DownloadImagesResult(gamesWithImages = games)
} }
@@ -47,7 +47,7 @@ class ImageEndpoint(
@GetMapping("/plugins/{id}/logo") @GetMapping("/plugins/{id}/logo")
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? { fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? {
val logo = pluginService.getLogo(pluginId) val logo = pluginService.getLogo(pluginId)
return Utils.Companion.inputStreamToResponseEntity(logo) return Utils.inputStreamToResponseEntity(logo)
} }
@GetMapping("/avatar") @GetMapping("/avatar")
@@ -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!!)
@@ -85,7 +85,7 @@ class ImageEndpoint(
userService.deleteAvatar(name) 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 image = imageService.getImage(id) ?: return ResponseEntity.notFound().build()
val file = image.let { imageService.getFileContent(it) } 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.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()
}
@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,19 +124,20 @@ 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) {
imageContentStore.unsetContent(image) val imageId = image.id ?: return
imageRepository.delete(image)
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 { fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image {
@@ -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
} }