Refactor structure of Game metadata

This commit is contained in:
grimsi
2025-05-29 11:36:12 +02:00
parent 489a6c7aed
commit 7bfa173c07
15 changed files with 107 additions and 93 deletions
@@ -77,7 +77,7 @@ function LibraryManagementLayout({getConfig, formik}: any) {
removeLibrary={removeLibrary} key={library.name}/>
)}
</div> :
"No libraries configured. Add your first library!"
<p className="mt-4 text-center text-default-500">No libraries found</p>
}
<LibraryCreationModal
@@ -32,7 +32,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
{new Date(item.createdAt).toLocaleString()}
</TableCell>
<TableCell>
{item.path}
{item.metadata.path}
</TableCell>
<TableCell className="flex flex-row gap-2">
<Button isIconOnly size="sm" isDisabled={true}><CheckCircle/></Button>
@@ -66,7 +66,7 @@ export default function GameView() {
<p className="text-foreground/60">{game.release !== undefined ? new Date(game.release).getFullYear() : "unknown"}</p>
</div>
</div>
{downloadOptions && <ComboButton description={humanFileSize(game.fileSize)}
{downloadOptions && <ComboButton description={humanFileSize(game.metadata.fileSize)}
options={downloadOptions}
preferredOptionKey="preferred-download-method"
/>}
@@ -21,7 +21,7 @@ class DownloadEndpoint(
@RequestParam provider: String
): ResponseEntity<StreamingResponseBody> {
val game = gameService.getById(gameId)
val download = downloadService.getDownload(game.path, provider)
val download = downloadService.getDownload(game.metadata.path, provider)
return when (download) {
is FileDownload -> {
@@ -110,7 +110,7 @@ class FilesystemService(
}
// Get all paths already in the library as game files or as unmatched paths
val currentLibraryGamePaths = library.games.map { Path(it.path) }
val currentLibraryGamePaths = library.games.map { Path(it.metadata.path) }
val currentLibraryUnmatchedPaths = library.unmatchedPaths.map { Path(it) }
val allCurrentLibraryPaths = currentLibraryGamePaths + currentLibraryUnmatchedPaths
@@ -7,14 +7,10 @@ import de.grimsi.gameyfin.core.filterValuesNotNull
import de.grimsi.gameyfin.core.plugins.PluginService
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.core.replaceRomanNumerals
import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.games.dto.GameEvent
import de.grimsi.gameyfin.games.dto.GameMetadataDto
import de.grimsi.gameyfin.games.dto.GameUpdateDto
import de.grimsi.gameyfin.games.dto.*
import de.grimsi.gameyfin.games.entities.*
import de.grimsi.gameyfin.games.repositories.GameRepository
import de.grimsi.gameyfin.libraries.Library
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.async
@@ -31,6 +27,7 @@ import reactor.core.publisher.Sinks
import java.nio.file.Path
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.toJavaDuration
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata as PluginApiMetadata
@Service
class GameService(
@@ -125,7 +122,7 @@ class GameService(
}
fun getAllByPaths(paths: List<String>): List<Game> {
return gameRepository.findAllByPathIn(paths)
return gameRepository.findAllByMetadata_PathIn(paths)
}
fun getById(id: Long): Game {
@@ -137,7 +134,7 @@ class GameService(
* Runs the queries concurrently and asynchronously
* @return A map of metadata plugins and their respective results
*/
private fun queryPlugins(gameTitle: String): Map<GameMetadataProvider, GameMetadata?> {
private fun queryPlugins(gameTitle: String): Map<GameMetadataProvider, PluginApiMetadata?> {
return runBlocking {
coroutineScope {
metadataPlugins.associateWith {
@@ -160,8 +157,8 @@ class GameService(
*/
private fun filterResults(
originalQuery: String,
results: Map<GameMetadataProvider, GameMetadata>
): Map<GameMetadataProvider, GameMetadata> {
results: Map<GameMetadataProvider, PluginApiMetadata>
): Map<GameMetadataProvider, PluginApiMetadata> {
val providerToTitle = results.entries.associate {
pluginManager.whichPlugin(it.key.javaClass).pluginId to it.value.title
}
@@ -183,12 +180,12 @@ class GameService(
* The plugin with the highest possible priority is used as the source for each field
*/
private fun mergeResults(
results: Map<GameMetadataProvider, GameMetadata?>,
results: Map<GameMetadataProvider, PluginApiMetadata?>,
path: Path,
library: Library
): Game {
val mergedGame = Game(path = path.toString(), library = library)
val metadataMap = mutableMapOf<String, FieldMetadata>()
val mergedGame = Game(path = path, library = library)
val metadataMap = mutableMapOf<String, GameFieldMetadata>()
val originalIdsMap = mutableMapOf<PluginManagementEntry, String>()
// Cache the plugin management entries for each provider
@@ -209,81 +206,81 @@ class GameService(
metadata.title.takeIf { it.isNotBlank() }?.let { title ->
if (!metadataMap.containsKey("title")) {
mergedGame.title = title
metadataMap["title"] = FieldMetadata(sourcePlugin)
metadataMap["title"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.description?.takeIf { it.isNotBlank() }?.let { description ->
if (!metadataMap.containsKey("summary")) {
mergedGame.summary = description
metadataMap["summary"] = FieldMetadata(sourcePlugin)
metadataMap["summary"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.coverUrl?.let { coverUrl ->
if (!metadataMap.containsKey("coverImage")) {
mergedGame.coverImage = Image(originalUrl = coverUrl.toURL(), type = ImageType.COVER)
metadataMap["coverImage"] = FieldMetadata(sourcePlugin)
metadataMap["coverImage"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.release?.let { release ->
if (!metadataMap.containsKey("release")) {
mergedGame.release = release
metadataMap["release"] = FieldMetadata(sourcePlugin)
metadataMap["release"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.userRating?.let { userRating ->
if (!metadataMap.containsKey("userRating")) {
mergedGame.userRating = userRating
metadataMap["userRating"] = FieldMetadata(sourcePlugin)
metadataMap["userRating"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.criticRating?.let { criticRating ->
if (!metadataMap.containsKey("criticRating")) {
mergedGame.criticRating = criticRating
metadataMap["criticRating"] = FieldMetadata(sourcePlugin)
metadataMap["criticRating"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy ->
if (!metadataMap.containsKey("publishers")) {
mergedGame.publishers =
publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }
metadataMap["publishers"] = FieldMetadata(sourcePlugin)
metadataMap["publishers"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy ->
if (!metadataMap.containsKey("developers")) {
mergedGame.developers =
developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }
metadataMap["developers"] = FieldMetadata(sourcePlugin)
metadataMap["developers"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.genres?.takeIf { it.isNotEmpty() }?.let { genres ->
if (!metadataMap.containsKey("genres")) {
mergedGame.genres = genres.toList()
metadataMap["genres"] = FieldMetadata(sourcePlugin)
metadataMap["genres"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.themes?.takeIf { it.isNotEmpty() }?.let { themes ->
if (!metadataMap.containsKey("themes")) {
mergedGame.themes = themes.toList()
metadataMap["themes"] = FieldMetadata(sourcePlugin)
metadataMap["themes"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.keywords?.takeIf { it.isNotEmpty() }?.let { keywords ->
if (!metadataMap.containsKey("keywords")) {
mergedGame.keywords = keywords.toList()
metadataMap["keywords"] = FieldMetadata(sourcePlugin)
metadataMap["keywords"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.features?.takeIf { it.isNotEmpty() }?.let { features ->
if (!metadataMap.containsKey("features")) {
mergedGame.features = features.toList()
metadataMap["features"] = FieldMetadata(sourcePlugin)
metadataMap["features"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.perspectives?.takeIf { it.isNotEmpty() }?.let { perspectives ->
if (!metadataMap.containsKey("perspectives")) {
mergedGame.perspectives = perspectives.toList()
metadataMap["perspectives"] = FieldMetadata(sourcePlugin)
metadataMap["perspectives"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls ->
@@ -291,20 +288,20 @@ class GameService(
mergedGame.images = runBlocking {
screenshotUrls.map { Image(originalUrl = it.toURL(), type = ImageType.SCREENSHOT) }
}
metadataMap["images"] = FieldMetadata(sourcePlugin)
metadataMap["images"] = GameFieldMetadata(source = sourcePlugin)
}
}
metadata.videoUrls?.takeIf { it.isNotEmpty() }?.let { videoUrls ->
if (!metadataMap.containsKey("videoUrls")) {
mergedGame.videoUrls = videoUrls.toList()
metadataMap["videoUrls"] = FieldMetadata(sourcePlugin)
metadataMap["videoUrls"] = GameFieldMetadata(source = sourcePlugin)
}
}
}
}
mergedGame.metadata = metadataMap
mergedGame.originalIds = originalIdsMap
mergedGame.metadata.fields = metadataMap
mergedGame.metadata.originalIds = originalIdsMap
return mergedGame
}
@@ -320,30 +317,29 @@ class GameService(
fun Game.toDto(): GameDto {
// Helper functions
fun toDto(metadata: FieldMetadata): GameMetadataDto {
return GameMetadataDto(
source = metadata.source.pluginId,
lastUpdated = metadata.lastUpdated
fun toDto(fieldMetadata: GameFieldMetadata): GameFieldMetadataDto {
return GameFieldMetadataDto(
source = fieldMetadata.source.pluginId,
updatedAt = fieldMetadata.updatedAt!!
)
}
fun toDto(metadata: Map<String, FieldMetadata>): Map<String, GameMetadataDto> {
return metadata.mapValues { toDto(it.value) }
fun toDto(metadata: GameMetadata): GameMetadataDto {
return GameMetadataDto(
fileSize = metadata.fileSize ?: 0L,
downloadCount = metadata.downloadCount,
path = metadata.path,
fields = metadata.fields.mapValues { toDto(it.value) },
originalIds = metadata.originalIds.mapKeys { it.key.pluginId }
)
}
val id = this.id ?: throw IllegalArgumentException("ID is null")
val createdAt = this.createdAt ?: throw IllegalArgumentException("creation timestamp is null")
val updatedAt = this.updatedAt ?: throw IllegalArgumentException("update timestamp is null")
val libraryId = this.library.id ?: throw IllegalArgumentException("library ID is null")
val title = this.title ?: throw IllegalArgumentException("title is null")
return GameDto(
id = id,
createdAt = createdAt,
updatedAt = updatedAt,
libraryId = libraryId,
title = title,
id = id!!,
createdAt = createdAt!!,
updatedAt = updatedAt!!,
libraryId = this.library.id!!,
title = title!!,
coverId = this.coverImage?.id,
comment = this.comment,
summary = this.summary,
@@ -359,9 +355,6 @@ fun Game.toDto(): GameDto {
perspectives = this.perspectives?.map { it.name },
imageIds = this.images.mapNotNull { it.id },
videoUrls = this.videoUrls.map { it.toString() },
path = this.path,
fileSize = this.fileSize ?: 0L,
metadata = toDto(this.metadata),
originalIds = this.originalIds.mapKeys { it.key.pluginId }
metadata = toDto(this.metadata)
)
}
@@ -1,7 +1,9 @@
package de.grimsi.gameyfin.games.dto
import com.fasterxml.jackson.annotation.JsonInclude
import java.time.Instant
@JsonInclude(JsonInclude.Include.NON_NULL)
class GameDto(
val id: Long,
val createdAt: Instant,
@@ -23,8 +25,5 @@ class GameDto(
val perspectives: List<String>?,
val imageIds: List<Long>?,
val videoUrls: List<String>?,
val path: String,
val fileSize: Long,
val metadata: Map<String, GameMetadataDto>,
val originalIds: Map<String, String>
val metadata: GameMetadataDto
)
@@ -0,0 +1,8 @@
package de.grimsi.gameyfin.games.dto
import java.time.Instant
class GameFieldMetadataDto(
val source: String,
val updatedAt: Instant
)
@@ -1,8 +1,12 @@
package de.grimsi.gameyfin.games.dto
import java.time.Instant
import com.fasterxml.jackson.annotation.JsonInclude
@JsonInclude(JsonInclude.Include.NON_NULL)
class GameMetadataDto(
val source: String,
val lastUpdated: Instant
val path: String?,
val fileSize: Long,
val fields: Map<String, GameFieldMetadataDto>?,
val originalIds: Map<String, String>?,
val downloadCount: Int
)
@@ -1,6 +1,5 @@
package de.grimsi.gameyfin.games.entities
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.libraries.Library
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameFeature
import de.grimsi.gameyfin.pluginapi.gamemetadata.Genre
@@ -10,6 +9,7 @@ import jakarta.persistence.*
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp
import java.net.URI
import java.nio.file.Path
import java.time.Instant
@Entity
@@ -76,16 +76,8 @@ class Game(
@ElementCollection
var videoUrls: List<URI> = emptyList(),
@Column(unique = true)
val path: String,
var fileSize: Long? = null,
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
var metadata: Map<String, FieldMetadata> = emptyMap(),
@ElementCollection
var originalIds: Map<PluginManagementEntry, String> = emptyMap(),
var downloadCount: Int = 0
)
@Embedded
var metadata: GameMetadata
) {
constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString()))
}
@@ -2,10 +2,11 @@ package de.grimsi.gameyfin.games.entities
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import jakarta.persistence.*
import org.hibernate.annotations.UpdateTimestamp
import java.time.Instant
@Entity
class FieldMetadata(
class GameFieldMetadata(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@@ -13,7 +14,6 @@ class FieldMetadata(
@ManyToOne
val source: PluginManagementEntry,
val lastUpdated: Instant
) {
constructor(source: PluginManagementEntry) : this(null, source, Instant.now())
}
@UpdateTimestamp
var updatedAt: Instant? = Instant.now()
)
@@ -0,0 +1,20 @@
package de.grimsi.gameyfin.games.entities
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import jakarta.persistence.*
@Embeddable
class GameMetadata(
@Column(unique = true)
val path: String,
var fileSize: Long? = null,
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.EAGER)
var fields: Map<String, GameFieldMetadata> = emptyMap(),
@ElementCollection
var originalIds: Map<PluginManagementEntry, String> = emptyMap(),
var downloadCount: Int = 0
)
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.games.repositories
import de.grimsi.gameyfin.games.entities.FieldMetadata
import de.grimsi.gameyfin.games.entities.GameFieldMetadata
import org.springframework.data.jpa.repository.JpaRepository
interface FieldMetadataRepository : JpaRepository<FieldMetadata, Long>
interface FieldMetadataRepository : JpaRepository<GameFieldMetadata, Long>
@@ -5,8 +5,8 @@ import org.springframework.data.domain.Limit
import org.springframework.data.jpa.repository.JpaRepository
interface GameRepository : JpaRepository<Game, Long> {
fun findByPath(path: String): Game?
fun findAllByPathIn(paths: List<String>): List<Game>
fun findByMetadata_Path(path: String): Game?
fun findAllByMetadata_PathIn(paths: List<String>): List<Game>
fun findByOrderByCreatedAtDesc(limit: Limit): List<Game>
fun findByOrderByUpdatedAtDesc(limit: Limit): List<Game>
}
@@ -256,9 +256,9 @@ class LibraryService(
val calculateFileSizeTask = gamesWithImages.map { game ->
Callable {
game.path.let { path ->
game.metadata.path.let { path ->
val fileSize = filesystemService.calculateFileSize(path)
game.fileSize = fileSize
game.metadata.fileSize = fileSize
progress.currentStep.current = calculatedFileSize.incrementAndGet()
emit(progress)
@@ -324,21 +324,19 @@ class LibraryService(
name = library.name,
directories = library.directories.map {
DirectoryMapping(internalPath = it.internalPath, externalPath = it.externalPath)
}.toMutableList()
}.toMutableList(),
)
}
}
fun Library.toDto(): LibraryDto {
val libraryId = this.id ?: throw IllegalArgumentException("Library ID is null")
val statsDto = LibraryStatsDto(
gamesCount = this.games.size,
downloadedGamesCount = this.games.sumOf { it.downloadCount }
downloadedGamesCount = this.games.sumOf { it.metadata.downloadCount }
)
return LibraryDto(
id = libraryId,
id = this.id!!,
name = this.name,
directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
games = this.games.mapNotNull { it.id },