Finalize Steam plugin (more or less)

This commit is contained in:
grimsi
2025-01-04 17:51:23 +01:00
parent 72f2e8e693
commit 046b550cc9
7 changed files with 339 additions and 63 deletions
@@ -9,7 +9,6 @@ import de.grimsi.gameyfin.pluginapi.gamemetadata.PlayerPerspective
import de.grimsi.gameyfin.pluginapi.gamemetadata.Theme
import org.slf4j.LoggerFactory
import java.net.URI
import java.net.URL
class Mapper {
companion object {
@@ -94,16 +93,16 @@ class Mapper {
}
}
fun screenshot(screenshot: proto.Screenshot): URL {
return URI(imageBuilder(screenshot.imageId, ImageSize.FHD, ImageType.PNG)).toURL()
fun screenshot(screenshot: proto.Screenshot): URI {
return URI(imageBuilder(screenshot.imageId, ImageSize.FHD, ImageType.PNG))
}
fun cover(cover: proto.Cover): URL {
return URI(imageBuilder(cover.imageId, ImageSize.COVER_BIG, ImageType.PNG)).toURL()
fun cover(cover: proto.Cover): URI {
return URI(imageBuilder(cover.imageId, ImageSize.COVER_BIG, ImageType.PNG))
}
fun video(video: proto.GameVideo): URL {
return URI("https://www.youtube.com/watch?v=${video.videoId}").toURL()
fun video(video: proto.GameVideo): URI {
return URI("https://www.youtube.com/watch?v=${video.videoId}")
}
fun gameFeatures(game: proto.Game): Set<GameFeature> {
@@ -4,15 +4,23 @@ 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 de.grimsi.gameyfin.plugins.steam.dto.SteamDetailsResultWrapper
import de.grimsi.gameyfin.plugins.steam.dto.SteamGame
import de.grimsi.gameyfin.plugins.steam.dto.SteamSearchResult
import de.grimsi.gameyfin.plugins.steam.mapper.toGenre
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import me.xdrop.fuzzywuzzy.FuzzySearch
import org.pf4j.Extension
import org.pf4j.PluginWrapper
@@ -27,39 +35,6 @@ import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.*
@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()
@@ -89,9 +64,13 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
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()
val response = client.get("https://store.steampowered.com/api/storesearch") {
parameter("term", encodedTitle)
parameter("cc", "en")
parameter("l", "en")
}
val searchResult: SteamSearchResult = response.body()
searchResult.items
} catch (e: Exception) {
log.error("Failed to search Steam store: ${e.message}")
@@ -100,29 +79,32 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
}
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
val response = client.get("https://store.steampowered.com/api/appdetails") {
parameter("appids", id)
parameter("cc", "en")
parameter("l", "en")
}
if (response["success"]?.jsonPrimitive?.boolean == false) return null
if (response.status != HttpStatusCode.OK) return null
val game = response["data"]?.jsonObject ?: return null
val responseBody: String = response.bodyAsText(Charsets.UTF_8)
val steamDetailsResultWrapper: Map<Int, SteamDetailsResultWrapper> = Json.decodeFromString(responseBody)
if (!steamDetailsResultWrapper.containsKey(id)) return null
val game = steamDetailsResultWrapper[id]?.data ?: return null
val metadata = GameMetadata(
title = string(game, "name"),
description = string(game, "detailed_description"),
coverUrl = URI("").toURL(),
release = date(game["release_date"]?.jsonObject["date"]?.jsonPrimitive?.content!!),
userRating = 0,
criticRating = 0,
developedBy = stringList(game, "developers").toSet(),
publishedBy = stringList(game, "publishers").toSet(),
genres = emptySet(),
themes = emptySet(),
keywords = emptySet(),
screenshotUrls = emptySet(),
videoUrls = emptySet(),
features = emptySet(),
perspectives = emptySet()
title = game.name,
description = game.detailedDescription,
coverUrl = URI(game.headerImage),
release = game.releaseDate?.date,
developedBy = game.developers.toSet(),
publishedBy = game.publishers.toSet(),
genres = game.genres.map { toGenre(it) }.toSet(),
keywords = game.categories.mapNotNull { it.description }.toSet(),
screenshotUrls = game.screenshots.map { URI(it.pathFull!!) }.toSet(),
videoUrls = game.movies.map { URI(it.webm?.max!!) }.toSet()
)
return metadata
@@ -0,0 +1,10 @@
package de.grimsi.gameyfin.plugins.steam.dto
import kotlinx.serialization.Serializable
@Serializable
data class Platforms(
val windows: Boolean,
val mac: Boolean,
val linux: Boolean
)
@@ -0,0 +1,182 @@
package de.grimsi.gameyfin.plugins.steam.dto
import de.grimsi.gameyfin.plugins.steam.util.SteamDateSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Serializable
data class SteamDetailsResultWrapper(
val success: Boolean,
val data: SteamGameDetails
)
@Serializable
data class SteamGameDetails(
val type: String,
val name: String,
@SerialName("steam_appid") val steamAppId: Int,
@SerialName("required_age") val requiredAge: String,
@SerialName("is_free") val isFree: Boolean,
@SerialName("controller_support") val controllerSupport: String?,
val dlc: List<Int>?,
@SerialName("detailed_description") val detailedDescription: String,
@SerialName("about_the_game") val aboutTheGame: String,
@SerialName("short_description") val shortDescription: String,
@SerialName("supported_languages") val supportedLanguages: String,
val reviews: String,
@SerialName("header_image") val headerImage: String,
@SerialName("capsule_image") val capsuleImage: String,
@SerialName("capsule_imagev5") val capsuleImageV5: String,
val website: String?,
@SerialName("pc_requirements") val pcRequirements: SystemRequirements,
@SerialName("mac_requirements") val macRequirements: SystemRequirements,
@SerialName("linux_requirements") val linuxRequirements: SystemRequirements,
@SerialName("legal_notice") val legalNotice: String?,
@SerialName("drm_notice") val drmNotice: String?,
@SerialName("ext_user_account_notice") val extUserAccountNotice: String?,
val developers: List<String>,
val publishers: List<String>,
@SerialName("price_overview") val priceOverview: PriceOverview?,
val packages: List<Int>?,
@SerialName("package_groups") val packageGroups: List<PackageGroup>,
val platforms: Platforms,
val categories: List<Category>,
val genres: List<SteamGenre>,
val screenshots: List<Screenshot>,
val movies: List<Movie>,
val recommendations: Recommendations?,
val achievements: Achievements?,
@SerialName("release_date") val releaseDate: ReleaseDate?,
@SerialName("support_info") val supportInfo: SupportInfo?,
val background: String?,
@SerialName("background_raw") val backgroundRaw: String?,
@SerialName("content_descriptors") val contentDescriptors: ContentDescriptors?,
@SerialName("ratings") val ratings: Map<String, Rating>?
)
@Serializable
data class SystemRequirements(
val minimum: String?,
val recommended: String?
)
@Serializable
data class PriceOverview(
val currency: String?,
val initial: Int?,
val final: Int?,
@SerialName("discount_percent") val discountPercent: Int?,
@SerialName("initial_formatted") val initialFormatted: String?,
@SerialName("final_formatted") val finalFormatted: String?
)
@Serializable
data class PackageGroup(
val name: String?,
val title: String?,
val description: String?,
@SerialName("selection_text") val selectionText: String?,
@SerialName("save_text") val saveText: String?,
@SerialName("display_type") val displayType: Int?,
@SerialName("is_recurring_subscription") val isRecurringSubscription: Boolean?,
val subs: List<Sub>?
)
@Serializable
data class Sub(
@SerialName("packageid") val packageId: Int,
@SerialName("percent_savings_text") val percentSavingsText: String?,
@SerialName("percent_savings") val percentSavings: Int?,
@SerialName("option_text") val optionText: String?,
@SerialName("option_description") val optionDescription: String?,
@SerialName("can_get_free_license") val canGetFreeLicense: String?,
@SerialName("is_free_license") val isFreeLicense: Boolean,
@SerialName("price_in_cents_with_discount") val priceInCentsWithDiscount: Int?
)
@Serializable
data class Category(
val id: Int,
val description: String?
)
@Serializable
data class SteamGenre(
val id: Int,
val description: String?
)
@Serializable
data class Screenshot(
val id: Int,
@SerialName("path_thumbnail") val pathThumbnail: String?,
@SerialName("path_full") val pathFull: String?
)
@Serializable
data class Movie(
val id: Int,
val name: String?,
val thumbnail: String?,
val webm: Webm?,
val mp4: Mp4?,
val highlight: Boolean
)
@Serializable
data class Webm(
val `480`: String,
val max: String
)
@Serializable
data class Mp4(
val `480`: String,
val max: String
)
@Serializable
data class Recommendations(
val total: Int?
)
@Serializable
data class Achievements(
val total: Int,
val highlighted: List<Achievement>?
)
@Serializable
data class Achievement(
val name: String?,
val path: String?
)
@Serializable
data class ReleaseDate(
@SerialName("coming_soon") val comingSoon: Boolean,
@Serializable(with = SteamDateSerializer::class) val date: Instant?
)
@Serializable
data class SupportInfo(
val url: String?,
val email: String?
)
@Serializable
data class ContentDescriptors(
val ids: List<Int>,
val notes: String?
)
@Serializable
data class Rating(
val rating: String,
val descriptors: String? = null,
@SerialName("display_online_notice") val displayOnlineNotice: Boolean? = null,
@SerialName("use_age_gate") val useAgeGate: Boolean? = null,
@SerialName("required_age") val requiredAge: Int? = null,
@SerialName("interactive_elements") val interactiveElements: String? = null
)
@@ -0,0 +1,30 @@
package de.grimsi.gameyfin.plugins.steam.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@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,
@SerialName("tiny_image") val tinyImage: String,
val metascore: String,
val platforms: Platforms,
@SerialName("streamingvideo") val streamingVideo: Boolean,
@SerialName("controller_support") val controllerSupport: String
)
@Serializable
data class Price(
val currency: String,
val initial: Int,
val final: Int
)
@@ -0,0 +1,39 @@
package de.grimsi.gameyfin.plugins.steam.mapper
import de.grimsi.gameyfin.pluginapi.gamemetadata.Genre
import de.grimsi.gameyfin.plugins.steam.dto.SteamGenre
fun toGenre(steamGenre: SteamGenre): Genre {
return when (steamGenre.id) {
1 -> Genre.ACTION
2 -> Genre.STRATEGY
25 -> Genre.ADVENTURE
23 -> Genre.INDIE
3 -> Genre.ROLE_PLAYING
28 -> Genre.SIMULATOR
29 -> Genre.MMO
9 -> Genre.RACING
18 -> Genre.SPORT
37 -> Genre.UNKNOWN // Free to Play doesn't match any genre directly
51 -> Genre.UNKNOWN // Animation & Modeling doesn't match any genre directly
58 -> Genre.UNKNOWN // Video Production doesn't match any genre directly
4 -> Genre.UNKNOWN // Casual doesn't match any genre directly
73 -> Genre.UNKNOWN // Violent doesn't map directly to a genre
72 -> Genre.UNKNOWN // Nudity doesn't match any genre directly
70 -> Genre.UNKNOWN // Early Access doesn't map directly to a genre
74 -> Genre.UNKNOWN // Gore doesn't match any genre directly
57 -> Genre.UNKNOWN // Utilities doesn't match any genre directly
52 -> Genre.UNKNOWN // Audio Production doesn't match any genre directly
53 -> Genre.UNKNOWN // Design & Illustration doesn't match any genre directly
59 -> Genre.UNKNOWN // Web Publishing doesn't map directly to a genre
55 -> Genre.UNKNOWN // Photo Editing doesn't map directly to a genre
54 -> Genre.UNKNOWN // Education doesn't match any genre directly
56 -> Genre.UNKNOWN // Software Training doesn't map directly to a genre
71 -> Genre.UNKNOWN // Sexual Content doesn't match any genre directly
60 -> Genre.UNKNOWN // Game Development doesn't map directly to a genre
50 -> Genre.UNKNOWN // Accounting doesn't map directly to a genre
81 -> Genre.UNKNOWN // Documentary doesn't map directly to a genre
84 -> Genre.UNKNOWN // Tutorial doesn't map directly to a genre
else -> Genre.UNKNOWN
}
}
@@ -0,0 +1,34 @@
package de.grimsi.gameyfin.plugins.steam.util
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializer
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.*
@OptIn(ExperimentalSerializationApi::class)
@Serializer(forClass = Instant::class)
class SteamDateSerializer : KSerializer<Instant> {
companion object {
val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd MMM, yyyy", Locale.ENGLISH)
}
override fun deserialize(decoder: Decoder): Instant = fromString(decoder.decodeString())
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
private fun fromString(dateString: String): Instant {
val localDate = LocalDate.parse(dateString, formatter)
return localDate.atStartOfDay().toInstant(ZoneOffset.UTC)
}
private fun toString(date: Instant): String {
return formatter.format(date.atZone(ZoneOffset.UTC))
}
}