mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Fix image handling and cleanup
This commit is contained in:
@@ -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 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,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 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
|
||||
@@ -107,8 +106,8 @@ class GameService(
|
||||
|
||||
@Transactional
|
||||
fun create(game: Game): Game? {
|
||||
game.publishers = game.publishers.map { companyService.createOrGet(it) }
|
||||
game.developers = game.developers.map { companyService.createOrGet(it) }
|
||||
game.publishers = game.publishers.map { companyService.createOrGet(it) }.toMutableList()
|
||||
game.developers = game.developers.map { companyService.createOrGet(it) }.toMutableList()
|
||||
|
||||
try {
|
||||
game.coverImage?.let {
|
||||
@@ -138,9 +137,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 +167,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 +195,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 +385,7 @@ class GameService(
|
||||
"publishers",
|
||||
game.publishers,
|
||||
updatedGame.publishers,
|
||||
{ game.publishers = it ?: emptyList() },
|
||||
{ game.publishers = it ?: mutableListOf() },
|
||||
updatedGame.metadata.fields["publishers"]
|
||||
)
|
||||
|
||||
@@ -387,7 +394,7 @@ class GameService(
|
||||
"developers",
|
||||
game.developers,
|
||||
updatedGame.developers,
|
||||
{ game.developers = it ?: emptyList() },
|
||||
{ game.developers = it ?: mutableListOf() },
|
||||
updatedGame.metadata.fields["developers"]
|
||||
)
|
||||
|
||||
@@ -441,7 +448,7 @@ class GameService(
|
||||
"images",
|
||||
game.images,
|
||||
updatedGame.images,
|
||||
{ game.images = it ?: emptyList() },
|
||||
{ game.images = it ?: mutableListOf() },
|
||||
updatedGame.metadata.fields["images"]
|
||||
)
|
||||
|
||||
@@ -758,14 +765,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 +805,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 +813,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 +854,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)
|
||||
}
|
||||
|
||||
@@ -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!!)
|
||||
|
||||
@@ -1,41 +1,122 @@
|
||||
package org.gameyfin.app.media
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
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()
|
||||
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) {
|
||||
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 +126,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
|
||||
}
|
||||
Reference in New Issue
Block a user