Preparations for plugin prioritisation and metadata merging

This commit is contained in:
grimsi
2025-01-04 18:26:11 +01:00
parent 046b550cc9
commit 8fdb6ac24d
11 changed files with 140 additions and 141 deletions
+1 -1
View File
@@ -4,8 +4,8 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
allprojects { allprojects {
repositories { repositories {
mavenLocal()
mavenCentral() mavenCentral()
mavenLocal()
} }
} }
@@ -14,7 +14,8 @@ import kotlin.io.path.Path
@Component @Component
class GameyfinPluginManager( class GameyfinPluginManager(
val pluginConfigRepository: PluginConfigRepository, val pluginConfigRepository: PluginConfigRepository,
val dbPluginStatusProvider: DatabasePluginStatusProvider val dbPluginStatusProvider: DatabasePluginStatusProvider,
val pluginManagementRepository: PluginManagementRepository
) : DefaultPluginManager(Path(System.getProperty("pf4j.pluginsDir", "plugins"))) { ) : DefaultPluginManager(Path(System.getProperty("pf4j.pluginsDir", "plugins"))) {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@@ -55,8 +56,8 @@ class GameyfinPluginManager(
override fun loadPluginFromPath(pluginPath: Path?): PluginWrapper? { override fun loadPluginFromPath(pluginPath: Path?): PluginWrapper? {
val pluginWrapper = super.loadPluginFromPath(pluginPath) val pluginWrapper = super.loadPluginFromPath(pluginPath)
// Inject config after loading, before starting
if (pluginWrapper != null) { if (pluginWrapper != null) {
// Inject config after loading, before starting
configurePlugin(pluginWrapper) configurePlugin(pluginWrapper)
} }
@@ -1,19 +1,14 @@
package de.grimsi.gameyfin.core.plugins.management package de.grimsi.gameyfin.core.plugins.management
import jakarta.persistence.Column
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
@Entity @Entity
@Table(name = "plugin_management")
data class PluginManagementEntry( data class PluginManagementEntry(
@Id @Id
@Column(name = "plugin_id")
val pluginId: String, val pluginId: String,
@NotNull var enabled: Boolean = true,
@Column(name = "enabled")
var enabled: Boolean = true var priority: Int = Int.MAX_VALUE
) )
@@ -17,7 +17,7 @@ import kotlinx.coroutines.runBlocking
import org.pf4j.PluginManager import org.pf4j.PluginManager
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.net.URL import java.net.URI
import java.net.URLConnection import java.net.URLConnection
import java.nio.file.Path import java.nio.file.Path
@@ -43,20 +43,7 @@ class GameService(
} }
fun createFromFile(path: Path): GameDto { fun createFromFile(path: Path): GameDto {
val metadataResults: Map<GameMetadataProvider, GameMetadata?> = runBlocking { val metadataResults = queryPlugins(path.fileName.toString())
coroutineScope {
metadataPlugins.associateWith {
async {
try {
it.fetchMetadata(path.fileName.toString())
} catch (e: Exception) {
log.error(e) { "Error fetching metadata with plugin ${it.javaClass.name}" }
null
}
}.await()
}
}
}
val (plugin, metadata) = metadataResults.entries.firstOrNull { it.value != null } val (plugin, metadata) = metadataResults.entries.firstOrNull { it.value != null }
?: throw NoMatchException("Could not match game at $path") ?: throw NoMatchException("Could not match game at $path")
@@ -84,25 +71,48 @@ class GameService(
return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found") return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found")
} }
/**
* Queries all metadata plugins for metadata on the provided game title
* Runs the queries concurrently and asynchronously
* @return A map of metadata plugins and their respective results
*/
private fun queryPlugins(gameTitle: String): Map<GameMetadataProvider, GameMetadata?> {
return runBlocking {
coroutineScope {
metadataPlugins.associateWith {
async {
try {
it.fetchMetadata(gameTitle)
} catch (e: Exception) {
log.error(e) { "Error fetching metadata with plugin ${it.javaClass.name}" }
null
}
}.await()
}
}
}
}
private fun toDto(game: Game): GameDto { private fun toDto(game: Game): GameDto {
val gameId = game.id ?: throw IllegalArgumentException("Game ID is null") val gameId = game.id ?: throw IllegalArgumentException("Game ID is null")
return GameDto( return GameDto(
id = gameId, id = gameId,
title = game.title, title = game.title,
coverId = game.coverImage.id, coverId = game.coverImage?.id,
comment = game.comment, comment = game.comment,
summary = game.summary, summary = game.summary,
release = game.release, release = game.release,
publishers = game.publishers.map { it.name }, publishers = game.publishers?.map { it.name },
developers = game.developers.map { it.name }, developers = game.developers?.map { it.name },
genres = game.genres.map { it.name }, genres = game.genres?.map { it.name },
themes = game.themes.map { it.name }, themes = game.themes?.map { it.name },
keywords = game.keywords.toList(), keywords = game.keywords?.toList(),
features = game.features.map { it.name }, features = game.features?.map { it.name },
perspectives = game.perspectives.map { it.name }, perspectives = game.perspectives?.map { it.name },
imageIds = game.images.mapNotNull { it.id }, imageIds = game.images?.mapNotNull { it.id },
videoUrls = game.videoUrls.map { it.toString() }, videoUrls = game.videoUrls?.map { it.toString() },
path = game.path,
metadata = toDto(game.metadata) metadata = toDto(game.metadata)
) )
} }
@@ -124,16 +134,16 @@ class GameService(
return Game( return Game(
title = metadata.title, title = metadata.title,
summary = metadata.description, summary = metadata.description,
coverImage = downloadAndPersist(metadata.coverUrl, ImageType.COVER), coverImage = metadata.coverUrl?.let { downloadAndPersist(it, ImageType.COVER) },
release = metadata.release, release = metadata.release,
publishers = metadata.publishedBy.map { toEntity(it, CompanyType.PUBLISHER) }.toSet(), publishers = metadata.publishedBy?.map { toEntity(it, CompanyType.PUBLISHER) }?.toSet(),
developers = metadata.developedBy.map { toEntity(it, CompanyType.DEVELOPER) }.toSet(), developers = metadata.developedBy?.map { toEntity(it, CompanyType.DEVELOPER) }?.toSet(),
genres = metadata.genres, genres = metadata.genres,
themes = metadata.themes, themes = metadata.themes,
keywords = metadata.keywords, keywords = metadata.keywords,
features = metadata.features, features = metadata.features,
perspectives = metadata.perspectives, perspectives = metadata.perspectives,
images = metadata.screenshotUrls.map { downloadAndPersist(it, ImageType.SCREENSHOT) }.toSet(), images = metadata.screenshotUrls?.map { downloadAndPersist(it, ImageType.SCREENSHOT) }?.toSet(),
videoUrls = metadata.videoUrls, videoUrls = metadata.videoUrls,
path = path.toString(), path = path.toString(),
metadata = mapOf("title" to FieldMetadata(sourcePlugin)) metadata = mapOf("title" to FieldMetadata(sourcePlugin))
@@ -146,12 +156,13 @@ class GameService(
return companyRepository.save(company) return companyRepository.save(company)
} }
private fun downloadAndPersist(imageUrl: URL, type: ImageType): Image { private fun downloadAndPersist(imageUrl: URI, type: ImageType): Image {
imageRepository.findByOriginalUrl(imageUrl)?.let { return it } val parsedUrl = imageUrl.toURL()
imageRepository.findByOriginalUrl(parsedUrl)?.let { return it }
val image = Image(originalUrl = imageUrl, type = type) val image = Image(originalUrl = parsedUrl, type = type)
imageUrl.openStream().use { input -> parsedUrl.openStream().use { input ->
image.mimeType = URLConnection.guessContentTypeFromName(imageUrl.file) image.mimeType = URLConnection.guessContentTypeFromName(parsedUrl.file)
imageContentStore.setContent(image, input) imageContentStore.setContent(image, input)
} }
return imageRepository.save(image) return imageRepository.save(image)
@@ -18,5 +18,6 @@ class GameDto(
val perspectives: List<String>?, val perspectives: List<String>?,
val imageIds: List<Long>?, val imageIds: List<Long>?,
val videoUrls: List<String>?, val videoUrls: List<String>?,
val path: String,
val metadata: Map<String, GameMetadataDto> val metadata: Map<String, GameMetadataDto>
) )
@@ -5,7 +5,7 @@ import de.grimsi.gameyfin.pluginapi.gamemetadata.Genre
import de.grimsi.gameyfin.pluginapi.gamemetadata.PlayerPerspective import de.grimsi.gameyfin.pluginapi.gamemetadata.PlayerPerspective
import de.grimsi.gameyfin.pluginapi.gamemetadata.Theme import de.grimsi.gameyfin.pluginapi.gamemetadata.Theme
import jakarta.persistence.* import jakarta.persistence.*
import java.net.URL import java.net.URI
import java.time.Instant import java.time.Instant
@Entity @Entity
@@ -17,7 +17,7 @@ class Game(
val title: String, val title: String,
@OneToOne(cascade = [CascadeType.MERGE]) @OneToOne(cascade = [CascadeType.MERGE])
val coverImage: Image, val coverImage: Image? = null,
@Lob @Lob
@Column(columnDefinition = "CLOB") @Column(columnDefinition = "CLOB")
@@ -25,40 +25,40 @@ class Game(
@Lob @Lob
@Column(columnDefinition = "CLOB") @Column(columnDefinition = "CLOB")
val summary: String, val summary: String? = null,
val release: Instant, val release: Instant? = null,
@ManyToMany(cascade = [CascadeType.MERGE]) @ManyToMany(cascade = [CascadeType.MERGE])
val publishers: Set<Company>, val publishers: Set<Company>? = null,
@ManyToMany(cascade = [CascadeType.MERGE]) @ManyToMany(cascade = [CascadeType.MERGE])
val developers: Set<Company>, val developers: Set<Company>? = null,
@ElementCollection @ElementCollection
val genres: Set<Genre>, val genres: Set<Genre>? = null,
@ElementCollection @ElementCollection
val themes: Set<Theme>, val themes: Set<Theme>? = null,
@ElementCollection @ElementCollection
val keywords: Set<String>, val keywords: Set<String>? = null,
@ElementCollection @ElementCollection
val features: Set<GameFeature>, val features: Set<GameFeature>? = null,
@ElementCollection @ElementCollection
val perspectives: Set<PlayerPerspective>, val perspectives: Set<PlayerPerspective>? = null,
@OneToMany(cascade = [CascadeType.MERGE]) @OneToMany(cascade = [CascadeType.MERGE])
val images: Set<Image>, val images: Set<Image>? = null,
@ElementCollection @ElementCollection
val videoUrls: Set<URL>, val videoUrls: Set<URI>? = null,
@Column(unique = true) @Column(unique = true)
val path: String, val path: String,
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true) @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
val metadata: Map<String, FieldMetadata> val metadata: Map<String, FieldMetadata> = emptyMap()
) )
@@ -1,28 +1,29 @@
package de.grimsi.gameyfin.pluginapi.gamemetadata package de.grimsi.gameyfin.pluginapi.gamemetadata
import java.net.URL import java.net.URI
import java.time.Instant import java.time.Instant
class GameMetadata( class GameMetadata(
val title: String, val title: String,
val description: String, val description: String? = null,
val coverUrl: URL, val coverUrl: URI? = null,
val release: Instant, val release: Instant? = null,
val userRating: Int?, val userRating: Int? = null,
val criticRating: Int?, val criticRating: Int? = null,
val developedBy: Set<String>, val developedBy: Set<String>? = null,
val publishedBy: Set<String>, val publishedBy: Set<String>? = null,
val genres: Set<Genre>, val genres: Set<Genre>? = null,
val themes: Set<Theme>, val themes: Set<Theme>? = null,
val keywords: Set<String>, val keywords: Set<String>? = null,
val screenshotUrls: Set<URL>, val screenshotUrls: Set<URI>? = null,
val videoUrls: Set<URL>, val videoUrls: Set<URI>? = null,
val features: Set<GameFeature>, val features: Set<GameFeature>? = null,
val perspectives: Set<PlayerPerspective> val perspectives: Set<PlayerPerspective>? = null
) )
enum class Genre { enum class Genre {
UNKNOWN, UNKNOWN,
ACTION,
PINBALL, PINBALL,
ADVENTURE, ADVENTURE,
INDIE, INDIE,
@@ -30,6 +31,7 @@ enum class Genre {
VISUAL_NOVEL, VISUAL_NOVEL,
CARD_AND_BOARD_GAME, CARD_AND_BOARD_GAME,
MOBA, MOBA,
MMO,
POINT_AND_CLICK, POINT_AND_CLICK,
FIGHTING, FIGHTING,
SHOOTER, SHOOTER,
@@ -7,7 +7,7 @@ import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import de.grimsi.gameyfin.plugins.steam.dto.SteamDetailsResultWrapper import de.grimsi.gameyfin.plugins.steam.dto.SteamDetailsResultWrapper
import de.grimsi.gameyfin.plugins.steam.dto.SteamGame import de.grimsi.gameyfin.plugins.steam.dto.SteamGame
import de.grimsi.gameyfin.plugins.steam.dto.SteamSearchResult import de.grimsi.gameyfin.plugins.steam.dto.SteamSearchResult
import de.grimsi.gameyfin.plugins.steam.mapper.toGenre import de.grimsi.gameyfin.plugins.steam.mapper.Mapper
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
@@ -18,9 +18,6 @@ import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import me.xdrop.fuzzywuzzy.FuzzySearch import me.xdrop.fuzzywuzzy.FuzzySearch
import org.pf4j.Extension import org.pf4j.Extension
import org.pf4j.PluginWrapper import org.pf4j.PluginWrapper
@@ -29,16 +26,12 @@ import org.slf4j.LoggerFactory
import java.net.URI import java.net.URI
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.*
class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) { class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
override val configMetadata: List<PluginConfigElement> = emptyList() override val configMetadata: List<PluginConfigElement> = emptyList()
override fun validateConfig(config: Map<String, String?>): Boolean { override fun validateConfig(config: Map<String, String?>): Boolean {
// No config to validate
return true return true
} }
@@ -52,6 +45,10 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
} }
} }
/**
* The Steam Store API I am using provides far less info than IGDB for example
* See it more as a proof of concept than a fully functional plugin
**/
override fun fetchMetadata(gameId: String): GameMetadata? { override fun fetchMetadata(gameId: String): GameMetadata? {
val searchResult: List<SteamGame> = runBlocking { searchStore(gameId) } val searchResult: List<SteamGame> = runBlocking { searchStore(gameId) }
if (searchResult.isEmpty()) return null if (searchResult.isEmpty()) return null
@@ -91,9 +88,11 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
val steamDetailsResultWrapper: Map<Int, SteamDetailsResultWrapper> = Json.decodeFromString(responseBody) val steamDetailsResultWrapper: Map<Int, SteamDetailsResultWrapper> = Json.decodeFromString(responseBody)
if (!steamDetailsResultWrapper.containsKey(id)) return null if (!steamDetailsResultWrapper.containsKey(id)) return null
if (steamDetailsResultWrapper[id]?.success != true) return null
val game = steamDetailsResultWrapper[id]?.data ?: return null val game = steamDetailsResultWrapper[id]?.data ?: return null
// This is as much as I can get from the Steam Store API
val metadata = GameMetadata( val metadata = GameMetadata(
title = game.name, title = game.name,
description = game.detailedDescription, description = game.detailedDescription,
@@ -101,7 +100,7 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
release = game.releaseDate?.date, release = game.releaseDate?.date,
developedBy = game.developers.toSet(), developedBy = game.developers.toSet(),
publishedBy = game.publishers.toSet(), publishedBy = game.publishers.toSet(),
genres = game.genres.map { toGenre(it) }.toSet(), genres = game.genres.map { Mapper.genre(it) }.toSet(),
keywords = game.categories.mapNotNull { it.description }.toSet(), keywords = game.categories.mapNotNull { it.description }.toSet(),
screenshotUrls = game.screenshots.map { URI(it.pathFull!!) }.toSet(), screenshotUrls = game.screenshots.map { URI(it.pathFull!!) }.toSet(),
videoUrls = game.movies.map { URI(it.webm?.max!!) }.toSet() videoUrls = game.movies.map { URI(it.webm?.max!!) }.toSet()
@@ -109,19 +108,5 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
return metadata return metadata
} }
private fun string(json: JsonObject, key: String): String {
return json[key]?.jsonPrimitive?.content ?: ""
}
private fun stringList(json: JsonObject, key: String): List<String> {
return json[key]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()
}
fun date(dateString: String): Instant {
val formatter = DateTimeFormatter.ofPattern("dd MMM, yyyy", Locale.ENGLISH)
val localDate = LocalDate.parse(dateString, formatter)
return localDate.atStartOfDay().toInstant(ZoneOffset.UTC)
}
} }
} }
@@ -16,7 +16,7 @@ data class SteamGameDetails(
val type: String, val type: String,
val name: String, val name: String,
@SerialName("steam_appid") val steamAppId: Int, @SerialName("steam_appid") val steamAppId: Int,
@SerialName("required_age") val requiredAge: String, @SerialName("required_age") val requiredAge: Int,
@SerialName("is_free") val isFree: Boolean, @SerialName("is_free") val isFree: Boolean,
@SerialName("controller_support") val controllerSupport: String?, @SerialName("controller_support") val controllerSupport: String?,
val dlc: List<Int>?, val dlc: List<Int>?,
@@ -1,39 +0,0 @@
package de.grimsi.gameyfin.plugins.steam.mapper
import de.grimsi.gameyfin.pluginapi.gamemetadata.Genre
import de.grimsi.gameyfin.plugins.steam.dto.SteamGenre
fun toGenre(steamGenre: SteamGenre): Genre {
return when (steamGenre.id) {
1 -> Genre.ACTION
2 -> Genre.STRATEGY
25 -> Genre.ADVENTURE
23 -> Genre.INDIE
3 -> Genre.ROLE_PLAYING
28 -> Genre.SIMULATOR
29 -> Genre.MMO
9 -> Genre.RACING
18 -> Genre.SPORT
37 -> Genre.UNKNOWN // Free to Play doesn't match any genre directly
51 -> Genre.UNKNOWN // Animation & Modeling doesn't match any genre directly
58 -> Genre.UNKNOWN // Video Production doesn't match any genre directly
4 -> Genre.UNKNOWN // Casual doesn't match any genre directly
73 -> Genre.UNKNOWN // Violent doesn't map directly to a genre
72 -> Genre.UNKNOWN // Nudity doesn't match any genre directly
70 -> Genre.UNKNOWN // Early Access doesn't map directly to a genre
74 -> Genre.UNKNOWN // Gore doesn't match any genre directly
57 -> Genre.UNKNOWN // Utilities doesn't match any genre directly
52 -> Genre.UNKNOWN // Audio Production doesn't match any genre directly
53 -> Genre.UNKNOWN // Design & Illustration doesn't match any genre directly
59 -> Genre.UNKNOWN // Web Publishing doesn't map directly to a genre
55 -> Genre.UNKNOWN // Photo Editing doesn't map directly to a genre
54 -> Genre.UNKNOWN // Education doesn't match any genre directly
56 -> Genre.UNKNOWN // Software Training doesn't map directly to a genre
71 -> Genre.UNKNOWN // Sexual Content doesn't match any genre directly
60 -> Genre.UNKNOWN // Game Development doesn't map directly to a genre
50 -> Genre.UNKNOWN // Accounting doesn't map directly to a genre
81 -> Genre.UNKNOWN // Documentary doesn't map directly to a genre
84 -> Genre.UNKNOWN // Tutorial doesn't map directly to a genre
else -> Genre.UNKNOWN
}
}
@@ -0,0 +1,43 @@
package de.grimsi.gameyfin.plugins.steam.mapper
import de.grimsi.gameyfin.pluginapi.gamemetadata.Genre
import de.grimsi.gameyfin.plugins.steam.dto.SteamGenre
class Mapper {
companion object {
fun genre(steamGenre: SteamGenre): Genre {
return when (steamGenre.id) {
1 -> Genre.ACTION
2 -> Genre.STRATEGY
25 -> Genre.ADVENTURE
23 -> Genre.INDIE
3 -> Genre.ROLE_PLAYING
28 -> Genre.SIMULATOR
29 -> Genre.MMO
9 -> Genre.RACING
18 -> Genre.SPORT
37 -> Genre.UNKNOWN // Free to Play doesn't match any genre directly
51 -> Genre.UNKNOWN // Animation & Modeling doesn't match any genre directly
58 -> Genre.UNKNOWN // Video Production doesn't match any genre directly
4 -> Genre.UNKNOWN // Casual doesn't match any genre directly
73 -> Genre.UNKNOWN // Violent doesn't map directly to a genre
72 -> Genre.UNKNOWN // Nudity doesn't match any genre directly
70 -> Genre.UNKNOWN // Early Access doesn't map directly to a genre
74 -> Genre.UNKNOWN // Gore doesn't match any genre directly
57 -> Genre.UNKNOWN // Utilities doesn't match any genre directly
52 -> Genre.UNKNOWN // Audio Production doesn't match any genre directly
53 -> Genre.UNKNOWN // Design & Illustration doesn't match any genre directly
59 -> Genre.UNKNOWN // Web Publishing doesn't map directly to a genre
55 -> Genre.UNKNOWN // Photo Editing doesn't map directly to a genre
54 -> Genre.UNKNOWN // Education doesn't match any genre directly
56 -> Genre.UNKNOWN // Software Training doesn't map directly to a genre
71 -> Genre.UNKNOWN // Sexual Content doesn't match any genre directly
60 -> Genre.UNKNOWN // Game Development doesn't map directly to a genre
50 -> Genre.UNKNOWN // Accounting doesn't map directly to a genre
81 -> Genre.UNKNOWN // Documentary doesn't map directly to a genre
84 -> Genre.UNKNOWN // Tutorial doesn't map directly to a genre
else -> Genre.UNKNOWN
}
}
}
}