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 {
repositories {
mavenLocal()
mavenCentral()
mavenLocal()
}
}
@@ -14,7 +14,8 @@ import kotlin.io.path.Path
@Component
class GameyfinPluginManager(
val pluginConfigRepository: PluginConfigRepository,
val dbPluginStatusProvider: DatabasePluginStatusProvider
val dbPluginStatusProvider: DatabasePluginStatusProvider,
val pluginManagementRepository: PluginManagementRepository
) : DefaultPluginManager(Path(System.getProperty("pf4j.pluginsDir", "plugins"))) {
private val log = KotlinLogging.logger {}
@@ -55,8 +56,8 @@ class GameyfinPluginManager(
override fun loadPluginFromPath(pluginPath: Path?): PluginWrapper? {
val pluginWrapper = super.loadPluginFromPath(pluginPath)
// Inject config after loading, before starting
if (pluginWrapper != null) {
// Inject config after loading, before starting
configurePlugin(pluginWrapper)
}
@@ -64,7 +65,7 @@ class GameyfinPluginManager(
}
override fun startPlugin(pluginId: String?): PluginState? {
if(pluginId == null) return PluginState.FAILED
if (pluginId == null) return PluginState.FAILED
// Validate config before starting the plugin
if (!validatePluginConfig(pluginId)) {
@@ -94,7 +95,7 @@ class GameyfinPluginManager(
}
try {
log.info { "Start plugin '${getPluginLabel(pluginWrapper.descriptor)}'"}
log.info { "Start plugin '${getPluginLabel(pluginWrapper.descriptor)}'" }
pluginWrapper.plugin.start()
pluginWrapper.pluginState = PluginState.STARTED
pluginWrapper.failedException = null
@@ -102,7 +103,7 @@ class GameyfinPluginManager(
} catch (e: Exception) {
pluginWrapper.pluginState = PluginState.FAILED
pluginWrapper.failedException = e
log.error { "Unable to start plugin '${getPluginLabel(pluginWrapper.descriptor)}': $e"}
log.error { "Unable to start plugin '${getPluginLabel(pluginWrapper.descriptor)}': $e" }
} finally {
firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginState))
}
@@ -1,19 +1,14 @@
package de.grimsi.gameyfin.core.plugins.management
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
@Entity
@Table(name = "plugin_management")
data class PluginManagementEntry(
@Id
@Column(name = "plugin_id")
val pluginId: String,
@NotNull
@Column(name = "enabled")
var enabled: Boolean = true
var enabled: Boolean = true,
var priority: Int = Int.MAX_VALUE
)
@@ -17,7 +17,7 @@ import kotlinx.coroutines.runBlocking
import org.pf4j.PluginManager
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import java.net.URL
import java.net.URI
import java.net.URLConnection
import java.nio.file.Path
@@ -43,20 +43,7 @@ class GameService(
}
fun createFromFile(path: Path): GameDto {
val metadataResults: Map<GameMetadataProvider, GameMetadata?> = runBlocking {
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 metadataResults = queryPlugins(path.fileName.toString())
val (plugin, metadata) = metadataResults.entries.firstOrNull { it.value != null }
?: 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")
}
/**
* 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 {
val gameId = game.id ?: throw IllegalArgumentException("Game ID is null")
return GameDto(
id = gameId,
title = game.title,
coverId = game.coverImage.id,
coverId = game.coverImage?.id,
comment = game.comment,
summary = game.summary,
release = game.release,
publishers = game.publishers.map { it.name },
developers = game.developers.map { it.name },
genres = game.genres.map { it.name },
themes = game.themes.map { it.name },
keywords = game.keywords.toList(),
features = game.features.map { it.name },
perspectives = game.perspectives.map { it.name },
imageIds = game.images.mapNotNull { it.id },
videoUrls = game.videoUrls.map { it.toString() },
publishers = game.publishers?.map { it.name },
developers = game.developers?.map { it.name },
genres = game.genres?.map { it.name },
themes = game.themes?.map { it.name },
keywords = game.keywords?.toList(),
features = game.features?.map { it.name },
perspectives = game.perspectives?.map { it.name },
imageIds = game.images?.mapNotNull { it.id },
videoUrls = game.videoUrls?.map { it.toString() },
path = game.path,
metadata = toDto(game.metadata)
)
}
@@ -124,16 +134,16 @@ class GameService(
return Game(
title = metadata.title,
summary = metadata.description,
coverImage = downloadAndPersist(metadata.coverUrl, ImageType.COVER),
coverImage = metadata.coverUrl?.let { downloadAndPersist(it, ImageType.COVER) },
release = metadata.release,
publishers = metadata.publishedBy.map { toEntity(it, CompanyType.PUBLISHER) }.toSet(),
developers = metadata.developedBy.map { toEntity(it, CompanyType.DEVELOPER) }.toSet(),
publishers = metadata.publishedBy?.map { toEntity(it, CompanyType.PUBLISHER) }?.toSet(),
developers = metadata.developedBy?.map { toEntity(it, CompanyType.DEVELOPER) }?.toSet(),
genres = metadata.genres,
themes = metadata.themes,
keywords = metadata.keywords,
features = metadata.features,
perspectives = metadata.perspectives,
images = metadata.screenshotUrls.map { downloadAndPersist(it, ImageType.SCREENSHOT) }.toSet(),
images = metadata.screenshotUrls?.map { downloadAndPersist(it, ImageType.SCREENSHOT) }?.toSet(),
videoUrls = metadata.videoUrls,
path = path.toString(),
metadata = mapOf("title" to FieldMetadata(sourcePlugin))
@@ -146,12 +156,13 @@ class GameService(
return companyRepository.save(company)
}
private fun downloadAndPersist(imageUrl: URL, type: ImageType): Image {
imageRepository.findByOriginalUrl(imageUrl)?.let { return it }
private fun downloadAndPersist(imageUrl: URI, type: ImageType): Image {
val parsedUrl = imageUrl.toURL()
imageRepository.findByOriginalUrl(parsedUrl)?.let { return it }
val image = Image(originalUrl = imageUrl, type = type)
imageUrl.openStream().use { input ->
image.mimeType = URLConnection.guessContentTypeFromName(imageUrl.file)
val image = Image(originalUrl = parsedUrl, type = type)
parsedUrl.openStream().use { input ->
image.mimeType = URLConnection.guessContentTypeFromName(parsedUrl.file)
imageContentStore.setContent(image, input)
}
return imageRepository.save(image)
@@ -18,5 +18,6 @@ class GameDto(
val perspectives: List<String>?,
val imageIds: List<Long>?,
val videoUrls: List<String>?,
val path: String,
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.Theme
import jakarta.persistence.*
import java.net.URL
import java.net.URI
import java.time.Instant
@Entity
@@ -17,7 +17,7 @@ class Game(
val title: String,
@OneToOne(cascade = [CascadeType.MERGE])
val coverImage: Image,
val coverImage: Image? = null,
@Lob
@Column(columnDefinition = "CLOB")
@@ -25,40 +25,40 @@ class Game(
@Lob
@Column(columnDefinition = "CLOB")
val summary: String,
val summary: String? = null,
val release: Instant,
val release: Instant? = null,
@ManyToMany(cascade = [CascadeType.MERGE])
val publishers: Set<Company>,
val publishers: Set<Company>? = null,
@ManyToMany(cascade = [CascadeType.MERGE])
val developers: Set<Company>,
val developers: Set<Company>? = null,
@ElementCollection
val genres: Set<Genre>,
val genres: Set<Genre>? = null,
@ElementCollection
val themes: Set<Theme>,
val themes: Set<Theme>? = null,
@ElementCollection
val keywords: Set<String>,
val keywords: Set<String>? = null,
@ElementCollection
val features: Set<GameFeature>,
val features: Set<GameFeature>? = null,
@ElementCollection
val perspectives: Set<PlayerPerspective>,
val perspectives: Set<PlayerPerspective>? = null,
@OneToMany(cascade = [CascadeType.MERGE])
val images: Set<Image>,
val images: Set<Image>? = null,
@ElementCollection
val videoUrls: Set<URL>,
val videoUrls: Set<URI>? = null,
@Column(unique = true)
val path: String,
@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
import java.net.URL
import java.net.URI
import java.time.Instant
class GameMetadata(
val title: String,
val description: String,
val coverUrl: URL,
val release: Instant,
val userRating: Int?,
val criticRating: Int?,
val developedBy: Set<String>,
val publishedBy: Set<String>,
val genres: Set<Genre>,
val themes: Set<Theme>,
val keywords: Set<String>,
val screenshotUrls: Set<URL>,
val videoUrls: Set<URL>,
val features: Set<GameFeature>,
val perspectives: Set<PlayerPerspective>
val description: String? = null,
val coverUrl: URI? = null,
val release: Instant? = null,
val userRating: Int? = null,
val criticRating: Int? = null,
val developedBy: Set<String>? = null,
val publishedBy: Set<String>? = null,
val genres: Set<Genre>? = null,
val themes: Set<Theme>? = null,
val keywords: Set<String>? = null,
val screenshotUrls: Set<URI>? = null,
val videoUrls: Set<URI>? = null,
val features: Set<GameFeature>? = null,
val perspectives: Set<PlayerPerspective>? = null
)
enum class Genre {
UNKNOWN,
ACTION,
PINBALL,
ADVENTURE,
INDIE,
@@ -30,6 +31,7 @@ enum class Genre {
VISUAL_NOVEL,
CARD_AND_BOARD_GAME,
MOBA,
MMO,
POINT_AND_CLICK,
FIGHTING,
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.SteamGame
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.call.*
import io.ktor.client.engine.cio.*
@@ -18,9 +18,6 @@ import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking
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 org.pf4j.Extension
import org.pf4j.PluginWrapper
@@ -29,16 +26,12 @@ import org.slf4j.LoggerFactory
import java.net.URI
import java.net.URLEncoder
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) {
override val configMetadata: List<PluginConfigElement> = emptyList()
override fun validateConfig(config: Map<String, String?>): Boolean {
// No config to validate
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? {
val searchResult: List<SteamGame> = runBlocking { searchStore(gameId) }
if (searchResult.isEmpty()) return null
@@ -91,9 +88,11 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
val steamDetailsResultWrapper: Map<Int, SteamDetailsResultWrapper> = Json.decodeFromString(responseBody)
if (!steamDetailsResultWrapper.containsKey(id)) return null
if (steamDetailsResultWrapper[id]?.success != true) 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(
title = game.name,
description = game.detailedDescription,
@@ -101,7 +100,7 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
release = game.releaseDate?.date,
developedBy = game.developers.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(),
screenshotUrls = game.screenshots.map { URI(it.pathFull!!) }.toSet(),
videoUrls = game.movies.map { URI(it.webm?.max!!) }.toSet()
@@ -109,19 +108,5 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
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 name: String,
@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("controller_support") val controllerSupport: String?,
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
}
}
}
}