mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 16:20:03 +00:00
Finalize Steam plugin (more or less)
This commit is contained in:
@@ -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
|
||||
)
|
||||
+182
@@ -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
|
||||
)
|
||||
+30
@@ -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
|
||||
}
|
||||
}
|
||||
+34
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user