diff --git a/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/Mapper.kt b/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/Mapper.kt index 5657f57..48294df 100644 --- a/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/Mapper.kt +++ b/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/Mapper.kt @@ -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 { 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 index c3fc5d4..d97b74a 100644 --- 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 @@ -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 -) - -@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() @@ -89,9 +64,13 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) { 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() + 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 = 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 diff --git a/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/dto/Platforms.kt b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/dto/Platforms.kt new file mode 100644 index 0000000..952c6a8 --- /dev/null +++ b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/dto/Platforms.kt @@ -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 +) \ No newline at end of file diff --git a/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/dto/SteamGameDetails.kt b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/dto/SteamGameDetails.kt new file mode 100644 index 0000000..f491f49 --- /dev/null +++ b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/dto/SteamGameDetails.kt @@ -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?, + @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, + val publishers: List, + @SerialName("price_overview") val priceOverview: PriceOverview?, + val packages: List?, + @SerialName("package_groups") val packageGroups: List, + val platforms: Platforms, + val categories: List, + val genres: List, + val screenshots: List, + val movies: List, + 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? +) + +@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? +) + +@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? +) + +@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, + 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 +) \ No newline at end of file diff --git a/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/dto/SteamGameOverview.kt b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/dto/SteamGameOverview.kt new file mode 100644 index 0000000..44dfd25 --- /dev/null +++ b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/dto/SteamGameOverview.kt @@ -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 +) + +@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 +) \ No newline at end of file diff --git a/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/mapper/GenreMapper.kt b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/mapper/GenreMapper.kt new file mode 100644 index 0000000..354c173 --- /dev/null +++ b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/mapper/GenreMapper.kt @@ -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 + } +} \ No newline at end of file diff --git a/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/util/SteamDateSerializer.kt b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/util/SteamDateSerializer.kt new file mode 100644 index 0000000..1e898ac --- /dev/null +++ b/plugins/steam/src/main/kotlin/de/grimsi/gameyfin/plugins/steam/util/SteamDateSerializer.kt @@ -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 { + + 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)) + } +} \ No newline at end of file