Finish implementing IGDB plugin

Updated Plugin API
Updated Steam plugin
Other minor improvements
This commit is contained in:
grimsi 2024-12-21 19:01:33 +01:00
parent 7b12ce1029
commit 890748fb7c
22 changed files with 347 additions and 90 deletions

View File

@ -10,6 +10,7 @@
</option>
<option name="taskNames">
<list>
<option value="clean" />
<option value="build" />
</list>
</option>

View File

@ -10,6 +10,7 @@
</option>
<option name="taskNames">
<list>
<option value="clean" />
<option value="build" />
</list>
</option>

View File

@ -94,19 +94,15 @@ class GameyfinPluginManager(
}
try {
log.info { "${"Start plugin '{}'"} ${getPluginLabel(pluginWrapper.descriptor)}"}
log.info { "Start plugin '${getPluginLabel(pluginWrapper.descriptor)}'"}
pluginWrapper.plugin.start()
pluginWrapper.pluginState = PluginState.STARTED
pluginWrapper.failedException = null
startedPlugins.add(pluginWrapper)
} catch (e: LinkageError) {
pluginWrapper.pluginState = PluginState.FAILED
pluginWrapper.failedException = e
log.error { "${"Unable to start plugin '{}'"} ${getPluginLabel(pluginWrapper.descriptor)} $e"}
} catch (e: Exception) {
pluginWrapper.pluginState = PluginState.FAILED
pluginWrapper.failedException = e
log.error { "${"Unable to start plugin '{}'"} ${getPluginLabel(pluginWrapper.descriptor)} $e"}
log.error { "Unable to start plugin '${getPluginLabel(pluginWrapper.descriptor)}': $e"}
} finally {
firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginState))
}

View File

@ -9,9 +9,9 @@ import jakarta.annotation.security.RolesAllowed
class PluginManagementEndpoint(
private val pluginManagementService: PluginManagementService
) {
fun getPlugins() = pluginManagementService.getPlugins()
fun getPlugins() = pluginManagementService.getPluginDtos()
fun getPlugin(pluginId: String) = pluginManagementService.getPlugin(pluginId)
fun getPlugin(pluginId: String) = pluginManagementService.getPluginDto(pluginId)
fun startPlugin(pluginId: String) = pluginManagementService.startPlugin(pluginId)

View File

@ -1,12 +1,15 @@
package de.grimsi.gameyfin.core.plugins.management
import org.pf4j.ExtensionPoint
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
@Service
class PluginManagementService(
private val pluginManager: GameyfinPluginManager
private val pluginManager: GameyfinPluginManager,
private val pluginManagementRepository: PluginManagementRepository
) {
fun getPlugins(): List<PluginDto> {
fun getPluginDtos(): List<PluginDto> {
return pluginManager.plugins.map {
PluginDto(
it.pluginId,
@ -18,7 +21,7 @@ class PluginManagementService(
}
}
fun getPlugin(pluginId: String): PluginDto {
fun getPluginDto(pluginId: String): PluginDto {
val plugin = pluginManager.getPlugin(pluginId)
return PluginDto(
plugin.pluginId,
@ -29,6 +32,17 @@ class PluginManagementService(
)
}
fun getPluginManagementEntry(pluginId: String): PluginManagementEntry {
return pluginManagementRepository.findByIdOrNull(pluginId)
?: throw IllegalArgumentException("Plugin with ID $pluginId not found")
}
fun getPluginManagementEntry(clazz: Class<ExtensionPoint>): PluginManagementEntry {
val pluginWrapper = pluginManager.whichPlugin(clazz)
return pluginManagementRepository.findByIdOrNull(pluginWrapper.pluginId)
?: throw IllegalArgumentException("Plugin with class $clazz not found")
}
fun startPlugin(pluginId: String) {
pluginManager.startPlugin(pluginId)
}

View File

@ -1,34 +0,0 @@
package de.grimsi.gameyfin.games
import jakarta.persistence.*
import java.time.Instant
@Entity
class Game(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
val title: String,
@Lob
@Column(columnDefinition = "CLOB")
val comment: String? = null,
@Lob
@Column(columnDefinition = "CLOB")
val summary: String,
val release: Instant,
@ElementCollection
val publishers: List<String>,
@ElementCollection
val developers: List<String>,
@Column(unique = true)
val path: String,
val source: String
)

View File

@ -1,5 +1,14 @@
package de.grimsi.gameyfin.games
import de.grimsi.gameyfin.core.plugins.management.PluginManagementService
import de.grimsi.gameyfin.games.entities.Company
import de.grimsi.gameyfin.games.entities.CompanyType
import de.grimsi.gameyfin.games.entities.Game
import de.grimsi.gameyfin.games.entities.Screenshot
import de.grimsi.gameyfin.games.repositories.CompanyRepository
import de.grimsi.gameyfin.games.repositories.GameRepository
import de.grimsi.gameyfin.games.repositories.ScreenshotContentStore
import de.grimsi.gameyfin.games.repositories.ScreenshotRepository
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import io.github.oshai.kotlinlogging.KotlinLogging
@ -9,12 +18,18 @@ import kotlinx.coroutines.runBlocking
import org.pf4j.PluginManager
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import java.net.URL
import java.net.URLConnection
import java.nio.file.Path
@Service
class GameService(
private val pluginManager: PluginManager,
private val pluginManagementService: PluginManagementService,
private val gameRepository: GameRepository,
private val pluginManager: PluginManager
private val companyRepository: CompanyRepository,
private val screenshotRepository: ScreenshotRepository,
private val screenshotContentStore: ScreenshotContentStore
) {
private val log = KotlinLogging.logger {}
@ -47,15 +62,11 @@ class GameService(
val (plugin, metadata) = metadataResults.entries.firstOrNull { it.value != null }
?: throw NoMatchException("Could not match game at $path")
val game = Game(
title = metadata!!.title,
summary = metadata.description,
release = metadata.release,
publishers = metadata.publishedBy,
developers = metadata.developedBy,
path = path.toString(),
source = plugin.javaClass.name
)
if (metadata == null) {
throw NoMatchException("Plugin ${plugin.javaClass} returned invalid metadata for game at $path")
}
val game = toEntity(metadata, path, plugin)
return createOrUpdate(game)
}
@ -74,13 +85,48 @@ class GameService(
}
private fun toDto(game: Game): GameDto {
if (game.id == null) {
throw IllegalArgumentException("Game ID is null")
}
val gameId = game.id ?: throw IllegalArgumentException("Game ID is null")
return GameDto(
id = game.id!!,
id = gameId,
title = game.title
)
}
private fun toEntity(metadata: GameMetadata, path: Path, source: GameMetadataProvider): Game {
return Game(
title = metadata.title,
summary = metadata.description,
release = metadata.release,
publishers = metadata.publishedBy.map { toEntity(it, CompanyType.PUBLISHER) }.toSet(),
developers = metadata.developedBy.map { toEntity(it, CompanyType.DEVELOPER) }.toSet(),
genres = metadata.genres,
themes = metadata.themes,
keywords = metadata.keywords,
features = metadata.features,
perspectives = metadata.perspectives,
screenshots = metadata.screenshotUrls.map { downloadAndPersist(it) }.toSet(),
videoUrls = metadata.videoUrls,
path = path.toString(),
source = pluginManagementService.getPluginManagementEntry(source.javaClass)
)
}
private fun toEntity(companyName: String, companyType: CompanyType): Company {
companyRepository.findByNameAndType(companyName, companyType)?.let { return it }
val company = Company(name = companyName, type = companyType)
return companyRepository.save(company)
}
private fun downloadAndPersist(screenshotUrl: URL): Screenshot {
screenshotRepository.findByOriginalUrl(screenshotUrl)?.let { return it }
val screenshot = Screenshot(originalUrl = screenshotUrl)
screenshotUrl.openStream().use { input ->
val mimeType = URLConnection.guessContentTypeFromStream(input)
screenshot.mimeType = mimeType
screenshotContentStore.setContent(screenshot, input)
}
return screenshotRepository.save(screenshot)
}
}

View File

@ -0,0 +1,18 @@
package de.grimsi.gameyfin.games.entities
import jakarta.persistence.*
@Entity
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["name", "type"])])
class Company(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
val name: String,
val type: CompanyType
)
enum class CompanyType {
DEVELOPER,
PUBLISHER
}

View File

@ -0,0 +1,62 @@
package de.grimsi.gameyfin.games.entities
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameFeature
import de.grimsi.gameyfin.pluginapi.gamemetadata.Genre
import de.grimsi.gameyfin.pluginapi.gamemetadata.PlayerPerspective
import de.grimsi.gameyfin.pluginapi.gamemetadata.Theme
import jakarta.persistence.*
import java.net.URL
import java.time.Instant
@Entity
class Game(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
val title: String,
@Lob
@Column(columnDefinition = "CLOB")
val comment: String? = null,
@Lob
@Column(columnDefinition = "CLOB")
val summary: String,
val release: Instant,
@OneToMany(cascade = [CascadeType.MERGE])
val publishers: Set<Company>,
@OneToMany(cascade = [CascadeType.MERGE])
val developers: Set<Company>,
@ElementCollection
val genres: Set<Genre>,
@ElementCollection
val themes: Set<Theme>,
@ElementCollection
val keywords: Set<String>,
@ElementCollection
val features: Set<GameFeature>,
@ElementCollection
val perspectives: Set<PlayerPerspective>,
@OneToMany(cascade = [CascadeType.MERGE])
val screenshots: Set<Screenshot>,
@ElementCollection
val videoUrls: Set<URL>,
@Column(unique = true)
val path: String,
@ManyToOne
val source: PluginManagementEntry
)

View File

@ -0,0 +1,32 @@
package de.grimsi.gameyfin.games.entities
import jakarta.annotation.Nullable
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import org.springframework.content.commons.annotations.ContentId
import org.springframework.content.commons.annotations.ContentLength
import org.springframework.content.commons.annotations.MimeType
import java.net.URL
@Entity
class Screenshot(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
val originalUrl: URL,
@ContentId
@Nullable
var contentId: String? = null,
@ContentLength
@Nullable
var contentLength: Long? = null,
@MimeType
@Nullable
var mimeType: String? = null
)

View File

@ -0,0 +1,4 @@
package de.grimsi.gameyfin.games.entities
class Video {
}

View File

@ -0,0 +1,9 @@
package de.grimsi.gameyfin.games.repositories
import de.grimsi.gameyfin.games.entities.Company
import de.grimsi.gameyfin.games.entities.CompanyType
import org.springframework.data.jpa.repository.JpaRepository
interface CompanyRepository : JpaRepository<Company, Long> {
fun findByNameAndType(name: String, type: CompanyType): Company?
}

View File

@ -1,5 +1,6 @@
package de.grimsi.gameyfin.games
package de.grimsi.gameyfin.games.repositories
import de.grimsi.gameyfin.games.entities.Game
import org.springframework.data.jpa.repository.JpaRepository
interface GameRepository : JpaRepository<Game, Long> {

View File

@ -0,0 +1,8 @@
package de.grimsi.gameyfin.games.repositories
import de.grimsi.gameyfin.games.entities.Screenshot
import org.springframework.content.commons.store.ContentStore
import org.springframework.stereotype.Repository
@Repository
interface ScreenshotContentStore : ContentStore<Screenshot, String>

View File

@ -0,0 +1,9 @@
package de.grimsi.gameyfin.games.repositories
import de.grimsi.gameyfin.games.entities.Screenshot
import org.springframework.data.jpa.repository.JpaRepository
import java.net.URL
interface ScreenshotRepository : JpaRepository<Screenshot, Long> {
fun findByOriginalUrl(originalUrl: URL): Screenshot?
}

View File

@ -1,6 +1,6 @@
package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.games.Game
import de.grimsi.gameyfin.games.entities.Game
import jakarta.persistence.*
@Entity

View File

@ -2,7 +2,7 @@ package de.grimsi.gameyfin.libraries
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.games.Game
import de.grimsi.gameyfin.games.entities.Game
import jakarta.annotation.security.RolesAllowed
@Endpoint

View File

@ -2,8 +2,8 @@ package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.games.Game
import de.grimsi.gameyfin.games.GameService
import de.grimsi.gameyfin.games.entities.Game
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import java.nio.file.Path

View File

@ -9,14 +9,15 @@ class GameMetadata(
val release: Instant,
val userRating: Int?,
val criticRating: Int?,
val developedBy: List<String>,
val publishedBy: List<String>,
val genres: List<Genre>,
val themes: List<Theme>,
val screenshotUrls: List<URL>,
val videoUrls: List<URL>,
val features: List<GameFeature>,
val perspectives: List<PlayerPerspective>
val developedBy: Set<String>,
val publishedBy: Set<String>,
val genres: Set<Genre>,
val themes: Set<Theme>,
val keywords: Set<String>,
val screenshotUrls: Set<URL>,
val videoUrls: Set<URL>,
val features: Set<GameFeature>,
val perspectives: Set<PlayerPerspective>
)
enum class Genre {
@ -95,10 +96,12 @@ enum class GameFeature {
ONLINE_PVE,
LOCAL_PVP,
LOCAL_PVE,
CROSSPLAY
CROSSPLAY,
SPLITSCREEN
}
enum class PlayerPerspective {
UNKNOWN,
FIRST_PERSON,
THIRD_PERSON,
BIRD_VIEW_ISOMETRIC,

View File

@ -59,9 +59,43 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
@Extension
class IgdbMetadataProvider : GameMetadataProvider {
private val QUERY_FIELDS = listOf(
"slug",
"name",
"summary",
"first_release_date",
"rating",
"aggregated_rating",
"total_rating",
"category",
"multiplayer_modes.lancoop",
"game_modes.slug",
"game_modes.name",
"cover.image_id",
"screenshots.image_id",
"videos.video_id",
"involved_companies.company.slug",
"involved_companies.company.name",
"involved_companies.developer",
"involved_companies.publisher",
"involved_companies.company.logo.image_id",
"genres.slug",
"genres.name",
"keywords.slug",
"keywords.name",
"themes.slug",
"themes.name",
"player_perspectives.slug",
"player_perspectives.name",
"platforms.slug",
"platforms.name",
"platforms.platform_logo.image_id"
).joinToString(",")
override fun fetchMetadata(gameId: String): GameMetadata? {
val findBySlugQuery = APICalypse()
.fields("*")
.fields(QUERY_FIELDS)
.where("slug = \"${guessSlug(gameId)}\"")
// First step: Try to find the game by guessing the slug
@ -70,7 +104,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
// Second step: Try a fuzzy search
if (game == null) {
val searchByNameQuery = APICalypse()
.fields("*")
.fields(QUERY_FIELDS)
.limit(100)
.search(gameId)
@ -91,14 +125,15 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
release = Instant.ofEpochSecond(game.firstReleaseDate.seconds),
userRating = game.rating.toInt(),
criticRating = game.aggregatedRating.toInt(),
developedBy = game.involvedCompaniesList.filter { it.developer }.map { it.company.name },
publishedBy = game.involvedCompaniesList.filter { it.publisher }.map { it.company.name },
genres = game.genresList.map { Mapper.genre(it) },
themes = game.themesList.map { Mapper.theme(it) },
screenshotUrls = listOf(),
videoUrls = listOf(),
features = listOf(),
perspectives = listOf()
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(),
keywords = game.keywordsList.map { it.name }.toSet(),
screenshotUrls = game.screenshotsList.map { Mapper.screenshot(it) }.toSet(),
videoUrls = game.videosList.map { Mapper.video(it) }.toSet(),
features = Mapper.gameFeatures(game),
perspectives = game.playerPerspectivesList.map { Mapper.playerPerspective(it) }.toSet()
)
}

View File

@ -1,8 +1,15 @@
package de.grimsi.gameyfin.plugins.igdb
import com.api.igdb.utils.ImageSize
import com.api.igdb.utils.ImageType
import com.api.igdb.utils.imageBuilder
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameFeature
import de.grimsi.gameyfin.pluginapi.gamemetadata.Genre
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 {
@ -70,5 +77,49 @@ class Mapper {
}
}
}
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
"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: proto.Screenshot): URL {
return URI(imageBuilder(screenshot.imageId, ImageSize.SCREENSHOT_HUGE, ImageType.PNG)).toURL()
}
fun video(video: proto.GameVideo): URL {
return URI("https://www.youtube.com/watch?v=${video.videoId}").toURL()
}
fun gameFeatures(game: proto.Game): Set<GameFeature> {
var 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)
"co-operative" -> gameFeatures.add(GameFeature.CO_OP)
"split-screen" -> gameFeatures.add(GameFeature.SPLITSCREEN)
else -> {
log.warn("Unknown game mode: {}", gameMode.slug)
}
}
}
return gameFeatures
}
}
}

View File

@ -112,14 +112,15 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
release = date(game["release_date"]?.jsonObject["date"]?.jsonPrimitive?.content!!),
userRating = 0,
criticRating = 0,
developedBy = stringList(game, "developers"),
publishedBy = stringList(game, "publishers"),
genres = emptyList(),
themes = emptyList(),
screenshotUrls = emptyList(),
videoUrls = emptyList(),
features = emptyList(),
perspectives = emptyList()
developedBy = stringList(game, "developers").toSet(),
publishedBy = stringList(game, "publishers").toSet(),
genres = emptySet(),
themes = emptySet(),
keywords = emptySet(),
screenshotUrls = emptySet(),
videoUrls = emptySet(),
features = emptySet(),
perspectives = emptySet()
)
return metadata