mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
@@ -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
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user