From ed95400d2d8470a2e2da8a8577d98e5ada925152 Mon Sep 17 00:00:00 2001 From: GRIMSIM Date: Wed, 18 Jun 2025 17:48:21 +0200 Subject: [PATCH 1/3] Try to parse release date from store page Use short_description because of relevance of content --- plugins/steam/build.gradle.kts | 1 + .../plugins/metadata/steam/SteamPlugin.kt | 31 +++++++++++++++++-- .../metadata/steam/dto/SteamGameDetails.kt | 1 + .../steam/util/SteamDateSerializer.kt | 21 +++++++++++-- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/plugins/steam/build.gradle.kts b/plugins/steam/build.gradle.kts index 5ca6034..85e3c6f 100644 --- a/plugins/steam/build.gradle.kts +++ b/plugins/steam/build.gradle.kts @@ -22,4 +22,5 @@ dependencies { } implementation("me.xdrop:fuzzywuzzy:1.4.0") + implementation("org.jsoup:jsoup:1.20.1") } \ No newline at end of file diff --git a/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/SteamPlugin.kt b/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/SteamPlugin.kt index 0d3a759..26730a0 100644 --- a/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/SteamPlugin.kt +++ b/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/SteamPlugin.kt @@ -19,10 +19,13 @@ import org.gameyfin.plugins.metadata.steam.dto.SteamDetailsResultWrapper import org.gameyfin.plugins.metadata.steam.dto.SteamGame import org.gameyfin.plugins.metadata.steam.dto.SteamSearchResult import org.gameyfin.plugins.metadata.steam.mapper.Mapper +import org.gameyfin.plugins.metadata.steam.util.SteamDateSerializer +import org.jsoup.Jsoup import org.pf4j.Extension import org.pf4j.PluginWrapper import org.slf4j.LoggerFactory import java.net.URI +import java.time.Instant class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) { @@ -31,6 +34,8 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) { isLenient = true ignoreUnknownKeys = true } + + val dateSerializer = SteamDateSerializer() } @Extension(ordinal = 3) @@ -114,9 +119,9 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) { val metadata = GameMetadata( originalId = id.toString(), title = sanitizeTitle(game.name), - description = game.detailedDescription, + description = game.shortDescription, // Using short description since the detailed description often contains just some ads for the Battle Pass etc. coverUrls = game.headerImage?.let { URI(it) }?.let { listOf(it) }, - release = game.releaseDate?.date, + release = parseOriginalReleaseDateFromStorePage(id) ?: game.releaseDate?.date, developedBy = game.developers?.toSet(), publishedBy = game.publishers?.toSet(), genres = game.genres?.let { genre -> genre.map { Mapper.genre(it) }.toSet() }, @@ -128,6 +133,28 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) { return metadata } + /** + * The API only provides the release date on Steam, not the original release date. + * However, it is possible to get the original release date from the Steam store page. + */ + private suspend fun parseOriginalReleaseDateFromStorePage(appId: Int): Instant? { + val response = client.get("https://store.steampowered.com/app/$appId") { + // Set language to English to avoid issues with different languages + cookie("Steam_Language", "english") + // Skip Steam age check + cookie("birthtime", "-2208989360") + cookie("lastagecheckage", "1-January-1900") + } + + if (response.status != HttpStatusCode.OK) return null + + val html: String = response.bodyAsText(Charsets.UTF_8) + val document = Jsoup.parse(html) + val releaseDateText = document.selectFirst("div.release_date div.date") ?: return null + + return dateSerializer.deserialize(releaseDateText.text()) + } + /** * Often titles on Steam contain copyright symbols which makes matching between different providers harder diff --git a/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/dto/SteamGameDetails.kt b/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/dto/SteamGameDetails.kt index 6af18c5..5ade582 100644 --- a/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/dto/SteamGameDetails.kt +++ b/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/dto/SteamGameDetails.kt @@ -15,6 +15,7 @@ data class SteamDetailsResultWrapper( data class SteamGameDetails( val type: String, val name: String, + @SerialName("short_description") val shortDescription: String? = null, @SerialName("detailed_description") val detailedDescription: String? = null, @SerialName("header_image") val headerImage: String? = null, val developers: List? = null, diff --git a/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/util/SteamDateSerializer.kt b/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/util/SteamDateSerializer.kt index 9ca09f1..160b578 100644 --- a/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/util/SteamDateSerializer.kt +++ b/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/util/SteamDateSerializer.kt @@ -27,9 +27,26 @@ class SteamDateSerializer : KSerializer { val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM, yyyy", Locale.ENGLISH) } - override fun deserialize(decoder: Decoder): Instant? = fromString(decoder.decodeString()) + override fun serialize(encoder: Encoder, value: Instant?) { + if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeString(value.toString()) + } + } - override fun serialize(encoder: Encoder, value: Instant?) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): Instant? { + return if (decoder.decodeNotNullMark()) { + fromString(decoder.decodeString()) + } else { + decoder.decodeNull() + null + } + } + + fun deserialize(dateString: String): Instant? { + return fromString(dateString) + } private fun fromString(dateString: String): Instant? { return try { From 0e928f812af302119b806aa487280f300c105dfb Mon Sep 17 00:00:00 2001 From: GRIMSIM Date: Wed, 18 Jun 2025 17:49:20 +0200 Subject: [PATCH 2/3] Use undocumented field release_date Close client on Plugin.stop() --- .../metadata/steamgriddb/SteamGridDbPlugin.kt | 7 ++++ .../steamgriddb/api/SteamGridDbApiClient.kt | 8 +++-- .../steamgriddb/dto/SteamGridDbGame.kt | 8 ++++- .../util/InstantEpochSecondsSerializer.kt | 33 +++++++++++++++++++ 4 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/util/InstantEpochSecondsSerializer.kt diff --git a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/SteamGridDbPlugin.kt b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/SteamGridDbPlugin.kt index c36fb97..8c25d0c 100644 --- a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/SteamGridDbPlugin.kt +++ b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/SteamGridDbPlugin.kt @@ -56,6 +56,11 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra } } + override fun stop() { + client?.close() + client = null + } + private suspend fun authenticate(apiKey: String? = null) { log.debug("Authenticating on SteamGridDB API...") @@ -83,6 +88,7 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra GameMetadata( originalId = game.id.toString(), title = game.name, + release = game.releaseDate, coverUrls = grids?.map { URI(it.url) }, headerUrls = heroes?.map { URI(it.url) } ) @@ -101,6 +107,7 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra return@runBlocking GameMetadata( originalId = game.id.toString(), title = game.name, + release = game.releaseDate, coverUrls = grids?.map { URI(it.url) }, headerUrls = heroes?.map { URI(it.url) } ) diff --git a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/api/SteamGridDbApiClient.kt b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/api/SteamGridDbApiClient.kt index b877852..341f97c 100644 --- a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/api/SteamGridDbApiClient.kt +++ b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/api/SteamGridDbApiClient.kt @@ -44,6 +44,10 @@ class SteamGridDbApiClient(private val apiKey: String) { return get("search/autocomplete/${term.encodeURLPath(encodeSlash = true, encodeEncoded = false)}", block).body() } + suspend fun game(gameId: Int, block: HttpRequestBuilder.() -> Unit = {}): SteamGridDbGameResult { + return get("games/id/$gameId", block).body() + } + suspend fun grids(gameId: Int, block: HttpRequestBuilder.() -> Unit = {}): SteamGridDbGridResult { return get("grids/game/$gameId") { url { @@ -59,8 +63,8 @@ class SteamGridDbApiClient(private val apiKey: String) { }.body() } - suspend fun game(gameId: Int, block: HttpRequestBuilder.() -> Unit = {}): SteamGridDbGameResult { - return get("games/id/$gameId", block).body() + fun close() { + client.close() } private suspend fun get(endpoint: String, block: HttpRequestBuilder.() -> Unit = {}): HttpResponse { diff --git a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbGame.kt b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbGame.kt index b9302bc..fb0db88 100644 --- a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbGame.kt +++ b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbGame.kt @@ -1,10 +1,16 @@ package org.gameyfin.plugins.metadata.steamgriddb.dto +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import org.gameyfin.plugins.metadata.steamgriddb.util.InstantEpochSecondsSerializer +import java.time.Instant @Serializable data class SteamGridDbGame( val id: Int, - val name: String + val name: String, + @SerialName("release_date") + @Serializable(with = InstantEpochSecondsSerializer::class) + val releaseDate: Instant? = null ) \ No newline at end of file diff --git a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/util/InstantEpochSecondsSerializer.kt b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/util/InstantEpochSecondsSerializer.kt new file mode 100644 index 0000000..a6f728d --- /dev/null +++ b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/util/InstantEpochSecondsSerializer.kt @@ -0,0 +1,33 @@ +package org.gameyfin.plugins.metadata.steamgriddb.util + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.Instant + +@OptIn(ExperimentalSerializationApi::class) +object InstantEpochSecondsSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("InstantEpochSeconds", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: Instant?) { + if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeLong(value.epochSecond) + } + } + + override fun deserialize(decoder: Decoder): Instant? { + return if (decoder.decodeNotNullMark()) { + Instant.ofEpochSecond(decoder.decodeLong()) + } else { + decoder.decodeNull() + null + } + } +} \ No newline at end of file From df95bc4534bd1d5307aa4ec35e2c95e20e38b274 Mon Sep 17 00:00:00 2001 From: GRIMSIM Date: Wed, 18 Jun 2025 17:49:29 +0200 Subject: [PATCH 3/3] Use SingletonExtensionFactory --- .../app/core/plugins/management/GameyfinPluginManager.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/kotlin/org/gameyfin/app/core/plugins/management/GameyfinPluginManager.kt b/app/src/main/kotlin/org/gameyfin/app/core/plugins/management/GameyfinPluginManager.kt index 8946ae4..7eb8c3a 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/plugins/management/GameyfinPluginManager.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/plugins/management/GameyfinPluginManager.kt @@ -67,6 +67,10 @@ class GameyfinPluginManager( return GameyfinManifestPluginDescriptorFinder() } + override fun createExtensionFactory(): ExtensionFactory { + return SingletonExtensionFactory(this) + } + override fun createExtensionFinder(): ExtensionFinder? { val extensionFinder = GameyfinExtensionFinder(this) addPluginStateListener(extensionFinder)