diff --git a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx index 7d1df28..c58855c 100644 --- a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx @@ -77,7 +77,7 @@ function LibraryManagementLayout({getConfig, formik}: any) { removeLibrary={removeLibrary} key={library.name}/> )} : - "No libraries configured. Add your first library!" +

No libraries found

} - {item.path} + {item.metadata.path} diff --git a/gameyfin/src/main/frontend/views/GameView.tsx b/gameyfin/src/main/frontend/views/GameView.tsx index 3173016..696c1ba 100644 --- a/gameyfin/src/main/frontend/views/GameView.tsx +++ b/gameyfin/src/main/frontend/views/GameView.tsx @@ -66,7 +66,7 @@ export default function GameView() {

{game.release !== undefined ? new Date(game.release).getFullYear() : "unknown"}

- {downloadOptions && } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt index d8b892f..3d5496d 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt @@ -21,7 +21,7 @@ class DownloadEndpoint( @RequestParam provider: String ): ResponseEntity { 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 -> { diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/filesystem/FilesystemService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/filesystem/FilesystemService.kt index 85642c5..9ec1ec9 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/filesystem/FilesystemService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/filesystem/FilesystemService.kt @@ -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 diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt index e2e303c..8f95e66 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt @@ -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): List { - 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 { + private fun queryPlugins(gameTitle: String): Map { return runBlocking { coroutineScope { metadataPlugins.associateWith { @@ -160,8 +157,8 @@ class GameService( */ private fun filterResults( originalQuery: String, - results: Map - ): Map { + results: Map + ): Map { 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, + results: Map, path: Path, library: Library ): Game { - val mergedGame = Game(path = path.toString(), library = library) - val metadataMap = mutableMapOf() + val mergedGame = Game(path = path, library = library) + val metadataMap = mutableMapOf() val originalIdsMap = mutableMapOf() // 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): Map { - 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) ) } \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameDto.kt index 822cd03..269b4eb 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameDto.kt @@ -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?, val imageIds: List?, val videoUrls: List?, - val path: String, - val fileSize: Long, - val metadata: Map, - val originalIds: Map + val metadata: GameMetadataDto ) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameFieldMetadataDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameFieldMetadataDto.kt new file mode 100644 index 0000000..9262a5c --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameFieldMetadataDto.kt @@ -0,0 +1,8 @@ +package de.grimsi.gameyfin.games.dto + +import java.time.Instant + +class GameFieldMetadataDto( + val source: String, + val updatedAt: Instant +) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameMetadataDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameMetadataDto.kt index e5f9035..53ee591 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameMetadataDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameMetadataDto.kt @@ -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?, + val originalIds: Map?, + val downloadCount: Int ) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt index eb1e070..e840bf6 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt @@ -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 = emptyList(), - @Column(unique = true) - val path: String, - - var fileSize: Long? = null, - - @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true) - var metadata: Map = emptyMap(), - - @ElementCollection - var originalIds: Map = emptyMap(), - - var downloadCount: Int = 0 -) \ No newline at end of file + @Embedded + var metadata: GameMetadata +) { + constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString())) +} \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/FieldMetadata.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/GameFieldMetadata.kt similarity index 69% rename from gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/FieldMetadata.kt rename to gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/GameFieldMetadata.kt index ea5c2cd..a133278 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/FieldMetadata.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/GameFieldMetadata.kt @@ -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() +) diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/GameMetadata.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/GameMetadata.kt new file mode 100644 index 0000000..02ae322 --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/GameMetadata.kt @@ -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 = emptyMap(), + + @ElementCollection + var originalIds: Map = emptyMap(), + + var downloadCount: Int = 0 +) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/repositories/FieldMetadataRepository.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/repositories/FieldMetadataRepository.kt index 41af584..918a662 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/repositories/FieldMetadataRepository.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/repositories/FieldMetadataRepository.kt @@ -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 \ No newline at end of file +interface FieldMetadataRepository : JpaRepository \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/repositories/GameRepository.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/repositories/GameRepository.kt index 1cc6802..77eb31e 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/repositories/GameRepository.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/repositories/GameRepository.kt @@ -5,8 +5,8 @@ import org.springframework.data.domain.Limit import org.springframework.data.jpa.repository.JpaRepository interface GameRepository : JpaRepository { - fun findByPath(path: String): Game? - fun findAllByPathIn(paths: List): List + fun findByMetadata_Path(path: String): Game? + fun findAllByMetadata_PathIn(paths: List): List fun findByOrderByCreatedAtDesc(limit: Limit): List fun findByOrderByUpdatedAtDesc(limit: Limit): List } \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt index e5659d2..2db9ebf 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt @@ -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 },