From 5e52d1183524e7e8fde2b7bd4efe3da1758f001a Mon Sep 17 00:00:00 2001 From: GRIMSIM Date: Mon, 16 Jun 2025 16:56:46 +0200 Subject: [PATCH] Extend Plugin API to return a list of covers and header images Implement dedicated header image in GameView Implement GameHeaderPicker --- app/package.json | 2 +- .../general/input/GameCoverPicker.tsx | 39 +++--- .../general/input/GameHeaderPicker.tsx | 50 +++++++ .../general/modals/EditGameMetadataModal.tsx | 18 +-- .../general/modals/GameCoverPickerModal.tsx | 49 ++++--- .../general/modals/GameHeaderPickerModal.tsx | 126 ++++++++++++++++++ .../general/modals/MatchGameModal.tsx | 12 +- .../components/general/plugin/PluginIcon.tsx | 34 +++-- .../components/general/plugin/PluginLogo.tsx | 12 +- .../plugin/PluginManagementSection.tsx | 15 ++- app/src/main/frontend/views/GameView.tsx | 20 ++- .../org/gameyfin/app/games/GameEndpoint.kt | 4 +- .../org/gameyfin/app/games/GameService.kt | 85 ++++++++---- .../org/gameyfin/app/games/dto/GameDto.kt | 1 + .../app/games/dto/GameSearchResultDto.kt | 10 +- .../gameyfin/app/games/dto/GameUpdateDto.kt | 1 + .../org/gameyfin/app/games/entities/Game.kt | 3 + .../org/gameyfin/app/games/entities/Image.kt | 1 + .../gameyfin/app/libraries/LibraryService.kt | 22 +-- .../org/gameyfin/app/media/ImageEndpoint.kt | 4 + build.gradle.kts | 3 +- .../pluginapi/gamemetadata/GameMetadata.kt | 6 +- .../src/main/resources/MANIFEST.MF | 2 +- .../plugins/metadata/igdb/IgdbPlugin.kt | 2 +- plugins/igdb/src/main/resources/MANIFEST.MF | 4 +- .../plugins/metadata/steam/SteamPlugin.kt | 10 +- plugins/steam/src/main/resources/MANIFEST.MF | 2 +- .../metadata/steamgriddb/SteamGridDbPlugin.kt | 48 ++++--- .../steamgriddb/api/SteamGridDbApiClient.kt | 7 + .../dto/SteamGridDbGridsDetails.kt | 2 - .../dto/SteamGridDbHeroesDetails.kt | 15 +++ .../src/main/resources/MANIFEST.MF | 2 +- .../src/main/resources/MANIFEST.MF | 2 +- 33 files changed, 454 insertions(+), 159 deletions(-) create mode 100644 app/src/main/frontend/components/general/input/GameHeaderPicker.tsx create mode 100644 app/src/main/frontend/components/general/modals/GameHeaderPickerModal.tsx create mode 100644 plugins/steamgriddb/src/main/kotlin/org/gameyfin/plugins/metadata/steamgriddb/dto/SteamGridDbHeroesDetails.kt diff --git a/app/package.json b/app/package.json index 9be10fc..eac60b4 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "gameyfin", - "version": "2.0.0.beta1", + "version": "2.0.0.beta2", "type": "module", "dependencies": { "@heroui/react": "2.7.9", diff --git a/app/src/main/frontend/components/general/input/GameCoverPicker.tsx b/app/src/main/frontend/components/general/input/GameCoverPicker.tsx index 7049322..cfefff6 100644 --- a/app/src/main/frontend/components/general/input/GameCoverPicker.tsx +++ b/app/src/main/frontend/components/general/input/GameCoverPicker.tsx @@ -1,13 +1,12 @@ import {Image, useDisclosure} from "@heroui/react"; -import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback"; import React from "react"; import {useField} from "formik"; import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal"; -import {Pencil} from "@phosphor-icons/react"; +import {ImageBroken, Pencil} from "@phosphor-icons/react"; // @ts-ignore -export default function GameCoverPicker({game, label, showErrorUntouched = false, ...props}) { +export default function GameCoverPicker({game, showErrorUntouched = false, ...props}) { // @ts-ignore const [field] = useField(props); @@ -15,24 +14,30 @@ export default function GameCoverPicker({game, label, showErrorUntouched = false const gameCoverPickerModal = useDisclosure(); return (<> -
- {game.title}} - /> + {field.value || game.coverId ? +
+ {game.title} +
: +
+ +

No cover image available

+
}
+

Edit cover

+
+ {field.value || game.headerId ? +
+ {game.title} +
: +
+ +

No header image available

+
} +
+ +

Edit header image

+
+
+ field.onChange({target: {name: field.name, value: headerUrl}})} + /> + ); +} \ No newline at end of file diff --git a/app/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx b/app/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx index b4886c7..b9eddd8 100644 --- a/app/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx +++ b/app/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx @@ -20,6 +20,7 @@ import * as Yup from "yup"; import GameCoverPicker from "Frontend/components/general/input/GameCoverPicker"; import DatePickerInput from "Frontend/components/general/input/DatePickerInput"; import ArrayInput from "Frontend/components/general/input/ArrayInput"; +import GameHeaderPicker from "Frontend/components/general/input/GameHeaderPicker"; interface EditGameMetadataModalProps { game: GameDto; @@ -57,15 +58,16 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit Update game metadata -
- {/*@ts-ignore*/} + +
-
- - - -
+ +
+
+ +
diff --git a/app/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx b/app/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx index cab5ff9..8e8a424 100644 --- a/app/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx +++ b/app/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx @@ -4,6 +4,10 @@ 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 GameCoverPickerModalProps { game: GameDto; @@ -17,7 +21,9 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}: const [searchTerm, setSearchTerm] = useState(game.title); const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false) + const [isSearching, setIsSearching] = useState(false); + + const state = useSnapshot(pluginState).state; useEffect(() => { if (isOpen && searchTerm.length > 0 && searchResults.length === 0) { @@ -27,8 +33,8 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}: async function search() { setIsSearching(true); - const results = await GameEndpoint.getPotentialMatches(searchTerm, false); - let validResults = results.filter(result => result.coverUrl && result.coverUrl.length > 0 && result.coverUrl !== "null"); + const results = await GameEndpoint.getPotentialMatches(searchTerm); + let validResults = results.filter(result => result.coverUrls && result.coverUrls.length > 0); setSearchResults(validResults); setIsSearching(false); } @@ -78,25 +84,36 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:

Searching...

} - {searchResults.map((result) => ( -
+ {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(); + }} + > {result.title}
- + 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(); + }} + > + {header.title} +
+ +

{header.title}

+ +
+
+ ))} +
+
+ ) + }} +
+
+ ); +} \ 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 screenshot : + {game.headerId ? ( + Game header + ) : game.imageIds && game.imageIds.length > 0 ? ( + Game screenshot + ) : (
- } + )}
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