Extend GameMetadataProvider with fetchById

This commit is contained in:
grimsi
2025-06-12 19:53:09 +02:00
parent ddfaeed34a
commit 0633bb14e7
6 changed files with 72 additions and 25 deletions
@@ -124,7 +124,7 @@ class GameService(
// 1. Query all plugins for up to 5 results each // 1. Query all plugins for up to 5 results each
val results = metadataPlugins.flatMap { plugin -> val results = metadataPlugins.flatMap { plugin ->
try { try {
plugin.fetchMetadata(searchTerm, 5) plugin.fetchByTitle(searchTerm, 5)
// Filter out invalid results (null release or coverUrl) // Filter out invalid results (null release or coverUrl)
.filter { it.release != null && it.coverUrl != null } .filter { it.release != null && it.coverUrl != null }
.map { plugin to it } .map { plugin to it }
@@ -240,7 +240,7 @@ class GameService(
metadataPlugins.associateWith { metadataPlugins.associateWith {
async { async {
try { try {
it.fetchMetadata(gameTitle).firstOrNull() it.fetchByTitle(gameTitle).firstOrNull()
} catch (e: Exception) { } catch (e: Exception) {
log.error(e) { "Error fetching metadata for game with plugin ${it.javaClass.name}" } log.error(e) { "Error fetching metadata for game with plugin ${it.javaClass.name}" }
null null
@@ -3,5 +3,7 @@ package de.grimsi.gameyfin.pluginapi.gamemetadata
import org.pf4j.ExtensionPoint import org.pf4j.ExtensionPoint
interface GameMetadataProvider : ExtensionPoint { interface GameMetadataProvider : ExtensionPoint {
fun fetchMetadata(gameId: String, maxResults: Int = 1): List<GameMetadata> fun fetchByTitle(gameTitle: String, maxResults: Int = 1): List<GameMetadata>
fun fetchById(id: String): GameMetadata?
} }
@@ -126,37 +126,52 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
).joinToString(",") ).joinToString(",")
} }
override fun fetchMetadata(gameId: String, maxResults: Int): List<GameMetadata> { override fun fetchByTitle(gameTitle: String, maxResults: Int): List<GameMetadata> {
try {
// Note: Limit is intentionally set high because IGDBs ranking algorithm is not very good // Note: Limit is intentionally set high because IGDBs ranking algorithm is not very good
val searchByNameQuery = APICalypse() val searchByNameQuery = APICalypse()
.fields(QUERY_FIELDS) .fields(QUERY_FIELDS)
.limit(100) .limit(100)
.search(gameId) .search(gameTitle)
// Use IGDBs search function to get a list of games that match the search query // Use IGDBs search function to get a list of games that match the search query
var games = IGDBWrapper.games(searchByNameQuery) var games = queryIgdbGames(searchByNameQuery)
if (games.isEmpty()) return emptyList() if (games.isEmpty()) return emptyList()
// Use fuzzy search to find the best matching game name // Use fuzzy search to find the best matching game name
val bestMatchingTitles = FuzzySearch.extractTop(gameId, games.map { it.name }, maxResults) val bestMatchingTitles = FuzzySearch.extractTop(gameTitle, games.map { it.name }, maxResults)
games = bestMatchingTitles.mapNotNull { title -> games.find { it.name == title.string } } games = bestMatchingTitles.mapNotNull { title -> games.find { it.name == title.string } }
return games.map { toGameMetadata(it) } return games.map { toGameMetadata(it) }
}
override fun fetchById(id: String): GameMetadata? {
// For slug we can limit the results to 1, since slugs are unique
val findBySlugQuery = APICalypse()
.fields(QUERY_FIELDS)
.limit(1)
.where("slug = \"$id\"")
val game = queryIgdbGames(findBySlugQuery).firstOrNull()
return game?.let { toGameMetadata(it) }
}
private fun queryIgdbGames(query: APICalypse): List<Game> {
return try {
IGDBWrapper.games(query)
} catch (e: RequestException) { } catch (e: RequestException) {
// FIXME: Handle rate limit errors with exponential backoff // FIXME: Handle rate limit errors with exponential backoff
if (e.statusCode == 429) { if (e.statusCode == 429) {
val randomInterval = (1..5).random().toLong() val randomInterval = (1..5).random().toLong()
log.warn("IGDB rate limit exceeded, retrying in $randomInterval seconds...") log.warn("IGDB rate limit exceeded, retrying in $randomInterval seconds...")
TimeUnit.SECONDS.sleep(randomInterval) TimeUnit.SECONDS.sleep(randomInterval)
return fetchMetadata(gameId, maxResults)
queryIgdbGames(query)
} }
log.error("Request to IGDB API failed with HTTP ${e.statusCode}") log.error("Request to IGDB API failed with HTTP ${e.statusCode}")
emptyList()
} }
return emptyList()
} }
private fun toGameMetadata(game: Game): GameMetadata { private fun toGameMetadata(game: Game): GameMetadata {
@@ -48,16 +48,21 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
* The Steam Store API I am using provides far less info than IGDB for example * 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 * See it more as a proof of concept than a fully functional plugin
**/ **/
override fun fetchMetadata(gameId: String, maxResults: Int): List<GameMetadata> { override fun fetchByTitle(gameTitle: String, maxResults: Int): List<GameMetadata> {
val searchResult: List<SteamGame> = runBlocking { searchStore(gameId) } val searchResult: List<SteamGame> = runBlocking { searchStore(gameTitle) }
if (searchResult.isEmpty()) return emptyList() if (searchResult.isEmpty()) return emptyList()
val bestMatchingTitles = FuzzySearch.extractTop(gameId, searchResult.map { it.name }, maxResults) val bestMatchingTitles = FuzzySearch.extractTop(gameTitle, searchResult.map { it.name }, maxResults)
val bestMatches = bestMatchingTitles.mapNotNull { title -> searchResult.find { it.name == title.string } } val bestMatches = bestMatchingTitles.mapNotNull { title -> searchResult.find { it.name == title.string } }
return runBlocking { bestMatches.map { getGameDetails(it.id) } }.filterNotNull() return runBlocking { bestMatches.map { getGameDetails(it.id) } }.filterNotNull()
} }
override fun fetchById(id: String): GameMetadata? {
val id = id.toIntOrNull() ?: return null
return runBlocking { getGameDetails(id) }
}
private suspend fun searchStore(title: String): List<SteamGame> { private suspend fun searchStore(title: String): List<SteamGame> {
val encodedTitle = URLEncoder.encode(title, StandardCharsets.UTF_8.toString()) val encodedTitle = URLEncoder.encode(title, StandardCharsets.UTF_8.toString())
return try { return try {
@@ -72,9 +72,9 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
@Extension @Extension
class SteamGridDBGameCoverProvider : GameMetadataProvider { class SteamGridDBGameCoverProvider : GameMetadataProvider {
override fun fetchMetadata(gameId: String, maxResults: Int): List<GameMetadata> { override fun fetchByTitle(gameTitle: String, maxResults: Int): List<GameMetadata> {
return runBlocking { return runBlocking {
var searchResults = searchSteamGridDb(gameId) var searchResults = searchSteamGridDb(gameTitle)
if (searchResults.isEmpty()) return@runBlocking emptyList() if (searchResults.isEmpty()) return@runBlocking emptyList()
if (searchResults.size > maxResults) searchResults = searchResults.slice(0 until maxResults) if (searchResults.size > maxResults) searchResults = searchResults.slice(0 until maxResults)
@@ -91,6 +91,19 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
} }
} }
override fun fetchById(id: String): GameMetadata? {
return runBlocking {
val gameId = id.toIntOrNull() ?: return@runBlocking null
val game = getGameById(gameId) ?: return@runBlocking null
return@runBlocking GameMetadata(
originalId = game.id.toString(),
title = game.name,
coverUrl = getGridForGame(game.id)?.let { grid -> URI(grid.url) }
)
}
}
private suspend fun searchSteamGridDb(term: String): List<SteamGridDbGame> { private suspend fun searchSteamGridDb(term: String): List<SteamGridDbGame> {
val client = client ?: throw PluginConfigError("SteamGridDB API client not initialized") val client = client ?: throw PluginConfigError("SteamGridDB API client not initialized")
@@ -110,5 +123,13 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
return gameDetails.data?.firstOrNull() return gameDetails.data?.firstOrNull()
} }
private suspend fun getGameById(gameId: Int): SteamGridDbGame? {
val client = client ?: throw PluginConfigError("SteamGridDB API client not initialized")
val gameDetails = client.game(gameId)
return gameDetails.data?.firstOrNull()
}
} }
} }
@@ -51,6 +51,10 @@ class SteamGridDbApiClient(private val apiKey: String) {
}.body() }.body()
} }
suspend fun game(gameId: Int, block: HttpRequestBuilder.() -> Unit = {}): SteamGridDbSearchResult {
return get("games/id/$gameId", block).body()
}
private suspend fun get(endpoint: String, block: HttpRequestBuilder.() -> Unit = {}): HttpResponse { private suspend fun get(endpoint: String, block: HttpRequestBuilder.() -> Unit = {}): HttpResponse {
return client.get("$BASE_URL/$endpoint".encodeURLPath(encodeEncoded = false)) { return client.get("$BASE_URL/$endpoint".encodeURLPath(encodeEncoded = false)) {
bearerAuth(apiKey) bearerAuth(apiKey)