Extend Plugin API to return a list of covers and header images

Implement dedicated header image in GameView
Implement GameHeaderPicker
This commit is contained in:
GRIMSIM
2025-06-16 16:56:46 +02:00
parent ac4eaf915e
commit 5e52d11835
33 changed files with 454 additions and 159 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gameyfin",
"version": "2.0.0.beta1",
"version": "2.0.0.beta2",
"type": "module",
"dependencies": {
"@heroui/react": "2.7.9",
@@ -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 (<>
<div className="relative group w-fit h-fit cursor-pointer"
<div className="relative group aspect-[12/17] cursor-pointer bg-background/50"
onClick={gameCoverPickerModal.onOpenChange}>
<Image
alt={game.title}
className="z-0 object-cover aspect-[12/17] group-hover:brightness-50"
src={field.value ? field.value : `images/cover/${game.coverId}`}
{...props}
{...field}
radius="none"
height={216}
fallbackSrc={<GameCoverFallback title={game.title}
size={216}
radius="none"/>}
/>
{field.value || game.coverId ?
<div className="size-full overflow-hidden">
<Image
alt={game.title}
className="z-0 object-cover group-hover:brightness-[25%]"
src={field.value ? field.value : `images/cover/${game.coverId}`}
{...props}
{...field}
radius="none"
/>
</div> :
<div
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
>
<ImageBroken size={46}/>
<p>No cover image available</p>
</div>}
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
>
<Pencil size={46}/>
<p>Edit cover</p>
</div>
</div>
<GameCoverPickerModal
@@ -0,0 +1,50 @@
import {Image, useDisclosure} from "@heroui/react";
import React from "react";
import {useField} from "formik";
import {ImageBroken, Pencil} from "@phosphor-icons/react";
import {GameHeaderPickerModal} from "Frontend/components/general/modals/GameHeaderPickerModal";
// @ts-ignore
export default function GameHeaderPicker({game, showErrorUntouched = false, ...props}) {
// @ts-ignore
const [field] = useField(props);
const gameHeaderPickerModal = useDisclosure();
return (<>
<div className="relative group size-full cursor-pointer bg-background/50"
onClick={gameHeaderPickerModal.onOpenChange}>
{field.value || game.headerId ?
<div className="size-full overflow-hidden">
<Image
alt={game.title}
className="z-0 object-cover group-hover:brightness-[25%]"
src={field.value ? field.value : `images/cover/${game.headerId}`}
{...props}
{...field}
radius="none"
/>
</div> :
<div
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
>
<ImageBroken size={46}/>
<p>No header image available</p>
</div>}
<div
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
>
<Pencil size={46}/>
<p>Edit header image</p>
</div>
</div>
<GameHeaderPickerModal
game={game}
isOpen={gameHeaderPickerModal.isOpen}
onOpenChange={gameHeaderPickerModal.onOpenChange}
setHeaderUrl={(headerUrl) => field.onChange({target: {name: field.name, value: headerUrl}})}
/>
</>);
}
@@ -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
</ModalHeader>
<ModalBody>
<div className="flex flex-row gap-8">
{/*@ts-ignore*/}
<Input key="metadata.path" name="metadata.path" label="Path"
isDisabled className="mb-0"/>
<div className="flex flex-row gap-4 h-44">
<GameCoverPicker key="coverUrl" name="coverUrl" game={game}/>
<div className="flex flex-col flex-1">
<Input key="metadata.path" name="metadata.path" label="Path"
isDisabled/>
<Input key="title" name="title" label="Title" isRequired/>
<DatePickerInput key="release" name="release" label="Release"/>
</div>
<GameHeaderPicker key="headerUrl" name="headerUrl" game={game}/>
</div>
<div className="flex flex-row gap-4">
<Input key="title" name="title" label="Title" isRequired/>
<DatePickerInput key="release" name="release" label="Release"
className="w-fit"/>
</div>
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
@@ -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<GameSearchResultDto[]>([]);
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}:
<p className="text-center text-foreground/70">Searching...</p>
}
<ScrollShadow
className="grid grid-cols-auto-fill gap-4 h-96 overflow-scroll justify-evenly">
{searchResults.map((result) => (
<div className="relative group w-fit h-fit cursor-pointer"
className="grid grid-cols-auto-fill gap-4 h-96 overflow-y-scroll justify-evenly">
{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 => (
<div key={cover.id}
className="relative group w-fit h-fit cursor-pointer"
onClick={() => {
setCoverUrl(result.coverUrl!);
onClose();
}}>
setCoverUrl(cover.url);
onOpenChange();
}}
>
<Image
key={result.id}
alt={result.title}
className="z-0 object-cover aspect-[12/17] group-hover:brightness-50"
src={result.coverUrl!}
alt={cover.title}
className="z-0 object-cover aspect-[12/17] group-hover:brightness-[25%]"
src={cover.url}
radius="none"
height={216}
/>
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
>
<ArrowRight size={46}/>
className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100">
<PluginIcon plugin={state[cover.source] as PluginDto} size={32}
blurred={false} showTooltip={false}/>
<p className="text-s text-center">{cover.title}</p>
<ArrowRight/>
</div>
</div>
))}
@@ -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<GameSearchResultDto[]>([]);
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 (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="2xl">
<ModalContent>
{(onClose) => {
return (<>
<ModalHeader>
Enter a URL or search for a header
</ModalHeader>
<ModalBody className="flex flex-col gap-4">
<div className="flex flex-row gap-2 mb-4">
<Input isClearable
placeholder="Enter a URL"
value={headerUrl}
onValueChange={setHeaderUrlState}
onClear={() => setHeaderUrlState("")}
/>
<Button isIconOnly onPress={() => {
setHeaderUrl(headerUrl);
onClose();
}}>
<ArrowRight/>
</Button>
</div>
<div className="flex flex-row gap-2 mb-4">
<Input placeholder="Search"
value={searchTerm}
onValueChange={setSearchTerm}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await search();
}
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
</Button>
</div>
{searchResults.length === 0 && !isSearching &&
<p className="text-center">No results found.</p>
}
{searchResults.length === 0 && isSearching &&
<p className="text-center text-foreground/70">Searching...</p>
}
<ScrollShadow
className="flex flex-col items-center gap-4 h-96 overflow-y-scroll">
{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 => (
<div key={header.id}
className="relative group w-fit h-fit cursor-pointer"
onClick={() => {
setHeaderUrl(header.url);
onOpenChange();
}}
>
<Image
alt={header.title}
className="z-0 object-cover group-hover:brightness-[25%]"
src={header.url}
radius="none"
/>
<div
className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100">
<PluginIcon plugin={state[header.source] as PluginDto} size={32}
blurred={false} showTooltip={false}/>
<p className="text-s text-center">{header.title}</p>
<ArrowRight/>
</div>
</div>
))}
</ScrollShadow>
</ModalBody>
</>)
}}
</ModalContent>
</Modal>
);
}
@@ -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<string | null>(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({
<div>
<Table removeWrapper isStriped isHeaderSticky
classNames={{
base: "h-80 overflow-scroll",
base: "h-80 overflow-y-auto",
}}
>
<TableHeader>
@@ -120,7 +125,8 @@ export default function MatchGameModal({
<TableCell>
<div className="flex flex-row gap-2">
{Object.values(item.originalIds).map(
originalId => <PluginIcon pluginId={originalId.pluginId}/>
originalId => <PluginIcon
plugin={state[originalId.pluginId] as PluginDto}/>
)}
</div>
</TableCell>
@@ -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 && (
<Tooltip content={state.state[pluginId].name}>
{state.state[pluginId].hasLogo ?
<Image src={`/images/plugins/${state.state[pluginId].id}/logo`} width={16} height={16} radius="none"/> :
<Plug size={16} weight="fill"/>
}
</Tooltip>
)
const icon = plugin.hasLogo
?
<Image isBlurred={blurred} src={`/images/plugins/${plugin.id}/logo`} width={size} height={size} radius="none"/>
: <Plug size={size} weight="fill"/>;
return showTooltip
? <Tooltip content={plugin.name}>{icon}</Tooltip>
: icon;
}
@@ -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 ?
<Image isBlurred src={`/images/plugins/${plugin.id}/logo`} width={64} height={64} radius="none"/> :
<Plug size={64} weight="fill"/>
}
</>
);
return <PluginIcon plugin={plugin} size={64} blurred={true} showTooltip={false}/>
}
@@ -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
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
<Tooltip color="foreground" placement="left" content="Change plugin order">
<Button isIconOnly variant="flat" onPress={pluginPrioritiesModal.onOpen}>
<Button isIconOnly
variant="flat"
onPress={pluginPrioritiesModal.onOpen}
isDisabled={plugins.length === 0}>
<ListNumbers/>
</Button>
</Tooltip>
</div>
<div className="grid grid-cols-300px gap-4">
{plugins.length === 0 && <div className="flex flex-row justify-center">
<p className="text-gray-500">No plugins of this type installed.</p>
</div>}
{plugins.length > 0 && <div className="grid grid-cols-300px gap-4">
{plugins.map((plugin) =>
<PluginManagementCard plugin={plugin} key={plugin.id}/>
)}
</div>
</div>}
<PluginPrioritiesModal
key={plugins.map(p => p.id + p.priority).join(',')} // force re-mount if plugin order changes
+14 -6
View File
@@ -79,13 +79,21 @@ export default function GameView() {
return game && (
<div className="flex flex-col gap-4">
<div className="overflow-hidden relative rounded-t-lg">
{(game.imageIds && game.imageIds.length > 0) ?
<img className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
alt="Game screenshot"
src={`/images/screenshot/${game.imageIds[0]}`}
/> :
{game.headerId ? (
<img
className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
alt="Game header"
src={`/images/header/${game.headerId}`}
/>
) : game.imageIds && game.imageIds.length > 0 ? (
<img
className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
alt="Game screenshot"
src={`/images/screenshot/${game.imageIds[0]}`}
/>
) : (
<div className="w-full h-96 bg-secondary relative"/>
}
)}
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-background"/>
</div>
<div className="flex flex-col gap-4 mx-24">
@@ -35,8 +35,8 @@ class GameEndpoint(
}
@RolesAllowed(Role.Names.ADMIN)
fun getPotentialMatches(searchTerm: String, groupResults: Boolean): List<GameSearchResultDto> {
return gameService.getPotentialMatches(searchTerm, groupResults)
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
return gameService.getPotentialMatches(searchTerm)
}
@RolesAllowed(Role.Names.ADMIN)
@@ -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<GameSearchResultDto> {
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
// 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<Pair<GameSearchResultDto, Int>> { 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(),
@@ -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?,
@@ -6,7 +6,8 @@ import java.util.*
class GameSearchResultDto(
val id: UUID = UUID.randomUUID(),
val title: String,
val coverUrl: String?,
val coverUrls: List<UrlWithSourceDto>?,
val headerUrls: List<UrlWithSourceDto>?,
val release: Instant?,
val publishers: Collection<String>?,
val developers: Collection<String>?,
@@ -20,4 +21,9 @@ class OriginalIdDto(
override fun toString(): String {
return "$pluginId:$originalId"
}
}
}
class UrlWithSourceDto(
val url: String,
val pluginId: String
)
@@ -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<String>?,
@@ -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,
@@ -31,6 +31,7 @@ class Image(
enum class ImageType {
COVER,
HEADER,
SCREENSHOT,
AVATAR
}
@@ -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(),
)
@@ -36,6 +36,10 @@ class ImageEndpoint(
fun getCover(@PathVariable("id") id: Long): ResponseEntity<InputStreamResource>? {
return getImageContent(id)
}
@GetMapping("/header/{id}")
fun getHeader(@PathVariable("id") id: Long): ResponseEntity<InputStreamResource>? {
return getImageContent(id)
}
@GetMapping("/plugins/{id}/logo")
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? {
+1 -2
View File
@@ -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 {
@@ -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<URI>? = null,
val headerUrls: List<URI>? = null,
val release: Instant? = null,
val userRating: Int? = null,
val criticRating: Int? = null,
@@ -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
@@ -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(),
+2 -2
View File
@@ -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.<br>
Requires a Twitch account and IGDB API credentials.<br>
@@ -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
+1 -1
View File
@@ -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
@@ -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<GameMetadata> {
return runBlocking {
val covers = mutableListOf<GameMetadata>()
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<SteamGridDbGrid>? {
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<SteamGridDbHero>? {
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? {
@@ -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()
}
@@ -11,7 +11,5 @@ data class SteamGridDbGridResult(
@Serializable
data class SteamGridDbGrid(
val id: Int,
val width: Int,
val height: Int,
val url: String
)
@@ -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<SteamGridDbHero>?
)
@Serializable
data class SteamGridDbHero(
val id: Int,
val url: String
)
@@ -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
@@ -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