mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +00:00
(WIP) Implement manual matching of game files
This commit is contained in:
@@ -97,7 +97,7 @@ export default function ProfileManagement() {
|
|||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
isLoading={formik.isSubmitting}
|
isLoading={formik.isSubmitting}
|
||||||
disabled={formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
|
isDisabled={formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {useField} from "formik";
|
||||||
|
import {Textarea} from "@heroui/react";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
export default function TextAreaInput({label, showErrorUntouched = false, ...props}) {
|
||||||
|
// @ts-ignore
|
||||||
|
const [field, meta] = useField(props);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
className={`flex-grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
|
||||||
|
fullWidth={false}
|
||||||
|
{...props}
|
||||||
|
{...field}
|
||||||
|
id={label}
|
||||||
|
label={label}
|
||||||
|
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||||
|
errorMessage={meta.initialError || meta.error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/Libr
|
|||||||
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Link,
|
||||||
Pagination,
|
Pagination,
|
||||||
Select,
|
Select,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
@@ -11,14 +12,17 @@ import {
|
|||||||
TableColumn,
|
TableColumn,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
Tooltip
|
Tooltip,
|
||||||
|
useDisclosure
|
||||||
} from "@heroui/react";
|
} from "@heroui/react";
|
||||||
import {CheckCircle, Pencil, Trash} from "@phosphor-icons/react";
|
import {CheckCircle, MagnifyingGlass, Pencil, Trash} from "@phosphor-icons/react";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||||
import GameUpdateDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameUpdateDto";
|
import GameUpdateDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameUpdateDto";
|
||||||
import {useMemo, useState} from "react";
|
import {useMemo, useState} from "react";
|
||||||
|
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
|
||||||
|
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||||
|
|
||||||
interface LibraryManagementGamesProps {
|
interface LibraryManagementGamesProps {
|
||||||
library: LibraryDto;
|
library: LibraryDto;
|
||||||
@@ -31,6 +35,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : [];
|
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : [];
|
||||||
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
|
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
|
||||||
|
|
||||||
|
const [selectedGame, setSelectedGame] = useState<GameDto>(games[0]);
|
||||||
|
const editGameModal = useDisclosure();
|
||||||
|
const matchGameModal = useDisclosure();
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pages = useMemo(() => {
|
const pages = useMemo(() => {
|
||||||
return Math.ceil(getFilteredGames().length / rowsPerPage);
|
return Math.ceil(getFilteredGames().length / rowsPerPage);
|
||||||
@@ -43,6 +51,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
return getFilteredGames().slice(start, end);
|
return getFilteredGames().slice(start, end);
|
||||||
}, [page, games, filter]);
|
}, [page, games, filter]);
|
||||||
|
|
||||||
|
|
||||||
function getFilteredGames() {
|
function getFilteredGames() {
|
||||||
if (filter === "confirmed") {
|
if (filter === "confirmed") {
|
||||||
return games.filter(g => g.metadata.matchConfirmed);
|
return games.filter(g => g.metadata.matchConfirmed);
|
||||||
@@ -98,6 +107,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn allowsSorting>Game</TableColumn>
|
<TableColumn allowsSorting>Game</TableColumn>
|
||||||
<TableColumn allowsSorting>Added to library</TableColumn>
|
<TableColumn allowsSorting>Added to library</TableColumn>
|
||||||
|
<TableColumn allowsSorting>Download count</TableColumn>
|
||||||
<TableColumn>Path</TableColumn>
|
<TableColumn>Path</TableColumn>
|
||||||
{/* width={1} keeps the column as far to the right as possible*/}
|
{/* width={1} keeps the column as far to the right as possible*/}
|
||||||
<TableColumn width={1}>Actions</TableColumn>
|
<TableColumn width={1}>Actions</TableColumn>
|
||||||
@@ -106,11 +116,18 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
{(item) => (
|
{(item) => (
|
||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{item.title} ({item.release !== undefined ? new Date(item.release).getFullYear() : "unknown"})
|
<Link href={`/game/${item.id}`}
|
||||||
|
color="foreground"
|
||||||
|
className="text-sm"
|
||||||
|
underline="hover">{item.title} ({item.release !== undefined ? new Date(item.release).getFullYear() : "unknown"})
|
||||||
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{new Date(item.createdAt).toLocaleString()}
|
{new Date(item.createdAt).toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.metadata.downloadCount}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{item.metadata.path}
|
{item.metadata.path}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -124,13 +141,38 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
<CheckCircle/>
|
<CheckCircle/>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button isIconOnly size="sm" isDisabled={true}><Pencil/></Button>
|
<Button isIconOnly size="sm" onPress={() => {
|
||||||
|
setSelectedGame(item);
|
||||||
|
editGameModal.onOpenChange();
|
||||||
|
}}>
|
||||||
|
<Tooltip content="Edit metadata">
|
||||||
|
<Pencil/>
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
<Button isIconOnly size="sm" onPress={() => {
|
||||||
|
setSelectedGame(item);
|
||||||
|
matchGameModal.onOpenChange();
|
||||||
|
}}>
|
||||||
|
<Tooltip content="Match game">
|
||||||
|
<MagnifyingGlass/>
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
<Button isIconOnly size="sm" color="danger"
|
<Button isIconOnly size="sm" color="danger"
|
||||||
onPress={() => deleteGame(item)}><Trash/></Button>
|
onPress={() => deleteGame(item)}>
|
||||||
|
<Tooltip content="Remove from library">
|
||||||
|
<Trash/>
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
<EditGameMetadataModal game={selectedGame}
|
||||||
|
isOpen={editGameModal.isOpen}
|
||||||
|
onOpenChange={editGameModal.onOpenChange}/>
|
||||||
|
<MatchGameModal initialSearchTerm={selectedGame.title}
|
||||||
|
isOpen={matchGameModal.isOpen}
|
||||||
|
onOpenChange={matchGameModal.onOpenChange}/>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||||
|
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||||
|
import {Form, Formik} from "formik";
|
||||||
|
import Input from "Frontend/components/general/input/Input";
|
||||||
|
import React from "react";
|
||||||
|
import GameUpdateDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameUpdateDto";
|
||||||
|
import {deepDiff} from "Frontend/util/utils";
|
||||||
|
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
|
||||||
|
|
||||||
|
interface EditGameMetadataModalProps {
|
||||||
|
game: GameDto;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => {
|
||||||
|
|
||||||
|
async function updateGame(values: GameUpdateDto) {
|
||||||
|
const changed = deepDiff(game, values) as GameUpdateDto;
|
||||||
|
if (Object.keys(changed).length === 0) return;
|
||||||
|
|
||||||
|
changed.id = game.id;
|
||||||
|
await GameEndpoint.updateGame(changed);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik initialValues={game}
|
||||||
|
enableReinitialize={true}
|
||||||
|
onSubmit={updateGame}
|
||||||
|
>
|
||||||
|
{(formik: any) => (
|
||||||
|
<Form>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
Update game metadata
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<Input key="title" name="title" label="Title"/>
|
||||||
|
<TextAreaInput key="summary" name="summary" label="Summary (Markdown)"/>
|
||||||
|
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isLoading={formik.isSubmitting}
|
||||||
|
isDisabled={formik.isSubmitting || !formik.dirty}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{formik.isSubmitting ? "" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
|
||||||
import {Form, Formik} from "formik";
|
|
||||||
import Input from "Frontend/components/general/input/Input";
|
|
||||||
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
|
|
||||||
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
|
|
||||||
import Section from "Frontend/components/general/Section";
|
|
||||||
|
|
||||||
interface LibraryDetailsModalProps {
|
|
||||||
library: LibraryDto;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: () => void;
|
|
||||||
updateLibrary: (library: LibraryUpdateDto) => Promise<void>;
|
|
||||||
removeLibrary: (library: LibraryDto) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LibraryDetailsModal({
|
|
||||||
library,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
updateLibrary,
|
|
||||||
removeLibrary
|
|
||||||
}: LibraryDetailsModalProps) {
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
|
||||||
<ModalContent>
|
|
||||||
{(onClose) => {
|
|
||||||
async function update(values: LibraryUpdateDto) {
|
|
||||||
await updateLibrary(values);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(library: LibraryDto) {
|
|
||||||
await removeLibrary(library);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Formik initialValues={library}
|
|
||||||
enableReinitialize={true}
|
|
||||||
onSubmit={(values) => update(values)}
|
|
||||||
>
|
|
||||||
{(formik: { isSubmitting: any; }) => (
|
|
||||||
<Form>
|
|
||||||
<ModalHeader className="flex flex-col gap-1">
|
|
||||||
Edit library
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
<Input key="name" name="name" label="Name"/>
|
|
||||||
|
|
||||||
<Section title="Danger zone"/>
|
|
||||||
<Button onPress={() => remove(library)} color="danger">
|
|
||||||
Delete this library
|
|
||||||
</Button>
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button variant="light" onPress={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
isLoading={formik.isSubmitting}
|
|
||||||
disabled={formik.isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{formik.isSubmitting ? "" : "Save"}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {Button, Input, Modal, ModalBody, ModalContent} from "@heroui/react";
|
||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {MagnifyingGlass} from "@phosphor-icons/react";
|
||||||
|
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
import GameSearchResultDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameSearchResultDto";
|
||||||
|
import PluginIcon from "../plugin/PluginIcon";
|
||||||
|
|
||||||
|
interface EditGameMetadataModalProps {
|
||||||
|
initialSearchTerm: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MatchGameModal({initialSearchTerm, isOpen, onOpenChange}: EditGameMetadataModalProps) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchTerm(initialSearchTerm);
|
||||||
|
setSearchResults([]);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
setIsLoading(true);
|
||||||
|
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||||
|
setSearchResults(results);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl" hideCloseButton>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalBody className="my-4">
|
||||||
|
<div className="flex flex-row gap-2 mb-4">
|
||||||
|
<Input value={searchTerm} onValueChange={setSearchTerm}/>
|
||||||
|
<Button isIconOnly onPress={search} color="primary" isLoading={isLoading}>
|
||||||
|
<MagnifyingGlass/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-52 mx-2">
|
||||||
|
{searchResults.length === 0 ?
|
||||||
|
<p className="text-gray-500 text-center">No results found.</p> :
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{searchResults.map((result, index) => (
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<p key={index}>{result.title} ({new Date(result.release).getFullYear()})</p>
|
||||||
|
{Object.keys(result.originalIds)
|
||||||
|
.map(pluginId => <PluginIcon pluginId={pluginId}/>)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChang
|
|||||||
</Button>
|
</Button>
|
||||||
<Button color="primary"
|
<Button color="primary"
|
||||||
isLoading={formik.isSubmitting}
|
isLoading={formik.isSubmitting}
|
||||||
disabled={formik.isSubmitting}
|
isDisabled={formik.isSubmitting}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{formik.isSubmitting ? "" : "Select"}
|
{formik.isSubmitting ? "" : "Select"}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {Image, Tooltip} from "@heroui/react";
|
||||||
|
import {Plug} from "@phosphor-icons/react";
|
||||||
|
import {initializePluginState, pluginState} from "Frontend/state/PluginState";
|
||||||
|
import {useSnapshot} from "valtio/react";
|
||||||
|
import {useEffect} from "react";
|
||||||
|
|
||||||
|
interface PluginLogoProps {
|
||||||
|
pluginId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginIcon({pluginId}: PluginLogoProps) {
|
||||||
|
const state = useSnapshot(pluginState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initializePluginState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ class DownloadEndpoint(
|
|||||||
@RequestParam provider: String
|
@RequestParam provider: String
|
||||||
): ResponseEntity<StreamingResponseBody> {
|
): ResponseEntity<StreamingResponseBody> {
|
||||||
val game = gameService.getById(gameId)
|
val game = gameService.getById(gameId)
|
||||||
|
gameService.incrementDownloadCount(game)
|
||||||
val download = downloadService.getDownload(game.metadata.path, provider)
|
val download = downloadService.getDownload(game.metadata.path, provider)
|
||||||
|
|
||||||
return when (download) {
|
return when (download) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.vaadin.hilla.Endpoint
|
|||||||
import de.grimsi.gameyfin.core.Role
|
import de.grimsi.gameyfin.core.Role
|
||||||
import de.grimsi.gameyfin.games.dto.GameDto
|
import de.grimsi.gameyfin.games.dto.GameDto
|
||||||
import de.grimsi.gameyfin.games.dto.GameEvent
|
import de.grimsi.gameyfin.games.dto.GameEvent
|
||||||
|
import de.grimsi.gameyfin.games.dto.GameSearchResultDto
|
||||||
import de.grimsi.gameyfin.games.dto.GameUpdateDto
|
import de.grimsi.gameyfin.games.dto.GameUpdateDto
|
||||||
import de.grimsi.gameyfin.libraries.LibraryService
|
import de.grimsi.gameyfin.libraries.LibraryService
|
||||||
import jakarta.annotation.security.PermitAll
|
import jakarta.annotation.security.PermitAll
|
||||||
@@ -30,4 +31,9 @@ class GameEndpoint(
|
|||||||
libraryService.deleteGameFromLibrary(gameId)
|
libraryService.deleteGameFromLibrary(gameId)
|
||||||
gameService.delete(gameId)
|
gameService.delete(gameId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
|
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
|
||||||
|
return gameService.getPotentialMatches(searchTerm)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import de.grimsi.gameyfin.games.entities.*
|
|||||||
import de.grimsi.gameyfin.games.repositories.GameRepository
|
import de.grimsi.gameyfin.games.repositories.GameRepository
|
||||||
import de.grimsi.gameyfin.libraries.Library
|
import de.grimsi.gameyfin.libraries.Library
|
||||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
||||||
|
import de.grimsi.gameyfin.users.UserService
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
@@ -20,11 +21,14 @@ import me.xdrop.fuzzywuzzy.FuzzySearch
|
|||||||
import org.apache.commons.io.FilenameUtils
|
import org.apache.commons.io.FilenameUtils
|
||||||
import org.pf4j.PluginManager
|
import org.pf4j.PluginManager
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
import reactor.core.publisher.Sinks
|
import reactor.core.publisher.Sinks
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import java.time.ZoneId
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.toJavaDuration
|
import kotlin.time.toJavaDuration
|
||||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata as PluginApiMetadata
|
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata as PluginApiMetadata
|
||||||
@@ -35,7 +39,8 @@ class GameService(
|
|||||||
private val pluginService: PluginService,
|
private val pluginService: PluginService,
|
||||||
private val config: ConfigService,
|
private val config: ConfigService,
|
||||||
private val companyService: CompanyService,
|
private val companyService: CompanyService,
|
||||||
private val gameRepository: GameRepository
|
private val gameRepository: GameRepository,
|
||||||
|
private val userService: UserService
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
@@ -86,10 +91,24 @@ class GameService(
|
|||||||
val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
|
val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
|
||||||
?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found")
|
?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found")
|
||||||
|
|
||||||
|
val userDetails = SecurityContextHolder.getContext().authentication.principal as UserDetails
|
||||||
|
val user = userService.getByUsernameNonNull(userDetails.username)
|
||||||
|
|
||||||
// Update only non-null fields
|
// Update only non-null fields
|
||||||
gameUpdateDto.title?.let { existingGame.title = it }
|
gameUpdateDto.title?.let {
|
||||||
gameUpdateDto.comment?.let { existingGame.comment = it }
|
existingGame.title = it
|
||||||
gameUpdateDto.summary?.let { existingGame.summary = it }
|
existingGame.metadata.fields["title"]?.source = GameFieldUserSource(user = user)
|
||||||
|
}
|
||||||
|
gameUpdateDto.comment?.let {
|
||||||
|
existingGame.comment = it
|
||||||
|
existingGame.metadata.fields["comment"]?.source = GameFieldUserSource(user = user)
|
||||||
|
}
|
||||||
|
gameUpdateDto.summary?.let {
|
||||||
|
existingGame.summary = it
|
||||||
|
existingGame.metadata.fields["summary"]?.source = GameFieldUserSource(user = user)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
gameUpdateDto.metadata?.let { metadata ->
|
gameUpdateDto.metadata?.let { metadata ->
|
||||||
metadata.matchConfirmed?.let { existingGame.metadata.matchConfirmed = it }
|
metadata.matchConfirmed?.let { existingGame.metadata.matchConfirmed = it }
|
||||||
}
|
}
|
||||||
@@ -101,6 +120,79 @@ class GameService(
|
|||||||
gameRepository.deleteById(gameId)
|
gameRepository.deleteById(gameId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
|
||||||
|
// 1. Query all plugins for up to 5 results each
|
||||||
|
val results = metadataPlugins.flatMap { plugin ->
|
||||||
|
try {
|
||||||
|
plugin.fetchMetadata(searchTerm, 5)
|
||||||
|
// Filter out invalid results (null release or coverUrl)
|
||||||
|
.filter { it.release != null && it.coverUrl != null }
|
||||||
|
.map { plugin to it }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error(e) { "Error fetching metadata for game with plugin ${plugin.javaClass.name}" }
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Group by title, release year, and release month
|
||||||
|
data class GroupKey(val title: String, val year: Int?, val month: Int?)
|
||||||
|
|
||||||
|
fun PluginApiMetadata.groupKey(): GroupKey {
|
||||||
|
val releaseZdt = this.release?.atZone(ZoneId.systemDefault())
|
||||||
|
|
||||||
|
return GroupKey(
|
||||||
|
title = this.title.normalizeGameTitle(),
|
||||||
|
year = releaseZdt?.year,
|
||||||
|
month = releaseZdt?.monthValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val grouped = results.groupBy { (_, metadata) -> metadata.groupKey() }
|
||||||
|
|
||||||
|
// 3. Merge each group into one GameSearchResultDto using plugin priorities
|
||||||
|
val providerToManagementEntry =
|
||||||
|
results.toMap().entries.associate { it.key to pluginService.getPluginManagementEntry(it.key.javaClass) }
|
||||||
|
|
||||||
|
fun pluginPriority(plugin: GameMetadataProvider) = providerToManagementEntry[plugin]?.priority ?: 0
|
||||||
|
|
||||||
|
fun mergeGroup(group: List<Pair<GameMetadataProvider, PluginApiMetadata>>): GameSearchResultDto {
|
||||||
|
val sorted = group.sortedByDescending { (provider, _) -> pluginPriority(provider) }
|
||||||
|
|
||||||
|
fun <T> pick(selector: (PluginApiMetadata) -> T?): T? = sorted.firstNotNullOfOrNull { selector(it.second) }
|
||||||
|
fun <T> pickList(selector: (PluginApiMetadata) -> List<T>?): List<T>? =
|
||||||
|
sorted.mapNotNull { selector(it.second) }.firstOrNull { it.isNotEmpty() }
|
||||||
|
|
||||||
|
// Collect originalIds for this group
|
||||||
|
val originalIds: Map<String, String> = group
|
||||||
|
.mapNotNull { (provider, metadata) ->
|
||||||
|
val pluginId = providerToManagementEntry[provider]?.pluginId
|
||||||
|
val originalId = metadata.originalId
|
||||||
|
if (pluginId != null) pluginId to originalId else null
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
|
||||||
|
return GameSearchResultDto(
|
||||||
|
title = pick { it.title }!!,
|
||||||
|
coverUrl = pick { it.coverUrl.toString() }!!,
|
||||||
|
release = pick { it.release }!!,
|
||||||
|
publishers = pickList { it.publishedBy?.toList() },
|
||||||
|
developers = pickList { it.developedBy?.toList() },
|
||||||
|
originalIds = originalIds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Sort & return merged results
|
||||||
|
val mergedResults = grouped.values.map { mergeGroup(it) }
|
||||||
|
|
||||||
|
return mergedResults
|
||||||
|
.map { result ->
|
||||||
|
val ratio = FuzzySearch.ratio(searchTerm, result.title)
|
||||||
|
result to ratio
|
||||||
|
}
|
||||||
|
.sortedByDescending { it.second }
|
||||||
|
.map { it.first }
|
||||||
|
}
|
||||||
|
|
||||||
fun matchFromFile(path: Path, library: Library): Game? {
|
fun matchFromFile(path: Path, library: Library): Game? {
|
||||||
val query = FilenameUtils.removeExtension(path.fileName.toString())
|
val query = FilenameUtils.removeExtension(path.fileName.toString())
|
||||||
|
|
||||||
@@ -132,9 +224,8 @@ class GameService(
|
|||||||
return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found")
|
return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMatchConfirmed(gameId: Long, confirmed: Boolean) {
|
fun incrementDownloadCount(game: Game) {
|
||||||
val game = getById(gameId)
|
game.metadata.downloadCount++
|
||||||
game.metadata.matchConfirmed = confirmed
|
|
||||||
gameRepository.save(game)
|
gameRepository.save(game)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +443,7 @@ fun Game.toDto(): GameDto {
|
|||||||
is GameFieldUserSource -> {
|
is GameFieldUserSource -> {
|
||||||
GameFieldMetadataDto(
|
GameFieldMetadataDto(
|
||||||
type = GameFieldMetadataType.USER,
|
type = GameFieldMetadataType.USER,
|
||||||
source = source.user.id!!,
|
source = source.user.username,
|
||||||
updatedAt = fieldMetadata.updatedAt!!
|
updatedAt = fieldMetadata.updatedAt!!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package de.grimsi.gameyfin.games.dto
|
package de.grimsi.gameyfin.games.dto
|
||||||
|
|
||||||
import java.io.Serializable
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
class GameFieldMetadataDto(
|
class GameFieldMetadataDto(
|
||||||
val type: GameFieldMetadataType,
|
val type: GameFieldMetadataType,
|
||||||
val source: Serializable,
|
val source: String,
|
||||||
val updatedAt: Instant
|
val updatedAt: Instant
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.grimsi.gameyfin.games.dto
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
class GameSearchResultDto(
|
||||||
|
val title: String,
|
||||||
|
val coverUrl: String,
|
||||||
|
val release: Instant,
|
||||||
|
val publishers: Collection<String>?,
|
||||||
|
val developers: Collection<String>?,
|
||||||
|
val originalIds: Map<String, String>
|
||||||
|
)
|
||||||
@@ -13,7 +13,7 @@ class GameFieldMetadata(
|
|||||||
var id: Long? = null,
|
var id: Long? = null,
|
||||||
|
|
||||||
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.EAGER)
|
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.EAGER)
|
||||||
val source: GameFieldSource,
|
var source: GameFieldSource,
|
||||||
|
|
||||||
@UpdateTimestamp
|
@UpdateTimestamp
|
||||||
var updatedAt: Instant? = Instant.now()
|
var updatedAt: Instant? = Instant.now()
|
||||||
|
|||||||
Reference in New Issue
Block a user