mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Proof of concept implementation for a Steam metadata provider plugin
Mainly for testing purposes
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package de.grimsi.gameyfin.pluginapi.gamemetadata
|
||||
|
||||
import org.pf4j.ExtensionPoint
|
||||
|
||||
interface GameMetadataFetcher : ExtensionPoint {
|
||||
fun fetchMetadata(gameId: String): GameMetadata
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package de.grimsi.gameyfin.pluginapi.gamemetadata
|
||||
|
||||
import org.pf4j.ExtensionPoint
|
||||
|
||||
interface GameMetadataProvider : ExtensionPoint {
|
||||
fun fetchMetadata(gameId: String): GameMetadata?
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user