Release v2.2.0 (#741)

* Migrate to TailwindCSS v4 (#740)

* Remove "material-tailwind" dependencies due to incompatibility of Stepper component with Tailwind v4

* Clean up Tailwind configs before upgrade

* Run HeroUI upgrade

* Run TailwindCSS upgrade

* Replace PostCSS with Vite

* Migrate custom styles to v4

* Remove tailwind.config.ts

* Add heroui.ts
Add tailwind vite plugin

* Fix small UI color inconsistency

* Fix theming system
Rename purple theme to pink

* Re-implement stepper in HeroUI

* Fix RoleChip colors

* Migrate icon names (#743)

* Add migration script for phosphor-icons

* Migrate icon usages

* Update version to 2.2.0-preview

* Revert accidental rename of menu title

* Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#750)

Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7.
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Improve library scanning (#749)

* Update script to generate example libraries using SteamSpy API

* Refactor library scanning process

* Display Flyway startup log by default

* Fix race condition in CompanyService

* Fix race condition in ImageService
Remove obsolete table

* Fix SMTP config requiring an email as username (#755)

* Disable length limit for config values (#757)

* Deprecate DockerHub image (#759)

* Remove deprecation warning from web UI

* Reworked the CICD pipelines

* Optimize container image (#761)

* Fix Gradle warning

* Rework Docker image to improve layer caching

* Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#765)

Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7.
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Multi platform support (#764)

* Remove migrate-phosphor-icons.js since migration has been successful
* Refactor GameMetadata into separate files
* Add Platform enum
* Implement platform support in Plugin API
* Implement platform support in Steam Plugin
* Implement platform support in IGDB Plugin
* Add database migration for platform support
* Implement platform support in GameService
* Implement platform support on most endpoints and features, some are still missing
Implemented platform support in all bundled plugins (although not finished polishing yet)
* Implement platforms in UI
* Make GameRequest platform aware
* Return headerImages from IGDB
* Implement proper PlatformMapper for IGDB plugin
* Fix various smaller issues and inconsistencies

* Replace placeholder in LibraryOverviewCard (#767)

* Bump actions/download-artifact from 5 to 6 (#769)

* Bump actions/upload-artifact from 4 to 5 (#770)

* Multi platform support (#773)

* Fix bug in Plugin API related to state loading/saving

* Hide Flyway query logs by default

* Extend migration script for multi platform tables

* Plugins now store their data and state in ./plugindata

* Add "plugindata" directory to entrypoint scripts

* Improve download handling (#756)

* Process download in background thread to avoid session timeout affecting it

* Increase default session timeout to 24h

* Use virtual thread pool for download task in background

* Make KSP extensions.idx generation more robust

* Implement download bandwidth limiter
Implement SliderInput
Refactor NumberInput

* Implement download bandwidth throttling
Implement real-time download monitoring

* Improve UI for DownloadManagement
Track more stats in SessionStats

* Update Hilla
Use React 19

* Implement real-time graph to track bandwidth usage
Implement downloaded data sum over last day
Small bug fixes
Small refactorings

* Update docker-compose.example.yml

* Improve DownloadSessionCard (#784)

* Fix unit on y-axis of download graph

* Show game size and library in tooltip
Make game chips interactive in DownloadSessionCard (leads to game page when clicked)
Optimize graph settings

* Migrate torrent plugin to libtorrent (#775)

* Disable TorrentDownloadPlugin in Alpine based Docker image

* Improve test coverage (#785)

* Fix potential divide by zero bug

* Add mockk dependency

* Add tests for org.gameyfin.app.core.download

* Add tests for Filesytem package
Fix DownloadServiceTest

* Fix FilesystemServiceTest

* Add tests for "job" package

* Upgrade Gradle wrapper
Enable Gradle config cache

* Added more tests

* Added tests for the "security" package

* Add tests for "game" package

* Fix AsyncFileTailer not shutting down properly on Windows

* Fix GameServiceTest

* Added tests for "libraries" package

* Added tests for "media" package

* Fix warning in ImageService

* Add tests fpr "messages" package
Make sure transport is closed even in case an exception is thrown

* Add tests for "platforms" package

* Add tests for "requests" package

* Moved "token" package to "core" package (from "shared")

* Add tests for "token" package

* Fix issue in RoleEnum.safeValueOf() throwing Exception

* Fix potential issue in UserEndpoint.getUserInfo() when auth is null

* Added tests for "user" package

* Migrate package for "token" in FE

* Publish test report in CI

* Fix workflow permissions

* Remove test because of timing issue in CI

* Replaced "unmatched paths" with "ignored paths" (#791)

* Use new "AutoComplete" component (#793)

* Use ArrayInputAutocomplete in EditGameMetadataModal

* Add test for getEnumPropertyValues

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Simon
2025-11-17 08:45:39 +01:00
committed by GitHub
parent dd3b18e5e3
commit 717a423449
357 changed files with 39213 additions and 7918 deletions
@@ -17,6 +17,8 @@ import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import org.gameyfin.pluginapi.core.wrapper.ConfigurableGameyfinPlugin
import org.gameyfin.pluginapi.gamemetadata.GameMetadata
import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import org.gameyfin.pluginapi.gamemetadata.Platform
import org.gameyfin.plugins.metadata.igdb.mapper.*
import org.pf4j.Extension
import org.pf4j.PluginWrapper
import proto.Game
@@ -88,6 +90,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
log.debug("Authentication successful")
}
@Suppress("Unused")
@Extension(ordinal = 2)
class IgdbMetadataProvider : GameMetadataProvider {
@@ -121,6 +124,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
"game_modes.slug",
"game_modes.name",
"cover.image_id",
"artworks.image_id",
"screenshots.image_id",
"videos.name",
"videos.video_id",
@@ -143,15 +147,26 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
).joinToString(",")
}
override fun fetchByTitle(gameTitle: String, maxResults: Int): List<GameMetadata> {
// Note: Limit is intentionally set high because IGDBs ranking algorithm is not very good
override val supportedPlatforms: Set<Platform>
get() = Platform.entries.toSet()
override fun fetchByTitle(
gameTitle: String,
platformFilter: Set<Platform>,
maxResults: Int
): List<GameMetadata> {
val searchByNameQuery = APICalypse()
.fields(QUERY_FIELDS)
// TODO: Change for multi-platform support
.where("platforms.slug = \"win\"")
// Note: Limit is intentionally set high because IGDBs ranking algorithm is not very good
.limit(100)
.search(gameTitle)
if (platformFilter.isNotEmpty()) {
val platformFilterQuery = PlatformMapper.toIgdb(platformFilter)
.joinToString(separator = "\", \"", prefix = "platforms.slug = (\"", postfix = "\")")
searchByNameQuery.where(platformFilterQuery)
}
// Use IGDBs search function to get a list of games that match the search query
var games = queryIgdbGames(searchByNameQuery)
@@ -170,7 +185,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
.sortedByDescending { bestMatchesMap[it.name] }
.take(maxResults)
return games.map { toGameMetadata(it) }
return games.map { toGameMetadata(it, platformFilter) }
}
override fun fetchById(id: String): GameMetadata? {
@@ -181,7 +196,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
.where("slug = \"$id\"")
val game = queryIgdbGames(findBySlugQuery).firstOrNull()
return game?.let { toGameMetadata(it) }
return game?.let { toGameMetadata(it, null) }
}
private fun queryIgdbGames(query: APICalypse): List<Game> {
@@ -193,29 +208,35 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
return decorated.get()
}
private fun toGameMetadata(game: Game): GameMetadata {
private fun toGameMetadata(game: Game, platformFilter: Set<Platform>?): GameMetadata {
val supportedPlatforms = game.platformsList.map { PlatformMapper.toGameyfin(it.slug) }
val filteredPlatforms = platformFilter?.let { filter -> supportedPlatforms.filter { it in filter } }
?: supportedPlatforms
return GameMetadata(
originalId = game.slug,
title = game.name,
platforms = filteredPlatforms.toSet(),
description = game.summary,
coverUrls = Mapper.cover(game.cover)?.let { listOf(it) },
coverUrls = MediaMapper.cover(game.cover)?.let { listOf(it) }?.toSet(),
headerUrls = game.artworksList.map { MediaMapper.header(it) }.toSet(),
release = if (game.firstReleaseDate.seconds > 0) Instant.ofEpochSecond(game.firstReleaseDate.seconds) else null,
userRating = game.rating.toInt(),
criticRating = game.aggregatedRating.toInt(),
developedBy = game.involvedCompaniesList.filter { it.developer }.map { it.company.name }.toSet(),
publishedBy = game.involvedCompaniesList.filter { it.publisher }.map { it.company.name }.toSet(),
genres = game.genresList.map { Mapper.genre(it) }.toSet(),
themes = game.themesList.map { Mapper.theme(it) }.toSet(),
genres = game.genresList.map { GenreMapper.genre(it) }.toSet(),
themes = game.themesList.map { ThemeMapper.theme(it) }.toSet(),
keywords = game.keywordsList.map { it.name }.toSet(),
screenshotUrls = game.screenshotsList.map { Mapper.screenshot(it) }.toSet(),
screenshotUrls = game.screenshotsList.map { MediaMapper.screenshot(it) }.toSet(),
videoUrls = game.videosList
// Lots of gameplay videos hosted on YouTube are blocked from viewing on external sites due to age ratings
// Trailers usually are not affected so we filter for them
// see https://support.google.com/youtube/answer/2802167
.filter { it.name.equals("trailer", ignoreCase = true) }
.map { Mapper.video(it) }.toSet(),
features = Mapper.gameFeatures(game),
perspectives = game.playerPerspectivesList.map { Mapper.playerPerspective(it) }.toSet()
.map { MediaMapper.video(it) }.toSet(),
features = GameFeatureMapper.gameFeatures(game),
perspectives = game.playerPerspectivesList.map { PlayerPerspectiveMapper.playerPerspective(it) }.toSet()
)
}
}
@@ -1,137 +0,0 @@
package org.gameyfin.plugins.metadata.igdb
import com.api.igdb.utils.ImageSize
import com.api.igdb.utils.ImageType
import com.api.igdb.utils.imageBuilder
import org.gameyfin.pluginapi.gamemetadata.GameFeature
import org.gameyfin.pluginapi.gamemetadata.Genre
import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective
import org.gameyfin.pluginapi.gamemetadata.Theme
import org.slf4j.LoggerFactory
import proto.Cover
import proto.Game
import proto.GameVideo
import proto.Screenshot
import java.net.URI
class Mapper {
companion object {
private val log = LoggerFactory.getLogger(Mapper::class.java)
fun genre(genre: proto.Genre): Genre {
return when (genre.slug) {
"pinball" -> Genre.PINBALL
"adventure" -> Genre.ADVENTURE
"indie" -> Genre.INDIE
"arcade" -> Genre.ARCADE
"visual-novel" -> Genre.VISUAL_NOVEL
"card-and-board-game" -> Genre.CARD_AND_BOARD_GAME
"moba" -> Genre.MOBA
"point-and-click" -> Genre.POINT_AND_CLICK
"fighting" -> Genre.FIGHTING
"shooter" -> Genre.SHOOTER
"music" -> Genre.MUSIC
"platform" -> Genre.PLATFORM
"puzzle" -> Genre.PUZZLE
"racing" -> Genre.RACING
"real-time-strategy-rts" -> Genre.REAL_TIME_STRATEGY
"role-playing-rpg" -> Genre.ROLE_PLAYING
"simulator" -> Genre.SIMULATOR
"sport" -> Genre.SPORT
"strategy" -> Genre.STRATEGY
"turn-based-strategy-tbs" -> Genre.TURN_BASED_STRATEGY
"tactical" -> Genre.TACTICAL
"hack-and-slash-beat-em-up" -> Genre.HACK_AND_SLASH_BEAT_EM_UP
"quiz-trivia" -> Genre.QUIZ_TRIVIA
else -> {
log.warn("Unknown genre: {}", genre.slug)
Genre.UNKNOWN
}
}
}
fun theme(theme: proto.Theme): Theme {
return when (theme.slug) {
"action" -> Theme.ACTION
"fantasy" -> Theme.FANTASY
"horror" -> Theme.HORROR
"sci-fi" -> Theme.SCIENCE_FICTION
"science-fiction" -> Theme.SCIENCE_FICTION
"mystery" -> Theme.MYSTERY
"thriller" -> Theme.THRILLER
"survival" -> Theme.SURVIVAL
"historical" -> Theme.HISTORICAL
"stealth" -> Theme.STEALTH
"comedy" -> Theme.COMEDY
"business" -> Theme.BUSINESS
"drama" -> Theme.DRAMA
"non-fiction" -> Theme.NON_FICTION
"sandbox" -> Theme.SANDBOX
"educational" -> Theme.EDUCATIONAL
"kids" -> Theme.KIDS
"open-world" -> Theme.OPEN_WORLD
"warfare" -> Theme.WARFARE
"party" -> Theme.PARTY
"4x-explore-expand-exploit-and-exterminate" -> Theme.FOUR_X
"erotic" -> Theme.EROTIC
"romance" -> Theme.ROMANCE
else -> {
log.warn("Unknown theme: {}", theme.slug)
Theme.UNKNOWN
}
}
}
fun playerPerspective(perspective: proto.PlayerPerspective): PlayerPerspective {
return when (perspective.slug) {
"first-person" -> PlayerPerspective.FIRST_PERSON
"third-person" -> PlayerPerspective.THIRD_PERSON
"bird-view-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
"bird-view-slash-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
"side-view" -> PlayerPerspective.SIDE_VIEW
"text" -> PlayerPerspective.TEXT
"auditory" -> PlayerPerspective.AUDITORY
"virtual-reality" -> PlayerPerspective.VIRTUAL_REALITY
else -> {
log.warn("Unknown player perspective: {}", perspective.slug)
PlayerPerspective.UNKNOWN
}
}
}
fun screenshot(screenshot: Screenshot): URI {
return URI(imageBuilder(screenshot.imageId, ImageSize.FHD, ImageType.PNG))
}
fun cover(cover: Cover): URI? {
if (cover.imageId.isEmpty()) return null
return URI(imageBuilder(cover.imageId, ImageSize.COVER_BIG, ImageType.PNG))
}
fun video(video: GameVideo): URI {
return URI("https://www.youtube.com/watch?v=${video.videoId}")
}
fun gameFeatures(game: Game): Set<GameFeature> {
val gameFeatures = mutableSetOf<GameFeature>()
// Get LAN support from multiplayer modes
if (game.multiplayerModesList.any { it.lancoop }) gameFeatures.add(GameFeature.LOCAL_MULTIPLAYER)
for (gameMode in game.gameModesList) {
when (gameMode.slug) {
"single-player" -> gameFeatures.add(GameFeature.SINGLEPLAYER)
"multiplayer" -> gameFeatures.add(GameFeature.MULTIPLAYER)
"massively-multiplayer-online-mmo" -> gameFeatures.add(GameFeature.MULTIPLAYER)
"battle-royale" -> gameFeatures.add(GameFeature.MULTIPLAYER)
"co-operative" -> gameFeatures.add(GameFeature.CO_OP)
"split-screen" -> gameFeatures.add(GameFeature.SPLITSCREEN)
else -> {
log.warn("Unknown game mode: {}", gameMode.slug)
}
}
}
return gameFeatures
}
}
}
@@ -0,0 +1,33 @@
package org.gameyfin.plugins.metadata.igdb.mapper
import org.gameyfin.pluginapi.gamemetadata.GameFeature
import org.slf4j.LoggerFactory
import proto.Game
class GameFeatureMapper {
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
fun gameFeatures(game: Game): Set<GameFeature> {
val gameFeatures = mutableSetOf<GameFeature>()
// Get LAN support from multiplayer modes
if (game.multiplayerModesList.any { it.lancoop }) gameFeatures.add(GameFeature.LOCAL_MULTIPLAYER)
for (gameMode in game.gameModesList) {
when (gameMode.slug) {
"single-player" -> gameFeatures.add(GameFeature.SINGLEPLAYER)
"multiplayer" -> gameFeatures.add(GameFeature.MULTIPLAYER)
"massively-multiplayer-online-mmo" -> gameFeatures.add(GameFeature.MULTIPLAYER)
"battle-royale" -> gameFeatures.add(GameFeature.MULTIPLAYER)
"co-operative" -> gameFeatures.add(GameFeature.CO_OP)
"split-screen" -> gameFeatures.add(GameFeature.SPLITSCREEN)
else -> {
log.warn("Unknown game mode: {}", gameMode.slug)
}
}
}
return gameFeatures
}
}
}
@@ -0,0 +1,42 @@
package org.gameyfin.plugins.metadata.igdb.mapper
import org.gameyfin.pluginapi.gamemetadata.Genre
import org.slf4j.LoggerFactory
class GenreMapper {
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
fun genre(genre: proto.Genre): Genre {
return when (genre.slug) {
"pinball" -> Genre.PINBALL
"adventure" -> Genre.ADVENTURE
"indie" -> Genre.INDIE
"arcade" -> Genre.ARCADE
"visual-novel" -> Genre.VISUAL_NOVEL
"card-and-board-game" -> Genre.CARD_AND_BOARD_GAME
"moba" -> Genre.MOBA
"point-and-click" -> Genre.POINT_AND_CLICK
"fighting" -> Genre.FIGHTING
"shooter" -> Genre.SHOOTER
"music" -> Genre.MUSIC
"platform" -> Genre.PLATFORM
"puzzle" -> Genre.PUZZLE
"racing" -> Genre.RACING
"real-time-strategy-rts" -> Genre.REAL_TIME_STRATEGY
"role-playing-rpg" -> Genre.ROLE_PLAYING
"simulator" -> Genre.SIMULATOR
"sport" -> Genre.SPORT
"strategy" -> Genre.STRATEGY
"turn-based-strategy-tbs" -> Genre.TURN_BASED_STRATEGY
"tactical" -> Genre.TACTICAL
"hack-and-slash-beat-em-up" -> Genre.HACK_AND_SLASH_BEAT_EM_UP
"quiz-trivia" -> Genre.QUIZ_TRIVIA
else -> {
log.warn("Unknown genre: {}", genre.slug)
Genre.UNKNOWN
}
}
}
}
}
@@ -0,0 +1,31 @@
package org.gameyfin.plugins.metadata.igdb.mapper
import com.api.igdb.utils.ImageSize
import com.api.igdb.utils.ImageType
import com.api.igdb.utils.imageBuilder
import proto.Artwork
import proto.Cover
import proto.GameVideo
import proto.Screenshot
import java.net.URI
class MediaMapper {
companion object {
fun cover(cover: Cover): URI? {
if (cover.imageId.isEmpty()) return null
return URI(imageBuilder(cover.imageId, ImageSize.COVER_BIG, ImageType.PNG))
}
fun header(header: Artwork): URI {
return URI(imageBuilder(header.imageId, ImageSize.FHD, ImageType.PNG))
}
fun screenshot(screenshot: Screenshot): URI {
return URI(imageBuilder(screenshot.imageId, ImageSize.FHD, ImageType.PNG))
}
fun video(video: GameVideo): URI {
return URI("https://www.youtube.com/watch?v=${video.videoId}")
}
}
}
@@ -0,0 +1,290 @@
package org.gameyfin.plugins.metadata.igdb.mapper
import org.gameyfin.pluginapi.gamemetadata.Platform
/**
* Mapper for converting between IGDB platform slugs and Gameyfin Platform enums
*/
class PlatformMapper {
companion object {
/**
* Map from IGDB platform slug to Gameyfin Platform enum
*/
private val igdbToGameyfinMap: Map<String, Platform> = mapOf(
"linux" to Platform.LINUX,
"n64" to Platform.NINTENDO_64,
"wii" to Platform.WII,
"win" to Platform.PC_MICROSOFT_WINDOWS,
"ps" to Platform.PLAYSTATION,
"ps2" to Platform.PLAYSTATION_2,
"ps3" to Platform.PLAYSTATION_3,
"xbox" to Platform.XBOX,
"xbox360" to Platform.XBOX_360,
"dos" to Platform.DOS,
"mac" to Platform.MAC,
"c64" to Platform.COMMODORE_C64_128_MAX,
"amiga" to Platform.AMIGA,
"nes" to Platform.NINTENDO_ENTERTAINMENT_SYSTEM,
"snes" to Platform.SUPER_NINTENDO_ENTERTAINMENT_SYSTEM,
"nds" to Platform.NINTENDO_DS,
"ngc" to Platform.NINTENDO_GAMECUBE,
"gbc" to Platform.GAME_BOY_COLOR,
"dc" to Platform.DREAMCAST,
"gba" to Platform.GAME_BOY_ADVANCE,
"acpc" to Platform.AMSTRAD_CPC,
"zxs" to Platform.ZX_SPECTRUM,
"msx" to Platform.MSX,
"genesis-slash-megadrive" to Platform.SEGA_MEGA_DRIVE_GENESIS,
"sega32" to Platform.SEGA_32X,
"saturn" to Platform.SEGA_SATURN,
"gb" to Platform.GAME_BOY,
"android" to Platform.ANDROID,
"gamegear" to Platform.SEGA_GAME_GEAR,
"3ds" to Platform.NINTENDO_3DS,
"psp" to Platform.PLAYSTATION_PORTABLE,
"ios" to Platform.IOS,
"wiiu" to Platform.WII_U,
"ngage" to Platform.N_GAGE,
"zod" to Platform.TAPWAVE_ZODIAC,
"psvita" to Platform.PLAYSTATION_VITA,
"vc" to Platform.VIRTUAL_CONSOLE,
"ps4--1" to Platform.PLAYSTATION_4,
"xboxone" to Platform.XBOX_ONE,
"3do" to Platform._3DO_INTERACTIVE_MULTIPLAYER,
"fds" to Platform.FAMILY_COMPUTER_DISK_SYSTEM,
"arcade" to Platform.ARCADE,
"msx2" to Platform.MSX2,
"mobile" to Platform.LEGACY_MOBILE_DEVICE,
"wonderswan" to Platform.WONDERSWAN,
"sfam" to Platform.SUPER_FAMICOM,
"atari2600" to Platform.ATARI_2600,
"atari7800" to Platform.ATARI_7800,
"lynx" to Platform.ATARI_LYNX,
"jaguar" to Platform.ATARI_JAGUAR,
"atari-st" to Platform.ATARI_ST_STE,
"sms" to Platform.SEGA_MASTER_SYSTEM_MARK_III,
"atari8bit" to Platform.ATARI_8_BIT,
"atari5200" to Platform.ATARI_5200,
"intellivision" to Platform.INTELLIVISION,
"colecovision" to Platform.COLECOVISION,
"bbcmicro" to Platform.BBC_MICROCOMPUTER_SYSTEM,
"vectrex" to Platform.VECTREX,
"vic-20" to Platform.COMMODORE_VIC_20,
"ouya" to Platform.OUYA,
"blackberry" to Platform.BLACKBERRY_OS,
"winphone" to Platform.WINDOWS_PHONE,
"appleii" to Platform.APPLE_II,
"x1" to Platform.SHARP_X1,
"sega-cd" to Platform.SEGA_CD,
"neogeomvs" to Platform.NEO_GEO_MVS,
"neogeoaes" to Platform.NEO_GEO_AES,
"browser" to Platform.WEB_BROWSER,
"sg1000" to Platform.SG_1000,
"donner30" to Platform.DONNER_MODEL_30,
"turbografx16--1" to Platform.TURBOGRAFX_16_PC_ENGINE,
"virtualboy" to Platform.VIRTUAL_BOY,
"odyssey--1" to Platform.ODYSSEY,
"microvision--1" to Platform.MICROVISION,
"cpet" to Platform.COMMODORE_PET,
"astrocade" to Platform.BALLY_ASTROCADE,
"c16" to Platform.COMMODORE_16,
"c-plus-4" to Platform.COMMODORE_PLUS_4,
"pdp1" to Platform.PDP_1,
"pdp10" to Platform.PDP_10,
"pdp-8--1" to Platform.PDP_8,
"gt40" to Platform.DEC_GT40,
"famicom" to Platform.FAMILY_COMPUTER,
"analogueelectronics" to Platform.ANALOGUE_ELECTRONICS,
"nimrod" to Platform.FERRANTI_NIMROD_COMPUTER,
"edsac--1" to Platform.EDSAC,
"pdp-7--1" to Platform.PDP_7,
"hp2100" to Platform.HP_2100,
"hp3000" to Platform.HP_3000,
"sdssigma7" to Platform.SDS_SIGMA_7,
"call-a-computer" to Platform.CALL_A_COMPUTER_TIME_SHARED_MAINFRAME_COMPUTER_SYSTEM,
"pdp11" to Platform.PDP_11,
"cdccyber70" to Platform.CDC_CYBER_70,
"plato--1" to Platform.PLATO,
"imlac-pds1" to Platform.IMLAC_PDS_1,
"microcomputer--1" to Platform.MICROCOMPUTER,
"onlive" to Platform.ONLIVE_GAME_SYSTEM,
"amiga-cd32" to Platform.AMIGA_CD32,
"apple-iigs" to Platform.APPLE_IIGS,
"acorn-archimedes" to Platform.ACORN_ARCHIMEDES,
"philips-cdi" to Platform.PHILIPS_CD_I,
"fm-towns" to Platform.FM_TOWNS,
"neo-geo-pocket" to Platform.NEO_GEO_POCKET,
"neo-geo-pocket-color" to Platform.NEO_GEO_POCKET_COLOR,
"sharp-x68000" to Platform.SHARP_X68000,
"nuon" to Platform.NUON,
"wonderswan-color" to Platform.WONDERSWAN_COLOR,
"swancrystal" to Platform.SWANCRYSTAL,
"pc-8800-series" to Platform.PC_8800_SERIES,
"trs-80" to Platform.TRS_80,
"fairchild-channel-f" to Platform.FAIRCHILD_CHANNEL_F,
"supergrafx" to Platform.PC_ENGINE_SUPERGRAFX,
"ti-99" to Platform.TEXAS_INSTRUMENTS_TI_99,
"switch" to Platform.NINTENDO_SWITCH,
"super-nes-cd-rom-system" to Platform.SUPER_NES_CD_ROM_SYSTEM,
"firetv" to Platform.AMAZON_FIRE_TV,
"odyssey-2-slash-videopac-g7000" to Platform.ODYSSEY_2_VIDEOPAC_G7000,
"acorn-electron" to Platform.ACORN_ELECTRON,
"hyper-neo-geo-64" to Platform.HYPER_NEO_GEO_64,
"neo-geo-cd" to Platform.NEO_GEO_CD,
"new-3ds" to Platform.NEW_NINTENDO_3DS,
"vc-4000" to Platform.VC_4000,
"1292-advanced-programmable-video-system" to Platform._1292_ADVANCED_PROGRAMMABLE_VIDEO_SYSTEM,
"ay-3-8500" to Platform.AY_3_8500,
"ay-3-8610" to Platform.AY_3_8610,
"pc-50x-family" to Platform.PC_50X_FAMILY,
"ay-3-8760" to Platform.AY_3_8760,
"ay-3-8710" to Platform.AY_3_8710,
"ay-3-8603" to Platform.AY_3_8603,
"ay-3-8605" to Platform.AY_3_8605,
"ay-3-8606" to Platform.AY_3_8606,
"ay-3-8607" to Platform.AY_3_8607,
"pc-9800-series" to Platform.PC_9800_SERIES,
"turbografx-16-slash-pc-engine-cd" to Platform.TURBOGRAFX_16_PC_ENGINE_CD,
"trs-80-color-computer" to Platform.TRS_80_COLOR_COMPUTER,
"fm-7" to Platform.FM_7,
"dragon-32-slash-64" to Platform.DRAGON_32_64,
"apcw" to Platform.AMSTRAD_PCW,
"tatung-einstein" to Platform.TATUNG_EINSTEIN,
"thomson-mo5" to Platform.THOMSON_MO5,
"nec-pc-6000-series" to Platform.NEC_PC_6000_SERIES,
"commodore-cdtv" to Platform.COMMODORE_CDTV,
"nintendo-dsi" to Platform.NINTENDO_DSI,
"windows-mixed-reality" to Platform.WINDOWS_MIXED_REALITY,
"oculus-vr" to Platform.OCULUS_VR,
"steam-vr" to Platform.STEAMVR,
"daydream" to Platform.DAYDREAM,
"psvr" to Platform.PLAYSTATION_VR,
"pokemon-mini" to Platform.POKEMON_MINI,
"ps5" to Platform.PLAYSTATION_5,
"series-x-s" to Platform.XBOX_SERIES_X_S,
"stadia" to Platform.GOOGLE_STADIA,
"duplicate-stadia" to Platform.DUPLICATE_STADIA,
"exidy-sorcerer" to Platform.EXIDY_SORCERER,
"sol-20" to Platform.SOL_20,
"dvd-player" to Platform.DVD_PLAYER,
"blu-ray-player" to Platform.BLU_RAY_PLAYER,
"zeebo" to Platform.ZEEBO,
"pc-fx" to Platform.PC_FX,
"satellaview" to Platform.SATELLAVIEW,
"g-and-w" to Platform.GAME_AND_WATCH,
"playdia" to Platform.PLAYDIA,
"evercade" to Platform.EVERCADE,
"sega-pico" to Platform.SEGA_PICO,
"ooparts" to Platform.OOPARTS,
"sinclair-zx81" to Platform.SINCLAIR_ZX81,
"sharp-mz-2200" to Platform.SHARP_MZ_2200,
"epoch-cassette-vision" to Platform.EPOCH_CASSETTE_VISION,
"epoch-super-cassette-vision" to Platform.EPOCH_SUPER_CASSETTE_VISION,
"plug-and-play" to Platform.PLUG_AND_PLAY,
"gamate" to Platform.GAMATE,
"game-dot-com" to Platform.GAME_COM,
"casio-loopy" to Platform.CASIO_LOOPY,
"playdate" to Platform.PLAYDATE,
"intellivision-amico" to Platform.INTELLIVISION_AMICO,
"oculus-quest" to Platform.OCULUS_QUEST,
"oculus-rift" to Platform.OCULUS_RIFT,
"meta-quest-2" to Platform.META_QUEST_2,
"oculus-go" to Platform.OCULUS_GO,
"gear-vr" to Platform.GEAR_VR,
"airconsole" to Platform.AIRCONSOLE,
"psvr2" to Platform.PLAYSTATION_VR2,
"windows-mobile" to Platform.WINDOWS_MOBILE,
"sinclair-ql" to Platform.SINCLAIR_QL,
"hyperscan" to Platform.HYPERSCAN,
"mega-duck-slash-cougar-boy" to Platform.MEGA_DUCK_COUGAR_BOY,
"legacy-computer" to Platform.LEGACY_COMPUTER,
"atari-jaguar-cd" to Platform.ATARI_JAGUAR_CD,
"handheld" to Platform.HANDHELD_ELECTRONIC_LCD,
"leapster" to Platform.LEAPSTER,
"leapster-explorer-slash-leadpad-explorer" to Platform.LEAPSTER_EXPLORER_LEADPAD_EXPLORER,
"leaptv" to Platform.LEAPTV,
"watara-slash-quickshot-supervision" to Platform.WATARA_QUICKSHOT_SUPERVISION,
"64dd" to Platform._64DD,
"palm-os" to Platform.PALM_OS,
"arduboy" to Platform.ARDUBOY,
"vsmile" to Platform.V_SMILE,
"visual-memory-unit-slash-visual-memory-system" to Platform.VISUAL_MEMORY_UNIT_VISUAL_MEMORY_SYSTEM,
"pocketstation" to Platform.POCKETSTATION,
"meta-quest-3" to Platform.META_QUEST_3,
"visionos" to Platform.VISIONOS,
"arcadia-2001" to Platform.ARCADIA_2001,
"gizmondo" to Platform.GIZMONDO,
"r-zone" to Platform.R_ZONE,
"apple-pippin" to Platform.APPLE_PIPPIN,
"panasonic-jungle" to Platform.PANASONIC_JUNGLE,
"panasonic-m2" to Platform.PANASONIC_M2,
"terebikko-slash-see-n-say-video-phone" to Platform.TEREBIKKO_SEE_N_SAY_VIDEO_PHONE,
"super-acan" to Platform.SUPER_ACAN,
"tomy-tutor-slash-pyuta-slash-grandstand-tutor" to Platform.TOMY_TUTOR_PYUTA_GRANDSTAND_TUTOR,
"sega-cd-32x" to Platform.SEGA_CD_32X,
"digiblast" to Platform.DIGIBLAST,
"laseractive" to Platform.LASERACTIVE,
"uzebox" to Platform.UZEBOX,
"elektor-tv-games-computer" to Platform.ELEKTOR_TV_GAMES_COMPUTER,
"gx4000" to Platform.AMSTRAD_GX4000,
"advanced-pico-beena" to Platform.ADVANCED_PICO_BEENA,
"switch-2" to Platform.NINTENDO_SWITCH_2,
"polymega" to Platform.POLYMEGA,
"e-reader-slash-card-e-reader" to Platform.E_READER_CARD_E_READER
)
/**
* Map from Gameyfin Platform enum to IGDB platform slug
*/
private val gameyfinToIgdbMap: Map<Platform, String> =
igdbToGameyfinMap.entries.associateBy({ it.value }, { it.key })
/**
* Convert an IGDB platform slug to a Gameyfin Platform enum
* @param igdbPlatformSlug The IGDB platform slug
* @return The corresponding Platform enum value, or null if not found
*/
fun toGameyfin(igdbPlatformSlug: String): Platform {
val platform = igdbToGameyfinMap[igdbPlatformSlug]
?: throw IllegalArgumentException("Could not map IGDB platform with slug '$igdbPlatformSlug' to Gameyfin Platform")
return platform
}
/**
* Convert multiple IGDB platform slugs to Gameyfin Platform enums
* @param igdbPlatformSlugs Collection of IGDB platform slugs
* @return Set of Platform enums (unmapped slugs are filtered out)
*/
fun toGameyfin(igdbPlatformSlugs: Collection<String>): Set<Platform> {
return igdbPlatformSlugs.map { toGameyfin(it) }.toSet()
}
/**
* Convert a Gameyfin Platform enum to an IGDB platform slug
* @param platform The Platform enum value
* @return The corresponding IGDB platform slug, or null if not found
*/
fun toIgdb(platform: Platform): String {
val slug = gameyfinToIgdbMap[platform]
?: throw IllegalArgumentException("Could not map Gameyfin Platform '${platform.displayName}' to IGDB platform slug")
return slug
}
/**
* Convert multiple Gameyfin Platform enums to IGDB platform slugs
* @param platforms Collection of Platform enums
* @return Set of IGDB platform slugs (unmapped platforms are filtered out)
*/
fun toIgdb(platforms: Collection<Platform>): Set<String> {
return platforms.map { toIgdb(it) }.toSet()
}
/**
* Get all IGDB platform slugs that have mappings
* @return Set of all mapped IGDB platform slugs
*/
fun getAllMappedIgdbSlugs(): Set<String> {
return igdbToGameyfinMap.keys
}
}
}
@@ -0,0 +1,27 @@
package org.gameyfin.plugins.metadata.igdb.mapper
import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective
import org.slf4j.LoggerFactory
class PlayerPerspectiveMapper {
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
fun playerPerspective(perspective: proto.PlayerPerspective): PlayerPerspective {
return when (perspective.slug) {
"first-person" -> PlayerPerspective.FIRST_PERSON
"third-person" -> PlayerPerspective.THIRD_PERSON
"bird-view-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
"bird-view-slash-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
"side-view" -> PlayerPerspective.SIDE_VIEW
"text" -> PlayerPerspective.TEXT
"auditory" -> PlayerPerspective.AUDITORY
"virtual-reality" -> PlayerPerspective.VIRTUAL_REALITY
else -> {
log.warn("Unknown player perspective: {}", perspective.slug)
PlayerPerspective.UNKNOWN
}
}
}
}
}
@@ -0,0 +1,43 @@
package org.gameyfin.plugins.metadata.igdb.mapper
import org.gameyfin.pluginapi.gamemetadata.Theme
import org.slf4j.LoggerFactory
class ThemeMapper {
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
fun theme(theme: proto.Theme): Theme {
return when (theme.slug) {
"action" -> Theme.ACTION
"fantasy" -> Theme.FANTASY
"horror" -> Theme.HORROR
"sci-fi" -> Theme.SCIENCE_FICTION
"science-fiction" -> Theme.SCIENCE_FICTION
"mystery" -> Theme.MYSTERY
"thriller" -> Theme.THRILLER
"survival" -> Theme.SURVIVAL
"historical" -> Theme.HISTORICAL
"stealth" -> Theme.STEALTH
"comedy" -> Theme.COMEDY
"business" -> Theme.BUSINESS
"drama" -> Theme.DRAMA
"non-fiction" -> Theme.NON_FICTION
"sandbox" -> Theme.SANDBOX
"educational" -> Theme.EDUCATIONAL
"kids" -> Theme.KIDS
"open-world" -> Theme.OPEN_WORLD
"warfare" -> Theme.WARFARE
"party" -> Theme.PARTY
"4x-explore-expand-exploit-and-exterminate" -> Theme.FOUR_X
"erotic" -> Theme.EROTIC
"romance" -> Theme.ROMANCE
else -> {
log.warn("Unknown theme: {}", theme.slug)
Theme.UNKNOWN
}
}
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
Plugin-Version: 1.0.0
Plugin-Version: 1.1.0
Plugin-Class: org.gameyfin.plugins.metadata.igdb.IgdbPlugin
Plugin-Id: org.gameyfin.plugins.metadata.igdb
Plugin-Name: IGDB Metadata
@@ -0,0 +1,189 @@
package org.gameyfin.plugins.metadata.igdb.mapper
import org.gameyfin.pluginapi.gamemetadata.Platform
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertAll
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
/**
* Unit tests for the PlatformMapper to ensure complete bidirectional mapping coverage
* between IGDB platform slugs and Gameyfin Platform enums.
*
* Goal: 100% 1:1 mapping in both directions
*/
class PlatformMapperTest {
@Test
fun `all Platform enums should map to an IGDB slug`() {
val unmappedPlatforms = mutableListOf<Platform>()
Platform.entries.forEach { platform ->
try {
PlatformMapper.toIgdb(platform)
} catch (_: IllegalArgumentException) {
unmappedPlatforms.add(platform)
}
}
assertEquals(
emptyList(),
unmappedPlatforms,
"The following Platform enum entries do not have IGDB slug mappings: $unmappedPlatforms"
)
}
@Test
fun `all mapped IGDB platform slugs should map to a Platform`() {
val allMappedIgdbSlugs = PlatformMapper.getAllMappedIgdbSlugs()
val unmappedIgdbSlugs = mutableListOf<String>()
allMappedIgdbSlugs.forEach { igdbSlug ->
try {
PlatformMapper.toGameyfin(igdbSlug)
} catch (_: IllegalArgumentException) {
unmappedIgdbSlugs.add(igdbSlug)
}
}
assertEquals(
emptyList(),
unmappedIgdbSlugs,
"The following IGDB slugs from the mapper do not have Platform mappings: $unmappedIgdbSlugs"
)
}
@Test
fun `bidirectional mapping should be consistent`() {
val inconsistencies = mutableListOf<String>()
Platform.entries.forEach { platform ->
val igdbSlug = PlatformMapper.toIgdb(platform)
assertNotNull(igdbSlug, "Platform $platform should map to an IGDB slug")
val mappedBack = PlatformMapper.toGameyfin(igdbSlug)
if (mappedBack != platform) {
inconsistencies.add(
"Platform.$platform -> IGDB slug '$igdbSlug' -> Platform.$mappedBack (expected Platform.$platform)"
)
}
}
assertEquals(
emptyList(),
inconsistencies,
"Bidirectional mapping inconsistencies found:\n${inconsistencies.joinToString("\n")}"
)
}
@Test
fun `toGameyfin collection should map all slugs`() {
val testSlugs = setOf("ps4--1", "xboxone", "switch") // PS4, Xbox One, Nintendo Switch
val platforms = PlatformMapper.toGameyfin(testSlugs)
assertEquals(
setOf(Platform.PLAYSTATION_4, Platform.XBOX_ONE, Platform.NINTENDO_SWITCH),
platforms,
"Should map all provided IGDB slugs to platforms"
)
}
@Test
fun `toIgdb collection should map all platforms`() {
val platformSet = setOf(
Platform.PLAYSTATION_4,
Platform.XBOX_ONE,
Platform.NINTENDO_SWITCH,
Platform.PC_MICROSOFT_WINDOWS
)
val igdbSlugs = PlatformMapper.toIgdb(platformSet)
assertEquals(
setOf("ps4--1", "xboxone", "switch", "win"),
igdbSlugs,
"Should map all platforms to their IGDB slugs"
)
}
@Test
fun `specific platform mappings should be correct`() {
assertAll(
// Modern consoles
{ assertEquals(Platform.PLAYSTATION_5, PlatformMapper.toGameyfin("ps5")) },
{ assertEquals("ps5", PlatformMapper.toIgdb(Platform.PLAYSTATION_5)) },
{ assertEquals(Platform.XBOX_SERIES_X_S, PlatformMapper.toGameyfin("series-x-s")) },
{ assertEquals("series-x-s", PlatformMapper.toIgdb(Platform.XBOX_SERIES_X_S)) },
{ assertEquals(Platform.NINTENDO_SWITCH, PlatformMapper.toGameyfin("switch")) },
{ assertEquals("switch", PlatformMapper.toIgdb(Platform.NINTENDO_SWITCH)) },
// PC platforms
{ assertEquals(Platform.PC_MICROSOFT_WINDOWS, PlatformMapper.toGameyfin("win")) },
{ assertEquals("win", PlatformMapper.toIgdb(Platform.PC_MICROSOFT_WINDOWS)) },
{ assertEquals(Platform.LINUX, PlatformMapper.toGameyfin("linux")) },
{ assertEquals("linux", PlatformMapper.toIgdb(Platform.LINUX)) },
{ assertEquals(Platform.MAC, PlatformMapper.toGameyfin("mac")) },
{ assertEquals("mac", PlatformMapper.toIgdb(Platform.MAC)) },
// Classic consoles
{ assertEquals(Platform.NINTENDO_ENTERTAINMENT_SYSTEM, PlatformMapper.toGameyfin("nes")) },
{ assertEquals("nes", PlatformMapper.toIgdb(Platform.NINTENDO_ENTERTAINMENT_SYSTEM)) },
{ assertEquals(Platform.SUPER_NINTENDO_ENTERTAINMENT_SYSTEM, PlatformMapper.toGameyfin("snes")) },
{ assertEquals("snes", PlatformMapper.toIgdb(Platform.SUPER_NINTENDO_ENTERTAINMENT_SYSTEM)) },
// Mobile
{ assertEquals(Platform.IOS, PlatformMapper.toGameyfin("ios")) },
{ assertEquals("ios", PlatformMapper.toIgdb(Platform.IOS)) },
{ assertEquals(Platform.ANDROID, PlatformMapper.toGameyfin("android")) },
{ assertEquals("android", PlatformMapper.toIgdb(Platform.ANDROID)) },
// VR
{ assertEquals(Platform.PLAYSTATION_VR2, PlatformMapper.toGameyfin("psvr2")) },
{ assertEquals("psvr2", PlatformMapper.toIgdb(Platform.PLAYSTATION_VR2)) },
{ assertEquals(Platform.META_QUEST_3, PlatformMapper.toGameyfin("meta-quest-3")) },
{ assertEquals("meta-quest-3", PlatformMapper.toIgdb(Platform.META_QUEST_3)) }
)
}
@Test
fun `no duplicate mappings should exist`() {
// Get all mapped IGDB slugs from all Platform enums
val allMappedIgdbSlugs = Platform.entries.map { PlatformMapper.toIgdb(it) }
val uniqueIgdbSlugs = allMappedIgdbSlugs.toSet()
assertEquals(
allMappedIgdbSlugs.size,
uniqueIgdbSlugs.size,
"Duplicate IGDB slug mappings found. Each Platform should map to a unique IGDB slug."
)
// Get all mapped Platforms from all IGDB slugs
val allIgdbSlugs = PlatformMapper.getAllMappedIgdbSlugs()
val allMappedPlatforms = allIgdbSlugs.map { PlatformMapper.toGameyfin(it) }
val uniquePlatforms = allMappedPlatforms.toSet()
assertEquals(
allMappedPlatforms.size,
uniquePlatforms.size,
"Duplicate Platform mappings found. Each IGDB slug should map to a unique Platform."
)
}
@Test
fun `mapped IGDB slugs count should equal Platform enum count for 1-to-1 mapping`() {
val allMappedIgdbSlugs = PlatformMapper.getAllMappedIgdbSlugs()
assertEquals(
Platform.entries.size,
allMappedIgdbSlugs.size,
"For 1:1 mapping, the number of mapped IGDB slugs (${allMappedIgdbSlugs.size}) should equal the number of Platform enums (${Platform.entries.size})"
)
}
}