mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Refactor structure of Game metadata
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
+5
-5
@@ -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
|
||||
)
|
||||
+2
-2
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user