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
+1
View File
@@ -10,6 +10,7 @@
</option> </option>
<option name="taskNames"> <option name="taskNames">
<list> <list>
<option value="clean" />
<option value="build" /> <option value="build" />
</list> </list>
</option> </option>
+1
View File
@@ -10,6 +10,7 @@
</option> </option>
<option name="taskNames"> <option name="taskNames">
<list> <list>
<option value="clean" />
<option value="build" /> <option value="build" />
</list> </list>
</option> </option>
@@ -94,19 +94,15 @@ class GameyfinPluginManager(
} }
try { try {
log.info { "${"Start plugin '{}'"} ${getPluginLabel(pluginWrapper.descriptor)}"} log.info { "Start plugin '${getPluginLabel(pluginWrapper.descriptor)}'"}
pluginWrapper.plugin.start() pluginWrapper.plugin.start()
pluginWrapper.pluginState = PluginState.STARTED pluginWrapper.pluginState = PluginState.STARTED
pluginWrapper.failedException = null pluginWrapper.failedException = null
startedPlugins.add(pluginWrapper) 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) { } catch (e: Exception) {
pluginWrapper.pluginState = PluginState.FAILED pluginWrapper.pluginState = PluginState.FAILED
pluginWrapper.failedException = e pluginWrapper.failedException = e
log.error { "${"Unable to start plugin '{}'"} ${getPluginLabel(pluginWrapper.descriptor)} $e"} log.error { "Unable to start plugin '${getPluginLabel(pluginWrapper.descriptor)}': $e"}
} finally { } finally {
firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginState)) firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginState))
} }
@@ -9,9 +9,9 @@ import jakarta.annotation.security.RolesAllowed
class PluginManagementEndpoint( class PluginManagementEndpoint(
private val pluginManagementService: PluginManagementService 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) fun startPlugin(pluginId: String) = pluginManagementService.startPlugin(pluginId)
@@ -1,12 +1,15 @@
package de.grimsi.gameyfin.core.plugins.management package de.grimsi.gameyfin.core.plugins.management
import org.pf4j.ExtensionPoint
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Service @Service
class PluginManagementService( 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 { return pluginManager.plugins.map {
PluginDto( PluginDto(
it.pluginId, it.pluginId,
@@ -18,7 +21,7 @@ class PluginManagementService(
} }
} }
fun getPlugin(pluginId: String): PluginDto { fun getPluginDto(pluginId: String): PluginDto {
val plugin = pluginManager.getPlugin(pluginId) val plugin = pluginManager.getPlugin(pluginId)
return PluginDto( return PluginDto(
plugin.pluginId, 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) { fun startPlugin(pluginId: String) {
pluginManager.startPlugin(pluginId) pluginManager.startPlugin(pluginId)
} }
@@ -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
)
@@ -1,5 +1,14 @@
package de.grimsi.gameyfin.games 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.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
@@ -9,12 +18,18 @@ import kotlinx.coroutines.runBlocking
import org.pf4j.PluginManager import org.pf4j.PluginManager
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.net.URL
import java.net.URLConnection
import java.nio.file.Path import java.nio.file.Path
@Service @Service
class GameService( class GameService(
private val pluginManager: PluginManager,
private val pluginManagementService: PluginManagementService,
private val gameRepository: GameRepository, 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 {} private val log = KotlinLogging.logger {}
@@ -47,15 +62,11 @@ class GameService(
val (plugin, metadata) = metadataResults.entries.firstOrNull { it.value != null } val (plugin, metadata) = metadataResults.entries.firstOrNull { it.value != null }
?: throw NoMatchException("Could not match game at $path") ?: throw NoMatchException("Could not match game at $path")
val game = Game( if (metadata == null) {
title = metadata!!.title, throw NoMatchException("Plugin ${plugin.javaClass} returned invalid metadata for game at $path")
summary = metadata.description, }
release = metadata.release,
publishers = metadata.publishedBy, val game = toEntity(metadata, path, plugin)
developers = metadata.developedBy,
path = path.toString(),
source = plugin.javaClass.name
)
return createOrUpdate(game) return createOrUpdate(game)
} }
@@ -74,13 +85,48 @@ class GameService(
} }
private fun toDto(game: Game): GameDto { private fun toDto(game: Game): GameDto {
if (game.id == null) { val gameId = game.id ?: throw IllegalArgumentException("Game ID is null")
throw IllegalArgumentException("Game ID is null")
}
return GameDto( return GameDto(
id = game.id!!, id = gameId,
title = game.title 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)
}
} }
@@ -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
}
@@ -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
)
@@ -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
)
@@ -0,0 +1,4 @@
package de.grimsi.gameyfin.games.entities
class Video {
}
@@ -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?
}
@@ -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 import org.springframework.data.jpa.repository.JpaRepository
interface GameRepository : JpaRepository<Game, Long> { interface GameRepository : JpaRepository<Game, Long> {
@@ -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>
@@ -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?
}
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.libraries package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.games.Game import de.grimsi.gameyfin.games.entities.Game
import jakarta.persistence.* import jakarta.persistence.*
@Entity @Entity
@@ -2,7 +2,7 @@ package de.grimsi.gameyfin.libraries
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.games.Game import de.grimsi.gameyfin.games.entities.Game
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@Endpoint @Endpoint
@@ -2,8 +2,8 @@ package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.config.ConfigProperties import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.games.Game
import de.grimsi.gameyfin.games.GameService import de.grimsi.gameyfin.games.GameService
import de.grimsi.gameyfin.games.entities.Game
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.nio.file.Path import java.nio.file.Path
@@ -9,14 +9,15 @@ class GameMetadata(
val release: Instant, val release: Instant,
val userRating: Int?, val userRating: Int?,
val criticRating: Int?, val criticRating: Int?,
val developedBy: List<String>, val developedBy: Set<String>,
val publishedBy: List<String>, val publishedBy: Set<String>,
val genres: List<Genre>, val genres: Set<Genre>,
val themes: List<Theme>, val themes: Set<Theme>,
val screenshotUrls: List<URL>, val keywords: Set<String>,
val videoUrls: List<URL>, val screenshotUrls: Set<URL>,
val features: List<GameFeature>, val videoUrls: Set<URL>,
val perspectives: List<PlayerPerspective> val features: Set<GameFeature>,
val perspectives: Set<PlayerPerspective>
) )
enum class Genre { enum class Genre {
@@ -95,10 +96,12 @@ enum class GameFeature {
ONLINE_PVE, ONLINE_PVE,
LOCAL_PVP, LOCAL_PVP,
LOCAL_PVE, LOCAL_PVE,
CROSSPLAY CROSSPLAY,
SPLITSCREEN
} }
enum class PlayerPerspective { enum class PlayerPerspective {
UNKNOWN,
FIRST_PERSON, FIRST_PERSON,
THIRD_PERSON, THIRD_PERSON,
BIRD_VIEW_ISOMETRIC, BIRD_VIEW_ISOMETRIC,
@@ -59,9 +59,43 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
@Extension @Extension
class IgdbMetadataProvider : GameMetadataProvider { 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? { override fun fetchMetadata(gameId: String): GameMetadata? {
val findBySlugQuery = APICalypse() val findBySlugQuery = APICalypse()
.fields("*") .fields(QUERY_FIELDS)
.where("slug = \"${guessSlug(gameId)}\"") .where("slug = \"${guessSlug(gameId)}\"")
// First step: Try to find the game by guessing the slug // 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 // Second step: Try a fuzzy search
if (game == null) { if (game == null) {
val searchByNameQuery = APICalypse() val searchByNameQuery = APICalypse()
.fields("*") .fields(QUERY_FIELDS)
.limit(100) .limit(100)
.search(gameId) .search(gameId)
@@ -91,14 +125,15 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
release = Instant.ofEpochSecond(game.firstReleaseDate.seconds), release = Instant.ofEpochSecond(game.firstReleaseDate.seconds),
userRating = game.rating.toInt(), userRating = game.rating.toInt(),
criticRating = game.aggregatedRating.toInt(), criticRating = game.aggregatedRating.toInt(),
developedBy = game.involvedCompaniesList.filter { it.developer }.map { it.company.name }, developedBy = game.involvedCompaniesList.filter { it.developer }.map { it.company.name }.toSet(),
publishedBy = game.involvedCompaniesList.filter { it.publisher }.map { it.company.name }, publishedBy = game.involvedCompaniesList.filter { it.publisher }.map { it.company.name }.toSet(),
genres = game.genresList.map { Mapper.genre(it) }, genres = game.genresList.map { Mapper.genre(it) }.toSet(),
themes = game.themesList.map { Mapper.theme(it) }, themes = game.themesList.map { Mapper.theme(it) }.toSet(),
screenshotUrls = listOf(), keywords = game.keywordsList.map { it.name }.toSet(),
videoUrls = listOf(), screenshotUrls = game.screenshotsList.map { Mapper.screenshot(it) }.toSet(),
features = listOf(), videoUrls = game.videosList.map { Mapper.video(it) }.toSet(),
perspectives = listOf() features = Mapper.gameFeatures(game),
perspectives = game.playerPerspectivesList.map { Mapper.playerPerspective(it) }.toSet()
) )
} }
@@ -1,8 +1,15 @@
package de.grimsi.gameyfin.plugins.igdb 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.Genre
import de.grimsi.gameyfin.pluginapi.gamemetadata.PlayerPerspective
import de.grimsi.gameyfin.pluginapi.gamemetadata.Theme import de.grimsi.gameyfin.pluginapi.gamemetadata.Theme
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.net.URI
import java.net.URL
class Mapper { class Mapper {
companion object { 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
}
} }
} }
@@ -112,14 +112,15 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
release = date(game["release_date"]?.jsonObject["date"]?.jsonPrimitive?.content!!), release = date(game["release_date"]?.jsonObject["date"]?.jsonPrimitive?.content!!),
userRating = 0, userRating = 0,
criticRating = 0, criticRating = 0,
developedBy = stringList(game, "developers"), developedBy = stringList(game, "developers").toSet(),
publishedBy = stringList(game, "publishers"), publishedBy = stringList(game, "publishers").toSet(),
genres = emptyList(), genres = emptySet(),
themes = emptyList(), themes = emptySet(),
screenshotUrls = emptyList(), keywords = emptySet(),
videoUrls = emptyList(), screenshotUrls = emptySet(),
features = emptyList(), videoUrls = emptySet(),
perspectives = emptyList() features = emptySet(),
perspectives = emptySet()
) )
return metadata return metadata