From cebfc99ec2820b271cfedf36f5f4e21d4df91ab7 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Fri, 1 Nov 2024 15:41:25 +0100 Subject: [PATCH] Proof of concept implementation for a Steam metadata provider plugin Mainly for testing purposes --- .run/Rebuild all.run.xml | 24 +++ .run/Rebuild plugins.run.xml | 24 +++ gameyfin/src/main/frontend/views/TestView.tsx | 20 ++- .../kotlin/de/grimsi/gameyfin/games/Game.kt | 13 +- .../de/grimsi/gameyfin/games/GameService.kt | 38 ++++- .../grimsi/gameyfin/games/NoMatchException.kt | 3 + .../gameyfin/libraries/LibraryEndpoint.kt | 5 +- .../gameyfin/libraries/LibraryService.kt | 5 +- .../gamemetadata/GameMetadataFetcher.kt | 7 - .../gamemetadata/GameMetadataProvider.kt | 7 + plugins/igdb/build.gradle.kts | 3 + .../gameyfin/plugins/igdb/IgdbPlugin.kt | 38 ++++- plugins/igdb/src/main/resources/MANIFEST.MF | 2 +- plugins/steam/build.gradle.kts | 21 +++ .../gameyfin/plugins/steam/SteamPlugin.kt | 138 ++++++++++++++++++ plugins/steam/src/main/resources/MANIFEST.MF | 6 + settings.gradle.kts | 2 + 17 files changed, 327 insertions(+), 29 deletions(-) create mode 100644 .run/Rebuild all.run.xml create mode 100644 .run/Rebuild plugins.run.xml create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/NoMatchException.kt delete mode 100644 plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/gamemetadata/GameMetadataFetcher.kt create mode 100644 plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/gamemetadata/GameMetadataProvider.kt create mode 100644 plugins/steam/build.gradle.kts create mode 100644 plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/SteamPlugin.kt create mode 100644 plugins/steam/src/main/resources/MANIFEST.MF diff --git a/.run/Rebuild all.run.xml b/.run/Rebuild all.run.xml new file mode 100644 index 0000000..15b4c09 --- /dev/null +++ b/.run/Rebuild all.run.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/.run/Rebuild plugins.run.xml b/.run/Rebuild plugins.run.xml new file mode 100644 index 0000000..ecc2410 --- /dev/null +++ b/.run/Rebuild plugins.run.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/gameyfin/src/main/frontend/views/TestView.tsx b/gameyfin/src/main/frontend/views/TestView.tsx index 8e1335c..96879b0 100644 --- a/gameyfin/src/main/frontend/views/TestView.tsx +++ b/gameyfin/src/main/frontend/views/TestView.tsx @@ -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(); + + function getGame() { + LibraryEndpoint.test(gameTitle).then(game => { + if (game == undefined) return; + setGame(game); + }); + } + return (
@@ -38,7 +50,11 @@ export default function TestView() { })}>Toast (Error)
- +
+ + +
+ {game && <>{JSON.stringify(game, null, 2)}}
); diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/Game.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/Game.kt index 290e6ab..b8781e5 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/Game.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/Game.kt @@ -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, + + @ElementCollection + val developers: List, + @Column(unique = true) - val path: String + val path: String, + + val source: String ) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt index ffb0347..93b47bb 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt @@ -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 - get() = pluginManager.getExtensions(GameMetadataFetcher::class.java) + private val metadataPlugins: List + 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 = 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) } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/NoMatchException.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/NoMatchException.kt new file mode 100644 index 0000000..a32c9f4 --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/NoMatchException.kt @@ -0,0 +1,3 @@ +package de.grimsi.gameyfin.games + +class NoMatchException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt index af2ccd6..e65a40d 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt @@ -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) } } \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt index cd786fd..7e0b38a 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt @@ -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 { diff --git a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/gamemetadata/GameMetadataFetcher.kt b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/gamemetadata/GameMetadataFetcher.kt deleted file mode 100644 index e699202..0000000 --- a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/gamemetadata/GameMetadataFetcher.kt +++ /dev/null @@ -1,7 +0,0 @@ -package de.grimsi.gameyfin.pluginapi.gamemetadata - -import org.pf4j.ExtensionPoint - -interface GameMetadataFetcher : ExtensionPoint { - fun fetchMetadata(gameId: String): GameMetadata -} \ No newline at end of file diff --git a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/gamemetadata/GameMetadataProvider.kt b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/gamemetadata/GameMetadataProvider.kt new file mode 100644 index 0000000..cb29bd1 --- /dev/null +++ b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/gamemetadata/GameMetadataProvider.kt @@ -0,0 +1,7 @@ +package de.grimsi.gameyfin.pluginapi.gamemetadata + +import org.pf4j.ExtensionPoint + +interface GameMetadataProvider : ExtensionPoint { + fun fetchMetadata(gameId: String): GameMetadata? +} \ No newline at end of file diff --git a/plugins/igdb/build.gradle.kts b/plugins/igdb/build.gradle.kts index 672c109..e35fb8e 100644 --- a/plugins/igdb/build.gradle.kts +++ b/plugins/igdb/build.gradle.kts @@ -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") } \ No newline at end of file diff --git a/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt b/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt index 4c6014f..3f7b26a 100644 --- a/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt +++ b/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt @@ -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() + } } } \ No newline at end of file diff --git a/plugins/igdb/src/main/resources/MANIFEST.MF b/plugins/igdb/src/main/resources/MANIFEST.MF index c398490..fdcf8d8 100644 --- a/plugins/igdb/src/main/resources/MANIFEST.MF +++ b/plugins/igdb/src/main/resources/MANIFEST.MF @@ -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 diff --git a/plugins/steam/build.gradle.kts b/plugins/steam/build.gradle.kts new file mode 100644 index 0000000..baf6fc3 --- /dev/null +++ b/plugins/steam/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/SteamPlugin.kt b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/SteamPlugin.kt new file mode 100644 index 0000000..7833c8d --- /dev/null +++ b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/SteamPlugin.kt @@ -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 +) + +@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 = emptyList() + + @Extension + class SteamMetadataProvider : GameMetadataProvider { + val client = HttpClient(CIO) { + install(ContentNegotiation) { + json() + } + } + + override fun fetchMetadata(gameId: String): GameMetadata? { + val searchResult: List = 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 { + 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 { + 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) + } + } +} \ No newline at end of file diff --git a/plugins/steam/src/main/resources/MANIFEST.MF b/plugins/steam/src/main/resources/MANIFEST.MF new file mode 100644 index 0000000..dd0d496 --- /dev/null +++ b/plugins/steam/src/main/resources/MANIFEST.MF @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts index 18a2dc5..65b0ecd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")