Proof of concept implementation for a Steam metadata provider plugin

Mainly for testing purposes
This commit is contained in:
grimsi
2024-11-01 15:41:25 +01:00
parent 34f00c091f
commit cebfc99ec2
17 changed files with 327 additions and 29 deletions
+24
View File
@@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Rebuild all" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="build" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+24
View File
@@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Rebuild plugins" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$/plugins" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="build" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+18 -2
View File
@@ -1,9 +1,21 @@
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import {Button} from "@nextui-org/react"; import {Button, Input} from "@nextui-org/react";
import {toast} from "sonner"; import {toast} from "sonner";
import {LibraryEndpoint, SystemEndpoint} from "Frontend/generated/endpoints.js"; import {LibraryEndpoint, SystemEndpoint} from "Frontend/generated/endpoints.js";
import {useState} from "react";
import Game from "Frontend/generated/de/grimsi/gameyfin/games/Game";
export default function TestView() { export default function TestView() {
const [gameTitle, setGameTitle] = useState("");
const [game, setGame] = useState<Game>();
function getGame() {
LibraryEndpoint.test(gameTitle).then(game => {
if (game == undefined) return;
setGame(game);
});
}
return ( return (
<div className="grow justify-center mt-12"> <div className="grow justify-center mt-12">
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
@@ -38,7 +50,11 @@ export default function TestView() {
})}>Toast (Error)</Button> })}>Toast (Error)</Button>
</div> </div>
<Button onPress={() => SystemEndpoint.restart()}>Restart</Button> <Button onPress={() => SystemEndpoint.restart()}>Restart</Button>
<Button onPress={() => LibraryEndpoint.test("Tetris")}>Test IGDB plugin</Button> <div className="flex flex-row gap-4 items-center">
<Input label="Game title" onValueChange={setGameTitle}/>
<Button onPress={getGame} size="lg">Match</Button>
</div>
{game && <>{JSON.stringify(game, null, 2)}</>}
</div> </div>
</div> </div>
); );
@@ -1,6 +1,7 @@
package de.grimsi.gameyfin.games package de.grimsi.gameyfin.games
import jakarta.persistence.* import jakarta.persistence.*
import java.time.Instant
@Entity @Entity
class Game( class Game(
@@ -18,6 +19,16 @@ class Game(
@Column(columnDefinition = "CLOB") @Column(columnDefinition = "CLOB")
val summary: String, val summary: String,
val release: Instant,
@ElementCollection
val publishers: List<String>,
@ElementCollection
val developers: List<String>,
@Column(unique = true) @Column(unique = true)
val path: String val path: String,
val source: String
) )
@@ -1,7 +1,11 @@
package de.grimsi.gameyfin.games package de.grimsi.gameyfin.games
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataFetcher import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
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
@@ -14,8 +18,8 @@ class GameService(
) { ) {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
private val metadataPlugins: List<GameMetadataFetcher> private val metadataPlugins: List<GameMetadataProvider>
get() = pluginManager.getExtensions(GameMetadataFetcher::class.java) get() = pluginManager.getExtensions(GameMetadataProvider::class.java)
fun createOrUpdate(game: Game): Game { fun createOrUpdate(game: Game): Game {
gameRepository.findByPath(game.path)?.let { gameRepository.findByPath(game.path)?.let {
@@ -25,12 +29,34 @@ class GameService(
} }
fun createFromFile(path: Path): Game { fun createFromFile(path: Path): Game {
val metadata = metadataPlugins.first().fetchMetadata(path.fileName.toString()) 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 (plugin, metadata) = metadataResults.entries.firstOrNull { it.value != null }
?: throw NoMatchException("Could not match game at $path")
val game = Game( val game = Game(
title = metadata.title, title = metadata!!.title,
summary = metadata.description, summary = metadata.description,
path = path.toString() release = metadata.release,
publishers = metadata.publishedBy,
developers = metadata.developedBy,
path = path.toString(),
source = plugin.javaClass.name
) )
return createOrUpdate(game) return createOrUpdate(game)
} }
@@ -0,0 +1,3 @@
package de.grimsi.gameyfin.games
class NoMatchException(message: String) : RuntimeException(message)
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.libraries
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.games.Game
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@Endpoint @Endpoint
@@ -19,7 +20,7 @@ class LibraryEndpoint(
} }
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun test(testString: String) { fun test(testString: String): Game {
libraryService.test(testString) return libraryService.test(testString)
} }
} }
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.config.ConfigProperties import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.games.Game
import de.grimsi.gameyfin.games.GameService import de.grimsi.gameyfin.games.GameService
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -17,8 +18,8 @@ class LibraryService(
private val gameService: GameService, private val gameService: GameService,
private val config: ConfigService private val config: ConfigService
) { ) {
fun test(testString: String) { fun test(testString: String): Game {
gameService.createFromFile(Path(testString)) return gameService.createFromFile(Path(testString))
} }
fun createOrUpdate(library: LibraryDto): LibraryDto { fun createOrUpdate(library: LibraryDto): LibraryDto {
@@ -1,7 +0,0 @@
package de.grimsi.gameyfin.pluginapi.gamemetadata
import org.pf4j.ExtensionPoint
interface GameMetadataFetcher : ExtensionPoint {
fun fetchMetadata(gameId: String): GameMetadata
}
@@ -0,0 +1,7 @@
package de.grimsi.gameyfin.pluginapi.gamemetadata
import org.pf4j.ExtensionPoint
interface GameMetadataProvider : ExtensionPoint {
fun fetchMetadata(gameId: String): GameMetadata?
}
+3
View File
@@ -7,4 +7,7 @@ dependencies {
// IGDB API client // IGDB API client
implementation("io.github.husnjak:igdb-api-jvm:1.2.0") implementation("io.github.husnjak:igdb-api-jvm:1.2.0")
// Fuzzy string matching
implementation("me.xdrop:fuzzywuzzy:1.4.0")
} }
@@ -8,7 +8,8 @@ import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.core.PluginConfigError import de.grimsi.gameyfin.pluginapi.core.PluginConfigError
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataFetcher import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import me.xdrop.fuzzywuzzy.FuzzySearch
import org.pf4j.Extension import org.pf4j.Extension
import org.pf4j.PluginWrapper import org.pf4j.PluginWrapper
import java.time.Instant import java.time.Instant
@@ -48,15 +49,32 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
} }
@Extension @Extension
class IgdbMetadataFetcher : GameMetadataFetcher { class IgdbMetadataProvider : GameMetadataProvider {
override fun fetchMetadata(gameId: String): GameMetadata { override fun fetchMetadata(gameId: String): GameMetadata? {
val findGameByName = APICalypse() val findBySlugQuery = APICalypse()
.fields("*") .fields("*")
.limit(100) .where("slug = \"${guessSlug(gameId)}\"")
.search(gameId)
val game = IGDBWrapper.games(findGameByName).filter { it.slug == gameId.lowercase() }.firstOrNull() // First step: Try to find the game by guessing the slug
?: throw IllegalArgumentException("Could not match game with ID '$gameId'") var game = IGDBWrapper.games(findBySlugQuery).firstOrNull()
// Second step: Try a fuzzy search
if (game == null) {
val searchByNameQuery = APICalypse()
.fields("*")
.limit(100)
.search(gameId)
// Use IGDBs search function to get a list of games that match the search query
val games = IGDBWrapper.games(searchByNameQuery)
if (games.isEmpty()) return null
// Use fuzzy search to find the best matching game name
val bestMatchingName = FuzzySearch.extractOne(gameId, games.map { it.name }).string
game = games.find { it.name == bestMatchingName } ?: return null
}
return GameMetadata( return GameMetadata(
title = game.name, title = game.name,
@@ -74,5 +92,9 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
perspectives = listOf() perspectives = listOf()
) )
} }
private fun guessSlug(gameId: String): String {
return gameId.replace(" ", "-").lowercase()
}
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
Manifest-Version: 1.0 Manifest-Version: 1.0
Plugin-Class: de.grimsi.gameyfin.plugins.igdb.IgdbPlugin Plugin-Class: de.grimsi.gameyfin.plugins.igdb.IgdbPlugin
Plugin-Id: igdb Plugin-Id: igdb
Plugin-Description: IGDB Plugin Plugin-Description: IGDB Metadata
Plugin-Version: 1.0.0-alpha1 Plugin-Version: 1.0.0-alpha1
Plugin-Provider: grimsi Plugin-Provider: grimsi
+21
View File
@@ -0,0 +1,21 @@
val ktor_version = "3.0.0"
plugins {
id("com.google.devtools.ksp") version "2.0.20-1.0.24"
kotlin("plugin.serialization")
}
repositories {
maven(url = "https://jitpack.io")
}
dependencies {
ksp("care.better.pf4j:pf4j-kotlin-symbol-processing:2.0.20-1.0.1")
implementation("io.ktor:ktor-client-core:$ktor_version")
implementation("io.ktor:ktor-client-cio:$ktor_version")
implementation("io.ktor:ktor-client-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
implementation("me.xdrop:fuzzywuzzy:1.4.0")
}
@@ -0,0 +1,138 @@
package de.grimsi.gameyfin.plugins.steam
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import me.xdrop.fuzzywuzzy.FuzzySearch
import org.pf4j.Extension
import org.pf4j.PluginWrapper
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.Locale
@Serializable
data class SteamSearchResult(
val total: Int,
val items: List<SteamGame>
)
@Serializable
data class SteamGame(
val type: String,
val name: String,
val id: Int,
val price: Price?,
val tiny_image: String,
val metascore: String?,
val platforms: Platforms,
val streamingvideo: Boolean,
val controller_support: String
)
@Serializable
data class Price(
val currency: String,
val initial: Int,
val final: Int
)
@Serializable
data class Platforms(
val windows: Boolean,
val mac: Boolean,
val linux: Boolean
)
class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
override val configMetadata: List<PluginConfigElement> = emptyList()
@Extension
class SteamMetadataProvider : GameMetadataProvider {
val client = HttpClient(CIO) {
install(ContentNegotiation) {
json()
}
}
override fun fetchMetadata(gameId: String): GameMetadata? {
val searchResult: List<SteamGame> = runBlocking { searchStore(gameId) }
if (searchResult.isEmpty()) return null
val bestMatchingTitle = FuzzySearch.extractOne(gameId, searchResult.map { it.name }).string
val bestMatch = searchResult.find { it.name == bestMatchingTitle } ?: return null
return runBlocking { getGameDetails(bestMatch.id) }
}
private suspend fun searchStore(title: String): List<SteamGame> {
val encodedTitle = URLEncoder.encode(title, StandardCharsets.UTF_8.toString())
val url = "https://store.steampowered.com/api/storesearch?term=$encodedTitle&cc=en"
return try {
val searchResult: SteamSearchResult = client.get(url).body()
searchResult.items
} catch (e: Exception) {
println(e.message)
emptyList()
}
}
private suspend fun getGameDetails(id: Int): GameMetadata? {
val url = "https://store.steampowered.com/api/appdetails?appids=$id"
val response: JsonObject = (client.get(url).body() as JsonObject)[id.toString()]?.jsonObject ?: return null
if (response["success"]?.jsonPrimitive?.boolean == false) return null
val game = response["data"]?.jsonObject ?: return null
val metadata = GameMetadata(
title = string(game, "name"),
description = string(game, "detailed_description"),
release = date(game["release_date"]?.jsonObject["date"]?.jsonPrimitive?.content!!),
userRating = 0,
criticRating = 0,
developedBy = stringList(game, "developers"),
publishedBy = stringList(game, "publishers"),
genres = emptyList(),
themes = emptyList(),
screenshotUrls = emptyList(),
videoUrls = emptyList(),
features = emptyList(),
perspectives = emptyList()
)
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)
}
}
}
@@ -0,0 +1,6 @@
Manifest-Version: 1.0
Plugin-Class: de.grimsi.gameyfin.plugins.steam.SteamPlugin
Plugin-Id: steam
Plugin-Description: Steam Metadata
Plugin-Version: 1.0.0-alpha1
Plugin-Provider: grimsi
+2
View File
@@ -12,6 +12,7 @@ pluginManagement {
kotlin("jvm") version extra["kotlinVersion"] as String kotlin("jvm") version extra["kotlinVersion"] as String
kotlin("plugin.spring") version extra["kotlinVersion"] as String kotlin("plugin.spring") version extra["kotlinVersion"] as String
kotlin("plugin.jpa") version extra["kotlinVersion"] as String kotlin("plugin.jpa") version extra["kotlinVersion"] as String
kotlin("plugin.serialization") version extra["kotlinVersion"] as String
} }
} }
@@ -22,3 +23,4 @@ include(":gameyfin")
include(":plugins") include(":plugins")
include(":plugins:igdb") include(":plugins:igdb")
include(":plugins:steam")