mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +00:00
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:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gameyfin",
|
"name": "gameyfin",
|
||||||
"version": "2.0.0.beta1",
|
"version": "2.0.0.beta2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroui/react": "2.7.9",
|
"@heroui/react": "2.7.9",
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import {Image, useDisclosure} from "@heroui/react";
|
import {Image, useDisclosure} from "@heroui/react";
|
||||||
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {useField} from "formik";
|
import {useField} from "formik";
|
||||||
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
|
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
|
||||||
import {Pencil} from "@phosphor-icons/react";
|
import {ImageBroken, Pencil} from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export default function GameCoverPicker({game, label, showErrorUntouched = false, ...props}) {
|
export default function GameCoverPicker({game, showErrorUntouched = false, ...props}) {
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const [field] = useField(props);
|
const [field] = useField(props);
|
||||||
@@ -15,24 +14,30 @@ export default function GameCoverPicker({game, label, showErrorUntouched = false
|
|||||||
const gameCoverPickerModal = useDisclosure();
|
const gameCoverPickerModal = useDisclosure();
|
||||||
|
|
||||||
return (<>
|
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}>
|
onClick={gameCoverPickerModal.onOpenChange}>
|
||||||
<Image
|
{field.value || game.coverId ?
|
||||||
alt={game.title}
|
<div className="size-full overflow-hidden">
|
||||||
className="z-0 object-cover aspect-[12/17] group-hover:brightness-50"
|
<Image
|
||||||
src={field.value ? field.value : `images/cover/${game.coverId}`}
|
alt={game.title}
|
||||||
{...props}
|
className="z-0 object-cover group-hover:brightness-[25%]"
|
||||||
{...field}
|
src={field.value ? field.value : `images/cover/${game.coverId}`}
|
||||||
radius="none"
|
{...props}
|
||||||
height={216}
|
{...field}
|
||||||
fallbackSrc={<GameCoverFallback title={game.title}
|
radius="none"
|
||||||
size={216}
|
/>
|
||||||
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
|
<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}/>
|
<Pencil size={46}/>
|
||||||
|
<p>Edit cover</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GameCoverPickerModal
|
<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 GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
|
||||||
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
|
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
|
||||||
import ArrayInput from "Frontend/components/general/input/ArrayInput";
|
import ArrayInput from "Frontend/components/general/input/ArrayInput";
|
||||||
|
import GameHeaderPicker from "Frontend/components/general/input/GameHeaderPicker";
|
||||||
|
|
||||||
interface EditGameMetadataModalProps {
|
interface EditGameMetadataModalProps {
|
||||||
game: GameDto;
|
game: GameDto;
|
||||||
@@ -57,15 +58,16 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
|
|||||||
Update game metadata
|
Update game metadata
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div className="flex flex-row gap-8">
|
<Input key="metadata.path" name="metadata.path" label="Path"
|
||||||
{/*@ts-ignore*/}
|
isDisabled className="mb-0"/>
|
||||||
|
<div className="flex flex-row gap-4 h-44">
|
||||||
<GameCoverPicker key="coverUrl" name="coverUrl" game={game}/>
|
<GameCoverPicker key="coverUrl" name="coverUrl" game={game}/>
|
||||||
<div className="flex flex-col flex-1">
|
<GameHeaderPicker key="headerUrl" name="headerUrl" game={game}/>
|
||||||
<Input key="metadata.path" name="metadata.path" label="Path"
|
</div>
|
||||||
isDisabled/>
|
<div className="flex flex-row gap-4">
|
||||||
<Input key="title" name="title" label="Title" isRequired/>
|
<Input key="title" name="title" label="Title" isRequired/>
|
||||||
<DatePickerInput key="release" name="release" label="Release"/>
|
<DatePickerInput key="release" name="release" label="Release"
|
||||||
</div>
|
className="w-fit"/>
|
||||||
</div>
|
</div>
|
||||||
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
|
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
|
||||||
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
|
<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 GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
|
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 {
|
interface GameCoverPickerModalProps {
|
||||||
game: GameDto;
|
game: GameDto;
|
||||||
@@ -17,7 +21,9 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
|||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState(game.title);
|
const [searchTerm, setSearchTerm] = useState(game.title);
|
||||||
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
const state = useSnapshot(pluginState).state;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && searchTerm.length > 0 && searchResults.length === 0) {
|
if (isOpen && searchTerm.length > 0 && searchResults.length === 0) {
|
||||||
@@ -27,8 +33,8 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
|||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, false);
|
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||||
let validResults = results.filter(result => result.coverUrl && result.coverUrl.length > 0 && result.coverUrl !== "null");
|
let validResults = results.filter(result => result.coverUrls && result.coverUrls.length > 0);
|
||||||
setSearchResults(validResults);
|
setSearchResults(validResults);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
@@ -78,25 +84,36 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
|||||||
<p className="text-center text-foreground/70">Searching...</p>
|
<p className="text-center text-foreground/70">Searching...</p>
|
||||||
}
|
}
|
||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
className="grid grid-cols-auto-fill gap-4 h-96 overflow-scroll justify-evenly">
|
className="grid grid-cols-auto-fill gap-4 h-96 overflow-y-scroll justify-evenly">
|
||||||
{searchResults.map((result) => (
|
{searchResults.flatMap(result => {
|
||||||
<div className="relative group w-fit h-fit cursor-pointer"
|
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={() => {
|
onClick={() => {
|
||||||
setCoverUrl(result.coverUrl!);
|
setCoverUrl(cover.url);
|
||||||
onClose();
|
onOpenChange();
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
key={result.id}
|
alt={cover.title}
|
||||||
alt={result.title}
|
className="z-0 object-cover aspect-[12/17] group-hover:brightness-[25%]"
|
||||||
className="z-0 object-cover aspect-[12/17] group-hover:brightness-50"
|
src={cover.url}
|
||||||
src={result.coverUrl!}
|
|
||||||
radius="none"
|
radius="none"
|
||||||
height={216}
|
height={216}
|
||||||
/>
|
/>
|
||||||
<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-4 items-center justify-center opacity-0 group-hover:opacity-100">
|
||||||
>
|
<PluginIcon plugin={state[cover.source] as PluginDto} size={32}
|
||||||
<ArrowRight size={46}/>
|
blurred={false} showTooltip={false}/>
|
||||||
|
<p className="text-s text-center">{cover.title}</p>
|
||||||
|
<ArrowRight/>
|
||||||
</div>
|
</div>
|
||||||
</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 {GameEndpoint} from "Frontend/generated/endpoints";
|
||||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||||
import PluginIcon from "../plugin/PluginIcon";
|
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 {
|
interface EditGameMetadataModalProps {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -40,6 +43,8 @@ export default function MatchGameModal({
|
|||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [isMatching, setIsMatching] = useState<string | null>(null);
|
const [isMatching, setIsMatching] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const state = useSnapshot(pluginState).state;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchTerm(initialSearchTerm);
|
setSearchTerm(initialSearchTerm);
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
@@ -51,7 +56,7 @@ export default function MatchGameModal({
|
|||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, true);
|
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
@@ -86,7 +91,7 @@ export default function MatchGameModal({
|
|||||||
<div>
|
<div>
|
||||||
<Table removeWrapper isStriped isHeaderSticky
|
<Table removeWrapper isStriped isHeaderSticky
|
||||||
classNames={{
|
classNames={{
|
||||||
base: "h-80 overflow-scroll",
|
base: "h-80 overflow-y-auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -120,7 +125,8 @@ export default function MatchGameModal({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
{Object.values(item.originalIds).map(
|
{Object.values(item.originalIds).map(
|
||||||
originalId => <PluginIcon pluginId={originalId.pluginId}/>
|
originalId => <PluginIcon
|
||||||
|
plugin={state[originalId.pluginId] as PluginDto}/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
import {Image, Tooltip} from "@heroui/react";
|
import {Image, Tooltip} from "@heroui/react";
|
||||||
import {Plug} from "@phosphor-icons/react";
|
import {Plug} from "@phosphor-icons/react";
|
||||||
import {pluginState} from "Frontend/state/PluginState";
|
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||||
import {useSnapshot} from "valtio/react";
|
|
||||||
|
|
||||||
interface PluginLogoProps {
|
interface PluginIconProps {
|
||||||
pluginId: string;
|
plugin: PluginDto;
|
||||||
|
size?: number;
|
||||||
|
blurred?: boolean;
|
||||||
|
showTooltip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PluginIcon({pluginId}: PluginLogoProps) {
|
export default function PluginIcon({
|
||||||
const state = useSnapshot(pluginState);
|
plugin,
|
||||||
|
size = 16,
|
||||||
|
blurred = false,
|
||||||
|
showTooltip = true
|
||||||
|
}: PluginIconProps) {
|
||||||
|
|
||||||
return state.isLoaded && (
|
const icon = plugin.hasLogo
|
||||||
<Tooltip content={state.state[pluginId].name}>
|
?
|
||||||
{state.state[pluginId].hasLogo ?
|
<Image isBlurred={blurred} src={`/images/plugins/${plugin.id}/logo`} width={size} height={size} radius="none"/>
|
||||||
<Image src={`/images/plugins/${state.state[pluginId].id}/logo`} width={16} height={16} radius="none"/> :
|
: <Plug size={size} weight="fill"/>;
|
||||||
<Plug size={16} weight="fill"/>
|
|
||||||
}
|
return showTooltip
|
||||||
</Tooltip>
|
? <Tooltip content={plugin.name}>{icon}</Tooltip>
|
||||||
)
|
: icon;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import {Plug} from "@phosphor-icons/react";
|
|
||||||
import React from "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";
|
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||||
|
|
||||||
interface PluginLogoProps {
|
interface PluginLogoProps {
|
||||||
@@ -8,12 +7,5 @@ interface PluginLogoProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PluginLogo({plugin}: PluginLogoProps) {
|
export default function PluginLogo({plugin}: PluginLogoProps) {
|
||||||
return (
|
return <PluginIcon plugin={plugin} size={64} blurred={true} showTooltip={false}/>
|
||||||
<>
|
|
||||||
{plugin.hasLogo ?
|
|
||||||
<Image isBlurred src={`/images/plugins/${plugin.id}/logo`} width={64} height={64} radius="none"/> :
|
|
||||||
<Plug size={64} weight="fill"/>
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ interface PluginManagementSectionProps {
|
|||||||
plugins: PluginDto[];
|
plugins: PluginDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PluginManagementSection({type, plugins}: PluginManagementSectionProps) {
|
export function PluginManagementSection({type, plugins = []}: PluginManagementSectionProps) {
|
||||||
const pluginPrioritiesModal = useDisclosure();
|
const pluginPrioritiesModal = useDisclosure();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,17 +20,24 @@ export function PluginManagementSection({type, plugins}: PluginManagementSection
|
|||||||
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
|
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
|
||||||
|
|
||||||
<Tooltip color="foreground" placement="left" content="Change plugin order">
|
<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/>
|
<ListNumbers/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</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) =>
|
{plugins.map((plugin) =>
|
||||||
<PluginManagementCard plugin={plugin} key={plugin.id}/>
|
<PluginManagementCard plugin={plugin} key={plugin.id}/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
<PluginPrioritiesModal
|
<PluginPrioritiesModal
|
||||||
key={plugins.map(p => p.id + p.priority).join(',')} // force re-mount if plugin order changes
|
key={plugins.map(p => p.id + p.priority).join(',')} // force re-mount if plugin order changes
|
||||||
|
|||||||
@@ -79,13 +79,21 @@ export default function GameView() {
|
|||||||
return game && (
|
return game && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="overflow-hidden relative rounded-t-lg">
|
<div className="overflow-hidden relative rounded-t-lg">
|
||||||
{(game.imageIds && game.imageIds.length > 0) ?
|
{game.headerId ? (
|
||||||
<img className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
|
<img
|
||||||
alt="Game screenshot"
|
className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
|
||||||
src={`/images/screenshot/${game.imageIds[0]}`}
|
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="w-full h-96 bg-secondary relative"/>
|
||||||
}
|
)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-background"/>
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-background"/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 mx-24">
|
<div className="flex flex-col gap-4 mx-24">
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ class GameEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@RolesAllowed(Role.Names.ADMIN)
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun getPotentialMatches(searchTerm: String, groupResults: Boolean): List<GameSearchResultDto> {
|
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
|
||||||
return gameService.getPotentialMatches(searchTerm, groupResults)
|
return gameService.getPotentialMatches(searchTerm)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RolesAllowed(Role.Names.ADMIN)
|
@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.core.replaceRomanNumerals
|
||||||
import org.gameyfin.app.games.dto.*
|
import org.gameyfin.app.games.dto.*
|
||||||
import org.gameyfin.app.games.entities.*
|
import org.gameyfin.app.games.entities.*
|
||||||
|
import org.gameyfin.app.games.entities.GameMetadata
|
||||||
import org.gameyfin.app.games.repositories.GameRepository
|
import org.gameyfin.app.games.repositories.GameRepository
|
||||||
import org.gameyfin.app.libraries.Library
|
import org.gameyfin.app.libraries.Library
|
||||||
import org.gameyfin.app.media.ImageService
|
import org.gameyfin.app.media.ImageService
|
||||||
@@ -90,6 +91,10 @@ class GameService(
|
|||||||
imageService.downloadIfNew(it)
|
imageService.downloadIfNew(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
game.headerImage?.let {
|
||||||
|
imageService.downloadIfNew(it)
|
||||||
|
}
|
||||||
|
|
||||||
game.images.map {
|
game.images.map {
|
||||||
imageService.downloadIfNew(it)
|
imageService.downloadIfNew(it)
|
||||||
}
|
}
|
||||||
@@ -139,6 +144,13 @@ class GameService(
|
|||||||
existingGame.coverImage = newCoverImage
|
existingGame.coverImage = newCoverImage
|
||||||
existingGame.metadata.fields["coverImage"]?.source = GameFieldUserSource(user = user)
|
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 {
|
gameUpdateDto.comment?.let {
|
||||||
existingGame.comment = it
|
existingGame.comment = it
|
||||||
existingGame.metadata.fields["comment"]?.source = GameFieldUserSource(user = user)
|
existingGame.metadata.fields["comment"]?.source = GameFieldUserSource(user = user)
|
||||||
@@ -218,7 +230,7 @@ class GameService(
|
|||||||
gameRepository.deleteById(gameId)
|
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
|
// 1. Query all plugins for up to 10 results each
|
||||||
val results = metadataPlugins.flatMap { plugin ->
|
val results = metadataPlugins.flatMap { plugin ->
|
||||||
try {
|
try {
|
||||||
@@ -232,31 +244,13 @@ class GameService(
|
|||||||
val providerToManagementEntry =
|
val providerToManagementEntry =
|
||||||
results.toMap().entries.associate { it.key to pluginService.getPluginManagementEntry(it.key.javaClass) }
|
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)
|
// 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)
|
// (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?)
|
data class GroupKey(val title: String, val year: Int?)
|
||||||
|
|
||||||
fun PluginApiMetadata.groupKey(): GroupKey =
|
fun PluginApiMetadata.groupKey(): GroupKey =
|
||||||
GroupKey(
|
GroupKey(
|
||||||
title = this.title.trim().lowercase(),
|
title = this.title.normalizeGameTitle(),
|
||||||
year = this.release?.atZone(ZoneId.systemDefault())?.year
|
year = this.release?.atZone(ZoneId.systemDefault())?.year
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -283,9 +277,25 @@ class GameService(
|
|||||||
}
|
}
|
||||||
.toMap()
|
.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(
|
return GameSearchResultDto(
|
||||||
title = pick { it.title }!!,
|
title = pick { it.title }!!,
|
||||||
coverUrl = pick { it.coverUrl.toString() },
|
coverUrls = coverUrls.ifEmpty { null },
|
||||||
|
headerUrls = headerUrls.ifEmpty { null },
|
||||||
release = pick { it.release },
|
release = pick { it.release },
|
||||||
publishers = pickList { it.publishedBy?.toList() },
|
publishers = pickList { it.publishedBy?.toList() },
|
||||||
developers = pickList { it.developedBy?.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) }
|
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 ->
|
.map { result ->
|
||||||
val ratio = FuzzySearch.ratio(searchTerm, result.title)
|
val ratio = FuzzySearch.ratio(searchTerm.normalizeGameTitle(), result.title.normalizeGameTitle())
|
||||||
result to ratio
|
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 }
|
.map { it.first }
|
||||||
|
|
||||||
|
return sortedResults
|
||||||
}
|
}
|
||||||
|
|
||||||
fun matchManually(
|
fun matchManually(
|
||||||
@@ -474,13 +499,20 @@ class GameService(
|
|||||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadata.coverUrl?.let { coverUrl ->
|
metadata.coverUrls?.firstOrNull()?.let { coverUrl ->
|
||||||
if (!metadataMap.containsKey("coverImage")) {
|
if (!metadataMap.containsKey("coverImage")) {
|
||||||
mergedGame.coverImage = Image(originalUrl = coverUrl.toURL(), type = ImageType.COVER)
|
mergedGame.coverImage = Image(originalUrl = coverUrl.toURL(), type = ImageType.COVER)
|
||||||
metadataMap["coverImage"] =
|
metadataMap["coverImage"] =
|
||||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
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 ->
|
metadata.release?.let { release ->
|
||||||
if (!metadataMap.containsKey("release")) {
|
if (!metadataMap.containsKey("release")) {
|
||||||
mergedGame.release = release
|
mergedGame.release = release
|
||||||
@@ -634,6 +666,7 @@ fun Game.toDto(): GameDto {
|
|||||||
libraryId = this.library.id!!,
|
libraryId = this.library.id!!,
|
||||||
title = title!!,
|
title = title!!,
|
||||||
coverId = this.coverImage?.id,
|
coverId = this.coverImage?.id,
|
||||||
|
headerId = this.headerImage?.id,
|
||||||
comment = this.comment,
|
comment = this.comment,
|
||||||
summary = this.summary,
|
summary = this.summary,
|
||||||
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
|
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class GameDto(
|
|||||||
val libraryId: Long,
|
val libraryId: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
val coverId: Long?,
|
val coverId: Long?,
|
||||||
|
val headerId: Long?,
|
||||||
val comment: String?,
|
val comment: String?,
|
||||||
val summary: String?,
|
val summary: String?,
|
||||||
val release: LocalDate?,
|
val release: LocalDate?,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import java.util.*
|
|||||||
class GameSearchResultDto(
|
class GameSearchResultDto(
|
||||||
val id: UUID = UUID.randomUUID(),
|
val id: UUID = UUID.randomUUID(),
|
||||||
val title: String,
|
val title: String,
|
||||||
val coverUrl: String?,
|
val coverUrls: List<UrlWithSourceDto>?,
|
||||||
|
val headerUrls: List<UrlWithSourceDto>?,
|
||||||
val release: Instant?,
|
val release: Instant?,
|
||||||
val publishers: Collection<String>?,
|
val publishers: Collection<String>?,
|
||||||
val developers: Collection<String>?,
|
val developers: Collection<String>?,
|
||||||
@@ -20,4 +21,9 @@ class OriginalIdDto(
|
|||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return "$pluginId:$originalId"
|
return "$pluginId:$originalId"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class UrlWithSourceDto(
|
||||||
|
val url: String,
|
||||||
|
val pluginId: String
|
||||||
|
)
|
||||||
@@ -7,6 +7,7 @@ data class GameUpdateDto(
|
|||||||
val title: String?,
|
val title: String?,
|
||||||
val release: LocalDate?,
|
val release: LocalDate?,
|
||||||
val coverUrl: String?,
|
val coverUrl: String?,
|
||||||
|
val headerUrl: String?,
|
||||||
val comment: String?,
|
val comment: String?,
|
||||||
val summary: String?,
|
val summary: String?,
|
||||||
val developers: List<String>?,
|
val developers: List<String>?,
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class Game(
|
|||||||
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
|
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
|
||||||
var coverImage: Image? = null,
|
var coverImage: Image? = null,
|
||||||
|
|
||||||
|
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
|
||||||
|
var headerImage: Image? = null,
|
||||||
|
|
||||||
@Lob
|
@Lob
|
||||||
@Column(columnDefinition = "CLOB")
|
@Column(columnDefinition = "CLOB")
|
||||||
var comment: String? = null,
|
var comment: String? = null,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class Image(
|
|||||||
|
|
||||||
enum class ImageType {
|
enum class ImageType {
|
||||||
COVER,
|
COVER,
|
||||||
|
HEADER,
|
||||||
SCREENSHOT,
|
SCREENSHOT,
|
||||||
AVATAR
|
AVATAR
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
package org.gameyfin.app.libraries
|
package org.gameyfin.app.libraries
|
||||||
|
|
||||||
import org.gameyfin.app.games.entities.Game
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.gameyfin.app.core.filesystem.FilesystemService
|
import org.gameyfin.app.core.filesystem.FilesystemService
|
||||||
import org.gameyfin.app.games.GameService
|
import org.gameyfin.app.games.GameService
|
||||||
import org.gameyfin.app.libraries.dto.DirectoryMappingDto
|
import org.gameyfin.app.games.entities.Game
|
||||||
import org.gameyfin.app.libraries.dto.LibraryDto
|
import org.gameyfin.app.libraries.dto.*
|
||||||
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.libraries.enums.ScanType
|
import org.gameyfin.app.libraries.enums.ScanType
|
||||||
import org.gameyfin.app.media.ImageService
|
import org.gameyfin.app.media.ImageService
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
@@ -251,7 +244,9 @@ class LibraryService(
|
|||||||
library.games.removeAll(removedGames)
|
library.games.removeAll(removedGames)
|
||||||
|
|
||||||
// 2. Download all images
|
// 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(
|
progress.currentStep = LibraryScanStep(
|
||||||
description = "Downloading images",
|
description = "Downloading images",
|
||||||
@@ -268,6 +263,11 @@ class LibraryService(
|
|||||||
completedImageDownload.andIncrement
|
completedImageDownload.andIncrement
|
||||||
}
|
}
|
||||||
|
|
||||||
|
game.headerImage?.let {
|
||||||
|
imageService.downloadIfNew(it)
|
||||||
|
completedImageDownload.andIncrement
|
||||||
|
}
|
||||||
|
|
||||||
game.images.map {
|
game.images.map {
|
||||||
imageService.downloadIfNew(it)
|
imageService.downloadIfNew(it)
|
||||||
completedImageDownload.andIncrement
|
completedImageDownload.andIncrement
|
||||||
@@ -378,7 +378,7 @@ class LibraryService(
|
|||||||
private fun toEntity(library: LibraryDto): Library {
|
private fun toEntity(library: LibraryDto): Library {
|
||||||
return libraryRepository.findByIdOrNull(library.id) ?: Library(
|
return libraryRepository.findByIdOrNull(library.id) ?: Library(
|
||||||
name = library.name,
|
name = library.name,
|
||||||
directories = library.directories.map {
|
directories = library.directories.distinctBy { it.internalPath }.map {
|
||||||
DirectoryMapping(internalPath = it.internalPath, externalPath = it.externalPath)
|
DirectoryMapping(internalPath = it.internalPath, externalPath = it.externalPath)
|
||||||
}.toMutableList(),
|
}.toMutableList(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class ImageEndpoint(
|
|||||||
fun getCover(@PathVariable("id") id: Long): ResponseEntity<InputStreamResource>? {
|
fun getCover(@PathVariable("id") id: Long): ResponseEntity<InputStreamResource>? {
|
||||||
return getImageContent(id)
|
return getImageContent(id)
|
||||||
}
|
}
|
||||||
|
@GetMapping("/header/{id}")
|
||||||
|
fun getHeader(@PathVariable("id") id: Long): ResponseEntity<InputStreamResource>? {
|
||||||
|
return getImageContent(id)
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/plugins/{id}/logo")
|
@GetMapping("/plugins/{id}/logo")
|
||||||
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? {
|
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? {
|
||||||
|
|||||||
+1
-2
@@ -1,13 +1,12 @@
|
|||||||
import groovy.json.JsonOutput
|
import groovy.json.JsonOutput
|
||||||
import groovy.json.JsonSlurper
|
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.JvmTarget
|
||||||
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
|
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
|
||||||
group = "org.gameyfin"
|
group = "org.gameyfin"
|
||||||
version = "2.0.0.beta1"
|
version = "2.0.0.beta2"
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import java.time.Instant
|
|||||||
* @property originalId The unique identifier for the game from the original source.
|
* @property originalId The unique identifier for the game from the original source.
|
||||||
* @property title The title of the game.
|
* @property title The title of the game.
|
||||||
* @property description A description of the game, or null if not available.
|
* @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 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 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.
|
* @property criticRating The critic rating for the game, or null if not available.
|
||||||
@@ -27,7 +28,8 @@ data class GameMetadata(
|
|||||||
val originalId: String,
|
val originalId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val coverUrl: URI? = null,
|
val coverUrls: List<URI>? = null,
|
||||||
|
val headerUrls: List<URI>? = null,
|
||||||
val release: Instant? = null,
|
val release: Instant? = null,
|
||||||
val userRating: Int? = null,
|
val userRating: Int? = null,
|
||||||
val criticRating: 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-Class: org.gameyfin.plugins.download.direct.DirectDownloadPlugin
|
||||||
Plugin-Id: org.gameyfin.plugins.download.direct
|
Plugin-Id: org.gameyfin.plugins.download.direct
|
||||||
Plugin-Name: Direct Download
|
Plugin-Name: Direct Download
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
|
|||||||
originalId = game.slug,
|
originalId = game.slug,
|
||||||
title = game.name,
|
title = game.name,
|
||||||
description = game.summary,
|
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,
|
release = if (game.firstReleaseDate.seconds > 0) Instant.ofEpochSecond(game.firstReleaseDate.seconds) else null,
|
||||||
userRating = game.rating.toInt(),
|
userRating = game.rating.toInt(),
|
||||||
criticRating = game.aggregatedRating.toInt(),
|
criticRating = game.aggregatedRating.toInt(),
|
||||||
|
|||||||
@@ -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-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-Name: IGDB Metadata
|
||||||
Plugin-Description: Fetches metadata from IGDB.<br>
|
Plugin-Description: Fetches metadata from IGDB.<br>
|
||||||
Requires a Twitch account and IGDB API credentials.<br>
|
Requires a Twitch account and IGDB API credentials.<br>
|
||||||
|
|||||||
@@ -115,14 +115,14 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
|||||||
originalId = id.toString(),
|
originalId = id.toString(),
|
||||||
title = sanitizeTitle(game.name),
|
title = sanitizeTitle(game.name),
|
||||||
description = game.detailedDescription,
|
description = game.detailedDescription,
|
||||||
coverUrl = game.headerImage?.let { URI(it) },
|
coverUrls = game.headerImage?.let { URI(it) }?.let { listOf(it) },
|
||||||
release = game.releaseDate?.date,
|
release = game.releaseDate?.date,
|
||||||
developedBy = game.developers?.toSet(),
|
developedBy = game.developers?.toSet(),
|
||||||
publishedBy = game.publishers?.toSet(),
|
publishedBy = game.publishers?.toSet(),
|
||||||
genres = game.genres?.let { it.map { Mapper.genre(it) }.toSet() },
|
genres = game.genres?.let { genre -> genre.map { Mapper.genre(it) }.toSet() },
|
||||||
keywords = game.categories?.let { it.mapNotNull { it.description }.toSet() },
|
keywords = game.categories?.mapNotNull { it.description }?.toSet(),
|
||||||
screenshotUrls = game.screenshots?.let { it.map { URI(it.pathFull) }.toSet() },
|
screenshotUrls = game.screenshots?.map { URI(it.pathFull) }?.toSet(),
|
||||||
videoUrls = game.movies?.let { it.mapNotNull { it.webm?.let { URI(it.max) } }.toSet() }
|
videoUrls = game.movies?.mapNotNull { video -> video.webm?.let { URI(it.max) } }?.toSet()
|
||||||
)
|
)
|
||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
|
|||||||
@@ -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-Class: org.gameyfin.plugins.metadata.steam.SteamPlugin
|
||||||
Plugin-Id: org.gameyfin.plugins.metadata.steam
|
Plugin-Id: org.gameyfin.plugins.metadata.steam
|
||||||
Plugin-Name: Steam Metadata
|
Plugin-Name: Steam Metadata
|
||||||
|
|||||||
+27
-21
@@ -8,6 +8,7 @@ import org.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
|||||||
import org.gameyfin.plugins.metadata.steamgriddb.api.SteamGridDbApiClient
|
import org.gameyfin.plugins.metadata.steamgriddb.api.SteamGridDbApiClient
|
||||||
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGame
|
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGame
|
||||||
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGrid
|
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGrid
|
||||||
|
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbHero
|
||||||
import org.pf4j.Extension
|
import org.pf4j.Extension
|
||||||
import org.pf4j.PluginWrapper
|
import org.pf4j.PluginWrapper
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
@@ -74,25 +75,18 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
|
|||||||
|
|
||||||
override fun fetchByTitle(gameTitle: String, maxResults: Int): List<GameMetadata> {
|
override fun fetchByTitle(gameTitle: String, maxResults: Int): List<GameMetadata> {
|
||||||
return runBlocking {
|
return runBlocking {
|
||||||
val covers = mutableListOf<GameMetadata>()
|
val results = searchSteamGridDb(gameTitle)
|
||||||
val games = searchSteamGridDb(gameTitle)
|
|
||||||
|
|
||||||
for (game in games) {
|
results.map { game ->
|
||||||
val gameDetails = client?.grids(game.id)
|
val grids = getGridsForGame(game.id)
|
||||||
val grids = gameDetails?.data.orEmpty()
|
val heroes = getHeroesForGame(game.id)
|
||||||
for (grid in grids) {
|
GameMetadata(
|
||||||
covers.add(
|
originalId = game.id.toString(),
|
||||||
GameMetadata(
|
title = game.name,
|
||||||
originalId = game.id.toString(),
|
coverUrls = grids?.map { URI(it.url) },
|
||||||
title = game.name,
|
headerUrls = heroes?.map { URI(it.url) }
|
||||||
coverUrl = URI(grid.url)
|
)
|
||||||
)
|
}.take(maxResults)
|
||||||
)
|
|
||||||
if (covers.size >= maxResults) break
|
|
||||||
}
|
|
||||||
if (covers.size >= maxResults) break
|
|
||||||
}
|
|
||||||
covers
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,10 +95,14 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
|
|||||||
val gameId = id.toIntOrNull() ?: return@runBlocking null
|
val gameId = id.toIntOrNull() ?: return@runBlocking null
|
||||||
val game = getGameById(gameId) ?: return@runBlocking null
|
val game = getGameById(gameId) ?: return@runBlocking null
|
||||||
|
|
||||||
|
val grids = getGridsForGame(game.id)
|
||||||
|
val heroes = getHeroesForGame(game.id)
|
||||||
|
|
||||||
return@runBlocking GameMetadata(
|
return@runBlocking GameMetadata(
|
||||||
originalId = game.id.toString(),
|
originalId = game.id.toString(),
|
||||||
title = game.name,
|
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 client = client ?: throw PluginConfigError("SteamGridDB API client not initialized")
|
||||||
|
|
||||||
val gameDetails = client.grids(gameId)
|
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? {
|
private suspend fun getGameById(gameId: Int): SteamGridDbGame? {
|
||||||
|
|||||||
+7
@@ -11,6 +11,7 @@ import io.ktor.serialization.kotlinx.json.*
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGameResult
|
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGameResult
|
||||||
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGridResult
|
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbGridResult
|
||||||
|
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbHeroResult
|
||||||
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbSearchResult
|
import org.gameyfin.plugins.metadata.steamgriddb.dto.SteamGridDbSearchResult
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +53,12 @@ class SteamGridDbApiClient(private val apiKey: String) {
|
|||||||
}.body()
|
}.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 {
|
suspend fun game(gameId: Int, block: HttpRequestBuilder.() -> Unit = {}): SteamGridDbGameResult {
|
||||||
return get("games/id/$gameId", block).body()
|
return get("games/id/$gameId", block).body()
|
||||||
}
|
}
|
||||||
|
|||||||
-2
@@ -11,7 +11,5 @@ data class SteamGridDbGridResult(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class SteamGridDbGrid(
|
data class SteamGridDbGrid(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val width: Int,
|
|
||||||
val height: Int,
|
|
||||||
val url: String
|
val url: String
|
||||||
)
|
)
|
||||||
+15
@@ -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-Class: org.gameyfin.plugins.metadata.steamgriddb.SteamGridDbPlugin
|
||||||
Plugin-Id: org.gameyfin.plugins.metadata.steamgriddb
|
Plugin-Id: org.gameyfin.plugins.metadata.steamgriddb
|
||||||
Plugin-Name: SteamGridDB Covers
|
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-Class: org.gameyfin.plugins.download.torrent.TorrentDownloadPlugin
|
||||||
Plugin-Id: org.gameyfin.plugins.download.torrent
|
Plugin-Id: org.gameyfin.plugins.download.torrent
|
||||||
Plugin-Name: Torrent Download
|
Plugin-Name: Torrent Download
|
||||||
|
|||||||
Reference in New Issue
Block a user