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 {Button} from "@nextui-org/react";
import {Button, Input} from "@nextui-org/react";
import {toast} from "sonner";
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() {
const [gameTitle, setGameTitle] = useState("");
const [game, setGame] = useState<Game>();
function getGame() {
LibraryEndpoint.test(gameTitle).then(game => {
if (game == undefined) return;
setGame(game);
});
}
return (
<div className="grow justify-center mt-12">
<div className="flex flex-col items-center gap-6">
@@ -38,7 +50,11 @@ export default function TestView() {
})}>Toast (Error)</Button>
</div>
<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>
);
@@ -1,6 +1,7 @@
package de.grimsi.gameyfin.games
import jakarta.persistence.*
import java.time.Instant
@Entity
class Game(
@@ -18,6 +19,16 @@ class Game(
@Column(columnDefinition = "CLOB")
val summary: String,
val release: Instant,
@ElementCollection
val publishers: List<String>,
@ElementCollection
val developers: List<String>,
@Column(unique = true)
val path: String
val path: String,
val source: String
)
@@ -1,7 +1,11 @@
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 kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import org.pf4j.PluginManager
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
@@ -14,8 +18,8 @@ class GameService(
) {
private val log = KotlinLogging.logger {}
private val metadataPlugins: List<GameMetadataFetcher>
get() = pluginManager.getExtensions(GameMetadataFetcher::class.java)
private val metadataPlugins: List<GameMetadataProvider>
get() = pluginManager.getExtensions(GameMetadataProvider::class.java)
fun createOrUpdate(game: Game): Game {
gameRepository.findByPath(game.path)?.let {
@@ -25,12 +29,34 @@ class GameService(
}
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(
title = metadata.title,
title = metadata!!.title,
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)
}
@@ -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 de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.games.Game
import jakarta.annotation.security.RolesAllowed
@Endpoint
@@ -19,7 +20,7 @@ class LibraryEndpoint(
}
@RolesAllowed(Role.Names.ADMIN)
fun test(testString: String) {
libraryService.test(testString)
fun test(testString: String): Game {
return libraryService.test(testString)
}
}
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.games.Game
import de.grimsi.gameyfin.games.GameService
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
@@ -17,8 +18,8 @@ class LibraryService(
private val gameService: GameService,
private val config: ConfigService
) {
fun test(testString: String) {
gameService.createFromFile(Path(testString))
fun test(testString: String): Game {
return gameService.createFromFile(Path(testString))
}
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
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.PluginConfigError
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.PluginWrapper
import java.time.Instant
@@ -48,15 +49,32 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
}
@Extension
class IgdbMetadataFetcher : GameMetadataFetcher {
override fun fetchMetadata(gameId: String): GameMetadata {
val findGameByName = APICalypse()
class IgdbMetadataProvider : GameMetadataProvider {
override fun fetchMetadata(gameId: String): GameMetadata? {
val findBySlugQuery = APICalypse()
.fields("*")
.limit(100)
.search(gameId)
.where("slug = \"${guessSlug(gameId)}\"")
val game = IGDBWrapper.games(findGameByName).filter { it.slug == gameId.lowercase() }.firstOrNull()
?: throw IllegalArgumentException("Could not match game with ID '$gameId'")
// First step: Try to find the game by guessing the slug
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(
title = game.name,
@@ -74,5 +92,9 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
perspectives = listOf()
)
}
private fun guessSlug(gameId: String): String {
return gameId.replace(" ", "-").lowercase()
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
Manifest-Version: 1.0
Plugin-Class: de.grimsi.gameyfin.plugins.igdb.IgdbPlugin
Plugin-Id: igdb
Plugin-Description: IGDB Plugin
Plugin-Description: IGDB Metadata
Plugin-Version: 1.0.0-alpha1
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("plugin.spring") 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:igdb")
include(":plugins:steam")