+ {searchResults.flatMap(result => {
+ if (!result.coverUrls) return [];
+ return result.coverUrls.map((url, idx) => ({
+ id: `${result.id}-${idx}`,
+ title: result.title,
+ url: url.url,
+ source: url.pluginId
+ }))
+ }).map(cover => (
+
{
- setCoverUrl(result.coverUrl!);
- onClose();
- }}>
+ setCoverUrl(cover.url);
+ onOpenChange();
+ }}
+ >
-
+ className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100">
+
+ {cover.title}
+
))}
diff --git a/app/src/main/frontend/components/general/modals/GameHeaderPickerModal.tsx b/app/src/main/frontend/components/general/modals/GameHeaderPickerModal.tsx
new file mode 100644
index 0000000..9bd95b0
--- /dev/null
+++ b/app/src/main/frontend/components/general/modals/GameHeaderPickerModal.tsx
@@ -0,0 +1,126 @@
+import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
+import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, ScrollShadow} from "@heroui/react";
+import React, {useEffect, useState} from "react";
+import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
+import {GameEndpoint} from "Frontend/generated/endpoints";
+import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
+import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
+import {useSnapshot} from "valtio/react";
+import {pluginState} from "Frontend/state/PluginState";
+import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
+
+interface GameHeaderPickerModalProps {
+ game: GameDto;
+ isOpen: boolean;
+ onOpenChange: () => void;
+ setHeaderUrl: (url: string) => void;
+}
+
+export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}: GameHeaderPickerModalProps) {
+ const [headerUrl, setHeaderUrlState] = useState("");
+
+ const [searchTerm, setSearchTerm] = useState(game.title);
+ const [searchResults, setSearchResults] = useState
([]);
+ const [isSearching, setIsSearching] = useState(false);
+
+ const state = useSnapshot(pluginState).state;
+
+ useEffect(() => {
+ if (isOpen && searchTerm.length > 0 && searchResults.length === 0) {
+ search();
+ }
+ }, [isOpen]);
+
+ async function search() {
+ setIsSearching(true);
+ const results = await GameEndpoint.getPotentialMatches(searchTerm);
+ let validResults = results.filter(result => result.headerUrls && result.headerUrls.length > 0);
+ setSearchResults(validResults);
+ setIsSearching(false);
+ }
+
+ return (
+
+
+ {(onClose) => {
+ return (<>
+
+ Enter a URL or search for a header
+
+
+
+
setHeaderUrlState("")}
+ />
+
+
+
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ await search();
+ }
+ }}
+ />
+
+
+ {searchResults.length === 0 && !isSearching &&
+ No results found.
+ }
+ {searchResults.length === 0 && isSearching &&
+ Searching...
+ }
+
+ {searchResults.flatMap(result => {
+ if (!result.headerUrls) return [];
+ return result.headerUrls.map((url, idx) => ({
+ id: `${result.id}-${idx}`,
+ title: result.title,
+ url: url.url,
+ source: url.pluginId
+ }))
+ }).map(header => (
+ {
+ setHeaderUrl(header.url);
+ onOpenChange();
+ }}
+ >
+
+
+
+ ))}
+
+
+ >)
+ }}
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/src/main/frontend/components/general/modals/MatchGameModal.tsx b/app/src/main/frontend/components/general/modals/MatchGameModal.tsx
index bacc7c1..f484648 100644
--- a/app/src/main/frontend/components/general/modals/MatchGameModal.tsx
+++ b/app/src/main/frontend/components/general/modals/MatchGameModal.tsx
@@ -17,6 +17,9 @@ import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
import {GameEndpoint} from "Frontend/generated/endpoints";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import PluginIcon from "../plugin/PluginIcon";
+import {useSnapshot} from "valtio/react";
+import {pluginState} from "Frontend/state/PluginState";
+import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface EditGameMetadataModalProps {
path: string;
@@ -40,6 +43,8 @@ export default function MatchGameModal({
const [isSearching, setIsSearching] = useState(false);
const [isMatching, setIsMatching] = useState(null);
+ const state = useSnapshot(pluginState).state;
+
useEffect(() => {
setSearchTerm(initialSearchTerm);
setSearchResults([]);
@@ -51,7 +56,7 @@ export default function MatchGameModal({
async function search() {
setIsSearching(true);
- const results = await GameEndpoint.getPotentialMatches(searchTerm, true);
+ const results = await GameEndpoint.getPotentialMatches(searchTerm);
setSearchResults(results);
setIsSearching(false);
}
@@ -86,7 +91,7 @@ export default function MatchGameModal({
@@ -120,7 +125,8 @@ export default function MatchGameModal({
{Object.values(item.originalIds).map(
- originalId =>
+ originalId =>
)}
diff --git a/app/src/main/frontend/components/general/plugin/PluginIcon.tsx b/app/src/main/frontend/components/general/plugin/PluginIcon.tsx
index ec76dd2..83999bc 100644
--- a/app/src/main/frontend/components/general/plugin/PluginIcon.tsx
+++ b/app/src/main/frontend/components/general/plugin/PluginIcon.tsx
@@ -1,21 +1,27 @@
import {Image, Tooltip} from "@heroui/react";
import {Plug} from "@phosphor-icons/react";
-import {pluginState} from "Frontend/state/PluginState";
-import {useSnapshot} from "valtio/react";
+import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
-interface PluginLogoProps {
- pluginId: string;
+interface PluginIconProps {
+ plugin: PluginDto;
+ size?: number;
+ blurred?: boolean;
+ showTooltip?: boolean;
}
-export default function PluginIcon({pluginId}: PluginLogoProps) {
- const state = useSnapshot(pluginState);
+export default function PluginIcon({
+ plugin,
+ size = 16,
+ blurred = false,
+ showTooltip = true
+ }: PluginIconProps) {
- return state.isLoaded && (
-
- {state.state[pluginId].hasLogo ?
- :
-
- }
-
- )
+ const icon = plugin.hasLogo
+ ?
+
+ : ;
+
+ return showTooltip
+ ? {icon}
+ : icon;
}
\ No newline at end of file
diff --git a/app/src/main/frontend/components/general/plugin/PluginLogo.tsx b/app/src/main/frontend/components/general/plugin/PluginLogo.tsx
index f4c2a95..898cc9b 100644
--- a/app/src/main/frontend/components/general/plugin/PluginLogo.tsx
+++ b/app/src/main/frontend/components/general/plugin/PluginLogo.tsx
@@ -1,6 +1,5 @@
-import {Plug} from "@phosphor-icons/react";
import React from "react";
-import {Image} from "@heroui/react";
+import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface PluginLogoProps {
@@ -8,12 +7,5 @@ interface PluginLogoProps {
}
export default function PluginLogo({plugin}: PluginLogoProps) {
- return (
- <>
- {plugin.hasLogo ?
- :
-
- }
- >
- );
+ return
}
\ No newline at end of file
diff --git a/app/src/main/frontend/components/general/plugin/PluginManagementSection.tsx b/app/src/main/frontend/components/general/plugin/PluginManagementSection.tsx
index 62ca5e8..37adfbe 100644
--- a/app/src/main/frontend/components/general/plugin/PluginManagementSection.tsx
+++ b/app/src/main/frontend/components/general/plugin/PluginManagementSection.tsx
@@ -11,7 +11,7 @@ interface PluginManagementSectionProps {
plugins: PluginDto[];
}
-export function PluginManagementSection({type, plugins}: PluginManagementSectionProps) {
+export function PluginManagementSection({type, plugins = []}: PluginManagementSectionProps) {
const pluginPrioritiesModal = useDisclosure();
return (
@@ -20,17 +20,24 @@ export function PluginManagementSection({type, plugins}: PluginManagementSection
{camelCaseToTitle(type)}
-
-
+ {plugins.length === 0 &&
+
No plugins of this type installed.
+
}
+
+ {plugins.length > 0 &&
{plugins.map((plugin) =>
)}
-
+
}
p.id + p.priority).join(',')} // force re-mount if plugin order changes
diff --git a/app/src/main/frontend/views/GameView.tsx b/app/src/main/frontend/views/GameView.tsx
index d866847..0f5c840 100644
--- a/app/src/main/frontend/views/GameView.tsx
+++ b/app/src/main/frontend/views/GameView.tsx
@@ -79,13 +79,21 @@ export default function GameView() {
return game && (
- {(game.imageIds && game.imageIds.length > 0) ?
-

:
+ {game.headerId ? (
+

+ ) : game.imageIds && game.imageIds.length > 0 ? (
+

+ ) : (
- }
+ )}
diff --git a/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt
index 222ceea..8901878 100644
--- a/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt
+++ b/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt
@@ -35,8 +35,8 @@ class GameEndpoint(
}
@RolesAllowed(Role.Names.ADMIN)
- fun getPotentialMatches(searchTerm: String, groupResults: Boolean): List
{
- return gameService.getPotentialMatches(searchTerm, groupResults)
+ fun getPotentialMatches(searchTerm: String): List {
+ return gameService.getPotentialMatches(searchTerm)
}
@RolesAllowed(Role.Names.ADMIN)
diff --git a/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt b/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt
index a1717ff..c503f36 100644
--- a/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt
+++ b/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt
@@ -17,6 +17,7 @@ import org.gameyfin.app.core.plugins.management.PluginManagementEntry
import org.gameyfin.app.core.replaceRomanNumerals
import org.gameyfin.app.games.dto.*
import org.gameyfin.app.games.entities.*
+import org.gameyfin.app.games.entities.GameMetadata
import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.libraries.Library
import org.gameyfin.app.media.ImageService
@@ -90,6 +91,10 @@ class GameService(
imageService.downloadIfNew(it)
}
+ game.headerImage?.let {
+ imageService.downloadIfNew(it)
+ }
+
game.images.map {
imageService.downloadIfNew(it)
}
@@ -139,6 +144,13 @@ class GameService(
existingGame.coverImage = newCoverImage
existingGame.metadata.fields["coverImage"]?.source = GameFieldUserSource(user = user)
}
+ gameUpdateDto.headerUrl?.let {
+ val newHeaderImage = Image(originalUrl = URI.create(it).toURL(), type = ImageType.HEADER)
+ imageService.downloadIfNew(newHeaderImage)
+
+ existingGame.headerImage = newHeaderImage
+ existingGame.metadata.fields["headerImage"]?.source = GameFieldUserSource(user = user)
+ }
gameUpdateDto.comment?.let {
existingGame.comment = it
existingGame.metadata.fields["comment"]?.source = GameFieldUserSource(user = user)
@@ -218,7 +230,7 @@ class GameService(
gameRepository.deleteById(gameId)
}
- fun getPotentialMatches(searchTerm: String, groupResults: Boolean = true): List {
+ fun getPotentialMatches(searchTerm: String): List {
// 1. Query all plugins for up to 10 results each
val results = metadataPlugins.flatMap { plugin ->
try {
@@ -232,31 +244,13 @@ class GameService(
val providerToManagementEntry =
results.toMap().entries.associate { it.key to pluginService.getPluginManagementEntry(it.key.javaClass) }
- if (!groupResults) {
- // If grouping is not requested, return the results directly
- return results.mapNotNull { (plugin, metadata) ->
- GameSearchResultDto(
- title = metadata.title.normalizeGameTitle(),
- coverUrl = metadata.coverUrl.toString(),
- release = metadata.release,
- publishers = metadata.publishedBy?.toList(),
- developers = metadata.developedBy?.toList(),
- originalIds = mapOf(
- plugin.javaClass.name to OriginalIdDto(
- providerToManagementEntry[plugin]?.pluginId ?: return@mapNotNull null, metadata.originalId
- )
- )
- )
- }.sortedByDescending { FuzzySearch.ratio(searchTerm, it.title) }
- }
-
// 2. Group by title and release year (if available)
// (NOTE: This _could_ lead to problems if multiple games have the (almost) same title - see Battlefront 2)
data class GroupKey(val title: String, val year: Int?)
fun PluginApiMetadata.groupKey(): GroupKey =
GroupKey(
- title = this.title.trim().lowercase(),
+ title = this.title.normalizeGameTitle(),
year = this.release?.atZone(ZoneId.systemDefault())?.year
)
@@ -283,9 +277,25 @@ class GameService(
}
.toMap()
+ // Merge and deduplicate coverUrls and headerUrls
+ val coverUrls = group.flatMap {
+ it.second.coverUrls?.mapNotNull { url ->
+ val pluginId = providerToManagementEntry[it.first]?.pluginId ?: return@mapNotNull null
+ UrlWithSourceDto(url = url.toString(), pluginId = pluginId)
+ } ?: emptyList()
+ }.distinct()
+
+ val headerUrls = group.flatMap {
+ it.second.headerUrls?.mapNotNull { url ->
+ val pluginId = providerToManagementEntry[it.first]?.pluginId ?: return@mapNotNull null
+ UrlWithSourceDto(url = url.toString(), pluginId = pluginId)
+ } ?: emptyList()
+ }.distinct()
+
return GameSearchResultDto(
title = pick { it.title }!!,
- coverUrl = pick { it.coverUrl.toString() },
+ coverUrls = coverUrls.ifEmpty { null },
+ headerUrls = headerUrls.ifEmpty { null },
release = pick { it.release },
publishers = pickList { it.publishedBy?.toList() },
developers = pickList { it.developedBy?.toList() },
@@ -293,16 +303,31 @@ class GameService(
)
}
- // 4. Sort & return merged results
+ // 4. Merge the results
val mergedResults = grouped.values.map { mergeGroup(it) }
- return mergedResults
+ // 5. Sort the results by fuzzy match ratio and then by release year (newer first)
+ val sortedResults = mergedResults
.map { result ->
- val ratio = FuzzySearch.ratio(searchTerm, result.title)
+ val ratio = FuzzySearch.ratio(searchTerm.normalizeGameTitle(), result.title.normalizeGameTitle())
result to ratio
}
- .sortedByDescending { it.second }
+ .sortedWith(
+ compareByDescending> { it.second }
+ .thenComparator { a, b ->
+ val yearA = a.first.release?.atZone(ZoneId.systemDefault())?.year
+ val yearB = b.first.release?.atZone(ZoneId.systemDefault())?.year
+ when {
+ yearA == yearB -> 0
+ yearA == null -> 1 // nulls last
+ yearB == null -> -1
+ else -> yearB.compareTo(yearA) // newer first
+ }
+ }
+ )
.map { it.first }
+
+ return sortedResults
}
fun matchManually(
@@ -474,13 +499,20 @@ class GameService(
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
- metadata.coverUrl?.let { coverUrl ->
+ metadata.coverUrls?.firstOrNull()?.let { coverUrl ->
if (!metadataMap.containsKey("coverImage")) {
mergedGame.coverImage = Image(originalUrl = coverUrl.toURL(), type = ImageType.COVER)
metadataMap["coverImage"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
+ metadata.headerUrls?.firstOrNull()?.let { headerUrl ->
+ if (!metadataMap.containsKey("headerImage")) {
+ mergedGame.headerImage = Image(originalUrl = headerUrl.toURL(), type = ImageType.HEADER)
+ metadataMap["headerImage"] =
+ GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
+ }
+ }
metadata.release?.let { release ->
if (!metadataMap.containsKey("release")) {
mergedGame.release = release
@@ -634,6 +666,7 @@ fun Game.toDto(): GameDto {
libraryId = this.library.id!!,
title = title!!,
coverId = this.coverImage?.id,
+ headerId = this.headerImage?.id,
comment = this.comment,
summary = this.summary,
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
diff --git a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameDto.kt b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameDto.kt
index e97ae3b..70fab71 100644
--- a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameDto.kt
+++ b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameDto.kt
@@ -12,6 +12,7 @@ class GameDto(
val libraryId: Long,
val title: String,
val coverId: Long?,
+ val headerId: Long?,
val comment: String?,
val summary: String?,
val release: LocalDate?,
diff --git a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameSearchResultDto.kt b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameSearchResultDto.kt
index f98be5d..45f98c5 100644
--- a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameSearchResultDto.kt
+++ b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameSearchResultDto.kt
@@ -6,7 +6,8 @@ import java.util.*
class GameSearchResultDto(
val id: UUID = UUID.randomUUID(),
val title: String,
- val coverUrl: String?,
+ val coverUrls: List?,
+ val headerUrls: List?,
val release: Instant?,
val publishers: Collection?,
val developers: Collection?,
@@ -20,4 +21,9 @@ class OriginalIdDto(
override fun toString(): String {
return "$pluginId:$originalId"
}
-}
\ No newline at end of file
+}
+
+class UrlWithSourceDto(
+ val url: String,
+ val pluginId: String
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameUpdateDto.kt b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameUpdateDto.kt
index 5d5b0a9..064ece8 100644
--- a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameUpdateDto.kt
+++ b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameUpdateDto.kt
@@ -7,6 +7,7 @@ data class GameUpdateDto(
val title: String?,
val release: LocalDate?,
val coverUrl: String?,
+ val headerUrl: String?,
val comment: String?,
val summary: String?,
val developers: List?,
diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt
index 8c0959e..3706600 100644
--- a/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt
+++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt
@@ -36,6 +36,9 @@ class Game(
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
var coverImage: Image? = null,
+ @OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
+ var headerImage: Image? = null,
+
@Lob
@Column(columnDefinition = "CLOB")
var comment: String? = null,
diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt
index 66d2ce6..436d189 100644
--- a/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt
+++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt
@@ -31,6 +31,7 @@ class Image(
enum class ImageType {
COVER,
+ HEADER,
SCREENSHOT,
AVATAR
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt
index bd6e2b1..0b2f231 100644
--- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt
+++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt
@@ -1,17 +1,10 @@
package org.gameyfin.app.libraries
-import org.gameyfin.app.games.entities.Game
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.core.filesystem.FilesystemService
import org.gameyfin.app.games.GameService
-import org.gameyfin.app.libraries.dto.DirectoryMappingDto
-import org.gameyfin.app.libraries.dto.LibraryDto
-import org.gameyfin.app.libraries.dto.LibraryEvent
-import org.gameyfin.app.libraries.dto.LibraryScanProgress
-import org.gameyfin.app.libraries.dto.LibraryScanStatus
-import org.gameyfin.app.libraries.dto.LibraryScanStep
-import org.gameyfin.app.libraries.dto.LibraryStatsDto
-import org.gameyfin.app.libraries.dto.LibraryUpdateDto
+import org.gameyfin.app.games.entities.Game
+import org.gameyfin.app.libraries.dto.*
import org.gameyfin.app.libraries.enums.ScanType
import org.gameyfin.app.media.ImageService
import org.springframework.data.repository.findByIdOrNull
@@ -251,7 +244,9 @@ class LibraryService(
library.games.removeAll(removedGames)
// 2. Download all images
- val totalImages = matchedGames.count { it.coverImage != null } + matchedGames.sumOf { it.images.size }
+ val totalImages = matchedGames.count { it.coverImage != null } +
+ matchedGames.count { it.headerImage !== null } +
+ matchedGames.sumOf { it.images.size }
progress.currentStep = LibraryScanStep(
description = "Downloading images",
@@ -268,6 +263,11 @@ class LibraryService(
completedImageDownload.andIncrement
}
+ game.headerImage?.let {
+ imageService.downloadIfNew(it)
+ completedImageDownload.andIncrement
+ }
+
game.images.map {
imageService.downloadIfNew(it)
completedImageDownload.andIncrement
@@ -378,7 +378,7 @@ class LibraryService(
private fun toEntity(library: LibraryDto): Library {
return libraryRepository.findByIdOrNull(library.id) ?: Library(
name = library.name,
- directories = library.directories.map {
+ directories = library.directories.distinctBy { it.internalPath }.map {
DirectoryMapping(internalPath = it.internalPath, externalPath = it.externalPath)
}.toMutableList(),
)
diff --git a/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt
index 8dfa669..020cbb8 100644
--- a/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt
+++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt
@@ -36,6 +36,10 @@ class ImageEndpoint(
fun getCover(@PathVariable("id") id: Long): ResponseEntity? {
return getImageContent(id)
}
+ @GetMapping("/header/{id}")
+ fun getHeader(@PathVariable("id") id: Long): ResponseEntity? {
+ return getImageContent(id)
+ }
@GetMapping("/plugins/{id}/logo")
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity? {
diff --git a/build.gradle.kts b/build.gradle.kts
index f582a65..2c125d2 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,13 +1,12 @@
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
-import org.gradle.internal.impldep.com.fasterxml.jackson.core.JsonGenerator
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
import java.nio.file.Files
group = "org.gameyfin"
-version = "2.0.0.beta1"
+version = "2.0.0.beta2"
allprojects {
repositories {
diff --git a/plugin-api/src/main/kotlin/org/gameyfin/pluginapi/gamemetadata/GameMetadata.kt b/plugin-api/src/main/kotlin/org/gameyfin/pluginapi/gamemetadata/GameMetadata.kt
index 4fb23ff..e63275b 100644
--- a/plugin-api/src/main/kotlin/org/gameyfin/pluginapi/gamemetadata/GameMetadata.kt
+++ b/plugin-api/src/main/kotlin/org/gameyfin/pluginapi/gamemetadata/GameMetadata.kt
@@ -9,7 +9,8 @@ import java.time.Instant
* @property originalId The unique identifier for the game from the original source.
* @property title The title of the game.
* @property description A description of the game, or null if not available.
- * @property coverUrl The URI to the game's cover image, or null if not available.
+ * @property coverUrls List of URIs to the game's cover images, or null if not available.
+ * @property headerUrls List of URIs to the game's header images, or null if not available.
* @property release The release date and time of the game, or null if not available.
* @property userRating The user rating for the game, or null if not available.
* @property criticRating The critic rating for the game, or null if not available.
@@ -27,7 +28,8 @@ data class GameMetadata(
val originalId: String,
val title: String,
val description: String? = null,
- val coverUrl: URI? = null,
+ val coverUrls: List? = null,
+ val headerUrls: List? = null,
val release: Instant? = null,
val userRating: Int? = null,
val criticRating: Int? = null,
diff --git a/plugins/directdownload/src/main/resources/MANIFEST.MF b/plugins/directdownload/src/main/resources/MANIFEST.MF
index 38d93bb..8192424 100644
--- a/plugins/directdownload/src/main/resources/MANIFEST.MF
+++ b/plugins/directdownload/src/main/resources/MANIFEST.MF
@@ -1,4 +1,4 @@
-Plugin-Version: 1.0.0-beta1
+Plugin-Version: 1.0.0.beta1
Plugin-Class: org.gameyfin.plugins.download.direct.DirectDownloadPlugin
Plugin-Id: org.gameyfin.plugins.download.direct
Plugin-Name: Direct Download
diff --git a/plugins/igdb/src/main/kotlin/org/gameyfin/plugins/metadata/igdb/IgdbPlugin.kt b/plugins/igdb/src/main/kotlin/org/gameyfin/plugins/metadata/igdb/IgdbPlugin.kt
index 4ae8ed0..33f6fa3 100644
--- a/plugins/igdb/src/main/kotlin/org/gameyfin/plugins/metadata/igdb/IgdbPlugin.kt
+++ b/plugins/igdb/src/main/kotlin/org/gameyfin/plugins/metadata/igdb/IgdbPlugin.kt
@@ -190,7 +190,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
originalId = game.slug,
title = game.name,
description = game.summary,
- coverUrl = Mapper.cover(game.cover),
+ coverUrls = Mapper.cover(game.cover)?.let { listOf(it) },
release = if (game.firstReleaseDate.seconds > 0) Instant.ofEpochSecond(game.firstReleaseDate.seconds) else null,
userRating = game.rating.toInt(),
criticRating = game.aggregatedRating.toInt(),
diff --git a/plugins/igdb/src/main/resources/MANIFEST.MF b/plugins/igdb/src/main/resources/MANIFEST.MF
index 3226027..68c4c5c 100644
--- a/plugins/igdb/src/main/resources/MANIFEST.MF
+++ b/plugins/igdb/src/main/resources/MANIFEST.MF
@@ -1,6 +1,6 @@
-Plugin-Version: 1.0.0-beta1
+Plugin-Version: 1.0.0.beta2
Plugin-Class: org.gameyfin.plugins.metadata.igdb.IgdbPlugin
-Plugin-Id: org.gameyfin.plugins.metadata.igdb.
+Plugin-Id: org.gameyfin.plugins.metadata.igdb
Plugin-Name: IGDB Metadata
Plugin-Description: Fetches metadata from IGDB.
Requires a Twitch account and IGDB API credentials.
diff --git a/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/SteamPlugin.kt b/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/SteamPlugin.kt
index 6730bf7..0d3a759 100644
--- a/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/SteamPlugin.kt
+++ b/plugins/steam/src/main/kotlin/org/gameyfin/plugins/metadata/steam/SteamPlugin.kt
@@ -115,14 +115,14 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
originalId = id.toString(),
title = sanitizeTitle(game.name),
description = game.detailedDescription,
- coverUrl = game.headerImage?.let { URI(it) },
+ coverUrls = game.headerImage?.let { URI(it) }?.let { listOf(it) },
release = game.releaseDate?.date,
developedBy = game.developers?.toSet(),
publishedBy = game.publishers?.toSet(),
- genres = game.genres?.let { it.map { Mapper.genre(it) }.toSet() },
- keywords = game.categories?.let { it.mapNotNull { it.description }.toSet() },
- screenshotUrls = game.screenshots?.let { it.map { URI(it.pathFull) }.toSet() },
- videoUrls = game.movies?.let { it.mapNotNull { it.webm?.let { URI(it.max) } }.toSet() }
+ genres = game.genres?.let { genre -> genre.map { Mapper.genre(it) }.toSet() },
+ keywords = game.categories?.mapNotNull { it.description }?.toSet(),
+ screenshotUrls = game.screenshots?.map { URI(it.pathFull) }?.toSet(),
+ videoUrls = game.movies?.mapNotNull { video -> video.webm?.let { URI(it.max) } }?.toSet()
)
return metadata
diff --git a/plugins/steam/src/main/resources/MANIFEST.MF b/plugins/steam/src/main/resources/MANIFEST.MF
index 8091366..ea9f0bf 100644
--- a/plugins/steam/src/main/resources/MANIFEST.MF
+++ b/plugins/steam/src/main/resources/MANIFEST.MF
@@ -1,4 +1,4 @@
-Plugin-Version: 1.0.0-beta1
+Plugin-Version: 1.0.0.beta2
Plugin-Class: org.gameyfin.plugins.metadata.steam.SteamPlugin
Plugin-Id: org.gameyfin.plugins.metadata.steam
Plugin-Name: Steam Metadata
diff --git a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/SteamGridDbPlugin.kt b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/SteamGridDbPlugin.kt
index 64f31ce..c36fb97 100644
--- a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/SteamGridDbPlugin.kt
+++ b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/SteamGridDbPlugin.kt
@@ -8,6 +8,7 @@ import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import org.gameyfin.plugins.metadata.steamgriddb.api.SteamGridDbApiClient
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGame
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGrid
+import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbHero
import org.pf4j.Extension
import org.pf4j.PluginWrapper
import java.net.URI
@@ -74,25 +75,18 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
override fun fetchByTitle(gameTitle: String, maxResults: Int): List {
return runBlocking {
- val covers = mutableListOf()
- val games = searchSteamGridDb(gameTitle)
+ val results = searchSteamGridDb(gameTitle)
- for (game in games) {
- val gameDetails = client?.grids(game.id)
- val grids = gameDetails?.data.orEmpty()
- for (grid in grids) {
- covers.add(
- GameMetadata(
- originalId = game.id.toString(),
- title = game.name,
- coverUrl = URI(grid.url)
- )
- )
- if (covers.size >= maxResults) break
- }
- if (covers.size >= maxResults) break
- }
- covers
+ results.map { game ->
+ val grids = getGridsForGame(game.id)
+ val heroes = getHeroesForGame(game.id)
+ GameMetadata(
+ originalId = game.id.toString(),
+ title = game.name,
+ coverUrls = grids?.map { URI(it.url) },
+ headerUrls = heroes?.map { URI(it.url) }
+ )
+ }.take(maxResults)
}
}
@@ -101,10 +95,14 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
val gameId = id.toIntOrNull() ?: return@runBlocking null
val game = getGameById(gameId) ?: return@runBlocking null
+ val grids = getGridsForGame(game.id)
+ val heroes = getHeroesForGame(game.id)
+
return@runBlocking GameMetadata(
originalId = game.id.toString(),
title = game.name,
- coverUrl = getGridForGame(game.id)?.let { grid -> URI(grid.url) }
+ coverUrls = grids?.map { URI(it.url) },
+ headerUrls = heroes?.map { URI(it.url) }
)
}
}
@@ -121,12 +119,20 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
}
}
- private suspend fun getGridForGame(gameId: Int): SteamGridDbGrid? {
+ private suspend fun getGridsForGame(gameId: Int): List? {
val client = client ?: throw PluginConfigError("SteamGridDB API client not initialized")
val gameDetails = client.grids(gameId)
- return gameDetails.data?.firstOrNull()
+ return gameDetails.data
+ }
+
+ private suspend fun getHeroesForGame(gameId: Int): List? {
+ val client = client ?: throw PluginConfigError("SteamGridDB API client not initialized")
+
+ val gameDetails = client.heroes(gameId)
+
+ return gameDetails.data
}
private suspend fun getGameById(gameId: Int): SteamGridDbGame? {
diff --git a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/api/SteamGridDbApiClient.kt b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/api/SteamGridDbApiClient.kt
index 3ad285e..b877852 100644
--- a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/api/SteamGridDbApiClient.kt
+++ b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/api/SteamGridDbApiClient.kt
@@ -11,6 +11,7 @@ import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGameResult
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGridResult
+import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbHeroResult
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbSearchResult
@@ -52,6 +53,12 @@ class SteamGridDbApiClient(private val apiKey: String) {
}.body()
}
+ suspend fun heroes(gameId: Int, block: HttpRequestBuilder.() -> Unit = {}): SteamGridDbHeroResult {
+ return get("heroes/game/$gameId") {
+ block()
+ }.body()
+ }
+
suspend fun game(gameId: Int, block: HttpRequestBuilder.() -> Unit = {}): SteamGridDbGameResult {
return get("games/id/$gameId", block).body()
}
diff --git a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbGridsDetails.kt b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbGridsDetails.kt
index ab8442c..d4415a6 100644
--- a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbGridsDetails.kt
+++ b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbGridsDetails.kt
@@ -11,7 +11,5 @@ data class SteamGridDbGridResult(
@Serializable
data class SteamGridDbGrid(
val id: Int,
- val width: Int,
- val height: Int,
val url: String
)
\ No newline at end of file
diff --git a/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbHeroesDetails.kt b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbHeroesDetails.kt
new file mode 100644
index 0000000..f3667e5
--- /dev/null
+++ b/plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbHeroesDetails.kt
@@ -0,0 +1,15 @@
+package org.gameyfin.plugins.metadata.steamgriddb.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SteamGridDbHeroResult(
+ val success: Boolean,
+ val data: List?
+)
+
+@Serializable
+data class SteamGridDbHero(
+ val id: Int,
+ val url: String
+)
\ No newline at end of file
diff --git a/plugins/steamgriddb/src/main/resources/MANIFEST.MF b/plugins/steamgriddb/src/main/resources/MANIFEST.MF
index 9e8a5f6..1817a37 100644
--- a/plugins/steamgriddb/src/main/resources/MANIFEST.MF
+++ b/plugins/steamgriddb/src/main/resources/MANIFEST.MF
@@ -1,4 +1,4 @@
-Plugin-Version: 1.0.0-beta1
+Plugin-Version: 1.0.0.beta2
Plugin-Class: org.gameyfin.plugins.metadata.steamgriddb.SteamGridDbPlugin
Plugin-Id: org.gameyfin.plugins.metadata.steamgriddb
Plugin-Name: SteamGridDB Covers
diff --git a/plugins/torrentdownload/src/main/resources/MANIFEST.MF b/plugins/torrentdownload/src/main/resources/MANIFEST.MF
index 30c5c1e..f0e9015 100644
--- a/plugins/torrentdownload/src/main/resources/MANIFEST.MF
+++ b/plugins/torrentdownload/src/main/resources/MANIFEST.MF
@@ -1,4 +1,4 @@
-Plugin-Version: 1.0.0-beta1
+Plugin-Version: 1.0.0.beta1
Plugin-Class: org.gameyfin.plugins.download.torrent.TorrentDownloadPlugin
Plugin-Id: org.gameyfin.plugins.download.torrent
Plugin-Name: Torrent Download