mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Implement manual game matching
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, {useEffect} from "react";
|
||||
import React from "react";
|
||||
import {PluginManagementSection} from "Frontend/components/general/plugin/PluginManagementSection";
|
||||
import {initializePluginState, pluginState} from "Frontend/state/PluginState";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
|
||||
export default function PluginManagement() {
|
||||
@@ -10,10 +10,6 @@ export default function PluginManagement() {
|
||||
|
||||
const state = useSnapshot(pluginState);
|
||||
|
||||
useEffect(() => {
|
||||
initializePluginState();
|
||||
}, []);
|
||||
|
||||
return state.isLoaded && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row flex-grow justify-between mb-8">
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function SearchBar() {
|
||||
defaultItems={games}
|
||||
inputProps={{
|
||||
classNames: {
|
||||
input: "text-small",
|
||||
input: "text-small w-96",
|
||||
inputWrapper: "h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20"
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -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 {
|
||||
Button,
|
||||
Input,
|
||||
Link,
|
||||
Pagination,
|
||||
Select,
|
||||
@@ -33,6 +34,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
|
||||
const state = useSnapshot(gameState);
|
||||
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : [];
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
|
||||
|
||||
const [selectedGame, setSelectedGame] = useState<GameDto>(games[0]);
|
||||
@@ -49,17 +51,24 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
const end = start + rowsPerPage;
|
||||
|
||||
return getFilteredGames().slice(start, end);
|
||||
}, [page, games, filter]);
|
||||
}, [page, games, filter, searchTerm]);
|
||||
|
||||
|
||||
function getFilteredGames() {
|
||||
let filteredGames = games.filter((game) =>
|
||||
game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
game.developers?.some(developer => developer.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
)
|
||||
|
||||
if (filter === "confirmed") {
|
||||
return games.filter(g => g.metadata.matchConfirmed);
|
||||
return filteredGames.filter(g => g.metadata.matchConfirmed);
|
||||
}
|
||||
if (filter === "nonConfirmed") {
|
||||
return games.filter(g => !g.metadata.matchConfirmed);
|
||||
return filteredGames.filter(g => !g.metadata.matchConfirmed);
|
||||
}
|
||||
return games;
|
||||
return filteredGames;
|
||||
}
|
||||
|
||||
async function toggleMatchConfirmed(game: GameDto) {
|
||||
@@ -75,9 +84,17 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
await GameEndpoint.deleteGame(game.id);
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-4">
|
||||
return selectedGame && <div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-bold">Manage games in library</h1>
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<div className="flex flex-row gap-2 justify-between">
|
||||
<Input
|
||||
className="w-96"
|
||||
isClearable
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onClear={() => setSearchTerm("")}
|
||||
/>
|
||||
<Select
|
||||
selectedKeys={[filter]}
|
||||
disallowEmptySelection
|
||||
@@ -89,9 +106,9 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
<SelectItem key="nonConfirmed">Show only non confirmed</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
<Table removeWrapper isStriped isHeaderSticky
|
||||
<Table removeWrapper isStriped
|
||||
bottomContent={
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="flex w-full justify-center sticky">
|
||||
{items.length > 0 &&
|
||||
<Pagination
|
||||
isCompact
|
||||
@@ -171,7 +188,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
<EditGameMetadataModal game={selectedGame}
|
||||
isOpen={editGameModal.isOpen}
|
||||
onOpenChange={editGameModal.onOpenChange}/>
|
||||
<MatchGameModal initialSearchTerm={selectedGame.title}
|
||||
<MatchGameModal path={selectedGame.metadata.path!!}
|
||||
libraryId={library.id}
|
||||
replaceGameId={selectedGame.id}
|
||||
initialSearchTerm={selectedGame.title}
|
||||
isOpen={matchGameModal.isOpen}
|
||||
onOpenChange={matchGameModal.onOpenChange}/>
|
||||
</div>;
|
||||
|
||||
+56
-22
@@ -1,29 +1,47 @@
|
||||
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
|
||||
import {Button, Pagination, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@heroui/react";
|
||||
import {Trash} from "@phosphor-icons/react";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
useDisclosure
|
||||
} from "@heroui/react";
|
||||
import {MagnifyingGlass, Trash} from "@phosphor-icons/react";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import {useMemo, useState} from "react";
|
||||
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
|
||||
import {hashCode} from "Frontend/util/utils";
|
||||
import {fileNameFromPath, hashCode} from "Frontend/util/utils";
|
||||
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||
|
||||
interface LibraryManagementUnmatchedPathsProps {
|
||||
library: LibraryDto;
|
||||
}
|
||||
|
||||
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) {
|
||||
const matchGameModal = useDisclosure();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const rowsPerPage = 25;
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const pages = useMemo(() => {
|
||||
return Math.ceil(library.unmatchedPaths!.length / rowsPerPage);
|
||||
}, [library]);
|
||||
const filteredItems = useMemo(() => {
|
||||
return library.unmatchedPaths!
|
||||
.filter((path) => path.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.map((path) => ({key: hashCode(path), path}));
|
||||
}, [searchTerm, library]);
|
||||
|
||||
const pages = useMemo(() => Math.ceil(filteredItems.length / rowsPerPage), [filteredItems]);
|
||||
const items = useMemo(() => {
|
||||
const start = (page - 1) * rowsPerPage;
|
||||
const end = start + rowsPerPage;
|
||||
return filteredItems.slice(start, start + rowsPerPage);
|
||||
}, [page, filteredItems]);
|
||||
|
||||
return unmatchedPathItems().slice(start, end);
|
||||
}, [page, library]);
|
||||
const [selectedPath, setSelectedPath] = useState(library.unmatchedPaths ? library.unmatchedPaths[0] : null);
|
||||
|
||||
async function deleteUnmatchedPath(unmatchedPath: string) {
|
||||
const libraryUpdateDto: LibraryUpdateDto = {
|
||||
@@ -33,15 +51,16 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
await LibraryEndpoint.updateLibrary(libraryUpdateDto);
|
||||
}
|
||||
|
||||
function unmatchedPathItems(): UnmatchedPathItem[] {
|
||||
return library.unmatchedPaths!.map((path) => ({
|
||||
key: hashCode(path),
|
||||
path: path
|
||||
}));
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-bold">Manage unmatched paths</h1>
|
||||
<Input
|
||||
className="w-96"
|
||||
isClearable
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onClear={() => setSearchTerm("")}
|
||||
/>
|
||||
<Table removeWrapper isStriped isHeaderSticky
|
||||
bottomContent={
|
||||
<div className="flex w-full justify-center">
|
||||
@@ -68,14 +87,29 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
{item.path}
|
||||
</TableCell>
|
||||
<TableCell className="flex flex-row gap-2">
|
||||
<Button isIconOnly size="sm" color="danger"
|
||||
onPress={() => deleteUnmatchedPath(item.path)}><Trash/>
|
||||
</Button>
|
||||
<Tooltip content="Match game">
|
||||
<Button isIconOnly size="sm" onPress={() => {
|
||||
setSelectedPath(item.path);
|
||||
matchGameModal.onOpenChange();
|
||||
}}>
|
||||
<MagnifyingGlass/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Remove entry from list">
|
||||
<Button isIconOnly size="sm" color="danger"
|
||||
onPress={() => deleteUnmatchedPath(item.path)}><Trash/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{selectedPath && <MatchGameModal path={selectedPath}
|
||||
libraryId={library.id}
|
||||
initialSearchTerm={fileNameFromPath(selectedPath, false)}
|
||||
isOpen={matchGameModal.isOpen}
|
||||
onOpenChange={matchGameModal.onOpenChange}/>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
type UnmatchedPathItem = { key: number; path: string };
|
||||
}
|
||||
@@ -1,60 +1,151 @@
|
||||
import {Button, Input, Modal, ModalBody, ModalContent} from "@heroui/react";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip
|
||||
} from "@heroui/react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {ArrowRight, 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 {
|
||||
path: string;
|
||||
libraryId: number;
|
||||
replaceGameId?: number;
|
||||
initialSearchTerm: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function MatchGameModal({initialSearchTerm, isOpen, onOpenChange}: EditGameMetadataModalProps) {
|
||||
export default function MatchGameModal({
|
||||
path,
|
||||
libraryId,
|
||||
replaceGameId,
|
||||
initialSearchTerm,
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: EditGameMetadataModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isMatching, setIsMatching] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(initialSearchTerm);
|
||||
setSearchResults([]);
|
||||
}, [isOpen]);
|
||||
|
||||
async function matchGame(result: GameSearchResultDto) {
|
||||
await GameEndpoint.matchManually(result.originalIds, path, libraryId, replaceGameId);
|
||||
}
|
||||
|
||||
async function search() {
|
||||
setIsLoading(true);
|
||||
setIsSearching(true);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||
setSearchResults(results);
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl" hideCloseButton>
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}
|
||||
hideCloseButton
|
||||
isDismissable={!isSearching && !isMatching}
|
||||
isKeyboardDismissDisabled={!isSearching && !isMatching}
|
||||
backdrop="opaque" size="5xl">
|
||||
<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>
|
||||
{(onClose) => (
|
||||
<ModalBody className="my-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<pre>{path}</pre>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 mb-4">
|
||||
<Input 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>
|
||||
|
||||
<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>
|
||||
<div>
|
||||
<Table removeWrapper isStriped isHeaderSticky
|
||||
classNames={{
|
||||
base: "h-80 overflow-scroll",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn>Title & Release</TableColumn>
|
||||
<TableColumn>Developer(s)</TableColumn>
|
||||
<TableColumn>Publisher(s)</TableColumn>
|
||||
{/* width={1} keeps the column as far to the right as possible*/}
|
||||
<TableColumn>Sources</TableColumn>
|
||||
<TableColumn width={1}> </TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody emptyContent="Your filter did not match any games." items={searchResults}>
|
||||
{(item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
{item.developers ? item.developers.map(
|
||||
developer => <p>{developer}</p>
|
||||
) : "unknown"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
{item.publishers ? item.publishers.map(
|
||||
publisher => <p>{publisher}</p>
|
||||
) : "unknown"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row gap-2">
|
||||
{Object.values(item.originalIds).map(
|
||||
originalId => <PluginIcon pluginId={originalId.pluginId}/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip content="Pick this result">
|
||||
<Button isIconOnly size="sm"
|
||||
isDisabled={isMatching !== null}
|
||||
isLoading={isMatching === item.id}
|
||||
onPress={async () => {
|
||||
setIsMatching(item.id);
|
||||
await matchGame(item);
|
||||
setIsMatching(null);
|
||||
onClose();
|
||||
}}>
|
||||
<ArrowRight/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ModalBody>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {Image, Tooltip} from "@heroui/react";
|
||||
import {Plug} from "@phosphor-icons/react";
|
||||
import {initializePluginState, pluginState} from "Frontend/state/PluginState";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {useEffect} from "react";
|
||||
|
||||
interface PluginLogoProps {
|
||||
pluginId: string;
|
||||
@@ -11,10 +10,6 @@ interface PluginLogoProps {
|
||||
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 ?
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<div className="flex flex-col pr-8">
|
||||
<Listbox className="min-w-60" color="primary">
|
||||
<Listbox className="w-60 fixed" color="primary">
|
||||
{menuItems.map((i) => (
|
||||
<ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)}
|
||||
onPress={() => setSelectedItem(i.url)}
|
||||
@@ -53,7 +53,7 @@ export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="ml-60 flex-1 overflow-auto">
|
||||
<Outlet/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {getCsrfToken} from "Frontend/util/auth";
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
export function isAdmin(auth: any): boolean {
|
||||
return auth.state.user?.roles?.some((a: string) => a?.includes("ADMIN"));
|
||||
}
|
||||
|
||||
export function roleToRoleName(role: string) {
|
||||
role = role.replace("ROLE_", "").toLowerCase();
|
||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||
@@ -188,4 +192,19 @@ export function deepDiff<T extends object>(initial: T, current: T): Partial<T> {
|
||||
|
||||
const result = compareObjects(initial, current);
|
||||
return result || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the file name from a given path.
|
||||
* Supports both Windows and Unix-style paths.
|
||||
* @param path
|
||||
* @param includeExtension
|
||||
*/
|
||||
export function fileNameFromPath(path: string, includeExtension: boolean = true): string {
|
||||
let fileName = (path.split('\\').pop() ?? '').split('/').pop() ?? '';
|
||||
if (includeExtension) {
|
||||
return fileName;
|
||||
}
|
||||
const dotIndex = fileName.lastIndexOf('.');
|
||||
return dotIndex < 0 ? fileName : fileName.substring(0, dotIndex);
|
||||
}
|
||||
@@ -4,17 +4,24 @@ import {useNavigate, useParams} from "react-router";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
|
||||
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
|
||||
import {Chip, Link, Tooltip} from "@heroui/react";
|
||||
import {humanFileSize, toTitleCase} from "Frontend/util/utils";
|
||||
import {Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import {humanFileSize, isAdmin, toTitleCase} from "Frontend/util/utils";
|
||||
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
|
||||
import {gameState, initializeGameState} from "Frontend/state/GameState";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||
import {Info, TriangleDashed} from "@phosphor-icons/react";
|
||||
import {Info, MagnifyingGlass, TriangleDashed} from "@phosphor-icons/react";
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||
|
||||
export default function GameView() {
|
||||
const {gameId} = useParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
|
||||
const matchGameModal = useDisclosure();
|
||||
|
||||
const state = useSnapshot(gameState);
|
||||
const game = gameId ? state.state[parseInt(gameId)] as GameDto : undefined;
|
||||
|
||||
@@ -76,10 +83,17 @@ export default function GameView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{downloadOptions && <ComboButton description={humanFileSize(game.metadata.fileSize)}
|
||||
options={downloadOptions}
|
||||
preferredOptionKey="preferred-download-method"
|
||||
/>}
|
||||
<div className="flex flex-row items-center gap-8">
|
||||
{isAdmin(auth) && <Tooltip content="Edit game">
|
||||
<Button isIconOnly onPress={matchGameModal.onOpenChange}>
|
||||
<MagnifyingGlass/>
|
||||
</Button>
|
||||
</Tooltip>}
|
||||
{downloadOptions && <ComboButton description={humanFileSize(game.metadata.fileSize)}
|
||||
options={downloadOptions}
|
||||
preferredOptionKey="preferred-download-method"
|
||||
/>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-row gap-12">
|
||||
@@ -181,6 +195,12 @@ export default function GameView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MatchGameModal path={game.metadata.path!!}
|
||||
libraryId={game.libraryId}
|
||||
replaceGameId={game.id}
|
||||
initialSearchTerm={game.title}
|
||||
isOpen={matchGameModal.isOpen}
|
||||
onOpenChange={matchGameModal.onOpenChange}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import {scanState} from "Frontend/state/ScanState";
|
||||
import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover";
|
||||
import {isAdmin} from "Frontend/util/utils";
|
||||
|
||||
export default function MainLayout() {
|
||||
const navigate = useNavigate();
|
||||
@@ -99,7 +100,7 @@ export default function MainLayout() {
|
||||
</Tooltip>
|
||||
</NavbarContent>}
|
||||
<NavbarContent justify="end">
|
||||
{auth.state.user?.roles?.some(a => a?.includes("ADMIN")) &&
|
||||
{isAdmin(auth) &&
|
||||
<NavbarItem>
|
||||
<ScanProgressPopover/>
|
||||
</NavbarItem>
|
||||
|
||||
+6
@@ -209,6 +209,12 @@ class GameyfinPluginManager(
|
||||
.map { it.simpleName }
|
||||
}
|
||||
|
||||
fun getPluginForExtension(extensionClass: Class<ExtensionPoint>): PluginWrapper? {
|
||||
return getPlugins().firstOrNull { pluginWrapper ->
|
||||
getExtensionTypeClasses(pluginWrapper.pluginId).any { it == extensionClass.javaClass }
|
||||
}
|
||||
}
|
||||
|
||||
fun getManagementEntry(pluginId: String): PluginManagementEntry {
|
||||
return pluginManagementRepository.findByIdOrNull(pluginId)
|
||||
?: throw IllegalArgumentException("Plugin with ID $pluginId not found")
|
||||
|
||||
@@ -2,14 +2,12 @@ package de.grimsi.gameyfin.games
|
||||
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.games.dto.GameDto
|
||||
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.*
|
||||
import de.grimsi.gameyfin.libraries.LibraryService
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import reactor.core.publisher.Flux
|
||||
import java.nio.file.Path
|
||||
|
||||
@Endpoint
|
||||
@PermitAll
|
||||
@@ -36,4 +34,13 @@ class GameEndpoint(
|
||||
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
|
||||
return gameService.getPotentialMatches(searchTerm)
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun matchManually(originalIds: Map<String, OriginalIdDto>, path: String, libraryId: Long, replaceGameId: Long?) {
|
||||
val library = libraryService.getById(libraryId)
|
||||
val game = gameService.matchManually(originalIds, Path.of(path), library, replaceGameId)
|
||||
if (game != null) {
|
||||
libraryService.addGamesToLibrary(listOf(game), library, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,17 @@ package de.grimsi.gameyfin.games
|
||||
import de.grimsi.gameyfin.config.ConfigProperties
|
||||
import de.grimsi.gameyfin.config.ConfigService
|
||||
import de.grimsi.gameyfin.core.alphaNumeric
|
||||
import de.grimsi.gameyfin.core.filesystem.FilesystemService
|
||||
import de.grimsi.gameyfin.core.filterValuesNotNull
|
||||
import de.grimsi.gameyfin.core.plugins.PluginService
|
||||
import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginManager
|
||||
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
|
||||
import de.grimsi.gameyfin.core.replaceRomanNumerals
|
||||
import de.grimsi.gameyfin.games.dto.*
|
||||
import de.grimsi.gameyfin.games.entities.*
|
||||
import de.grimsi.gameyfin.games.repositories.GameRepository
|
||||
import de.grimsi.gameyfin.libraries.Library
|
||||
import de.grimsi.gameyfin.media.ImageService
|
||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
@@ -19,7 +22,6 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
import org.pf4j.PluginManager
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
@@ -28,19 +30,20 @@ import org.springframework.transaction.annotation.Transactional
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Sinks
|
||||
import java.nio.file.Path
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.toJavaDuration
|
||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata as PluginApiMetadata
|
||||
|
||||
@Service
|
||||
class GameService(
|
||||
private val pluginManager: PluginManager,
|
||||
private val gameRepository: GameRepository,
|
||||
private val pluginManager: GameyfinPluginManager,
|
||||
private val pluginService: PluginService,
|
||||
private val config: ConfigService,
|
||||
private val companyService: CompanyService,
|
||||
private val gameRepository: GameRepository,
|
||||
private val userService: UserService
|
||||
private val userService: UserService,
|
||||
private val imageService: ImageService,
|
||||
private val filesystemService: FilesystemService
|
||||
) {
|
||||
companion object {
|
||||
private val log = KotlinLogging.logger {}
|
||||
@@ -74,6 +77,29 @@ class GameService(
|
||||
return entities.map { it.toDto() }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun create(game: Game): Game? {
|
||||
game.publishers = game.publishers.map { companyService.createOrGet(it) }
|
||||
game.developers = game.developers.map { companyService.createOrGet(it) }
|
||||
|
||||
try {
|
||||
game.coverImage?.let {
|
||||
imageService.downloadIfNew(it)
|
||||
}
|
||||
|
||||
game.images.map {
|
||||
imageService.downloadIfNew(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error downloading images for game: ${e.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
game.metadata.fileSize = filesystemService.calculateFileSize(game.metadata.path)
|
||||
|
||||
return gameRepository.save(game)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun create(games: List<Game>): List<Game> {
|
||||
val gamesToBePersisted = games.filter { it.id == null }
|
||||
@@ -121,12 +147,10 @@ class GameService(
|
||||
}
|
||||
|
||||
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
|
||||
// 1. Query all plugins for up to 5 results each
|
||||
// 1. Query all plugins for up to 10 results each
|
||||
val results = metadataPlugins.flatMap { plugin ->
|
||||
try {
|
||||
plugin.fetchByTitle(searchTerm, 5)
|
||||
// Filter out invalid results (null release or coverUrl)
|
||||
.filter { it.release != null && it.coverUrl != null }
|
||||
plugin.fetchByTitle(searchTerm, 10)
|
||||
.map { plugin to it }
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error fetching metadata for game with plugin ${plugin.javaClass.name}" }
|
||||
@@ -134,20 +158,9 @@ class GameService(
|
||||
}
|
||||
}
|
||||
|
||||
// 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() }
|
||||
// 2. Group by title
|
||||
// (NOTE: This _could_ lead to problems if multiple games have the (almost) same title - see Battlefront 2)
|
||||
val grouped = results.groupBy { (_, metadata) -> metadata.title.normalizeGameTitle() }
|
||||
|
||||
// 3. Merge each group into one GameSearchResultDto using plugin priorities
|
||||
val providerToManagementEntry =
|
||||
@@ -163,18 +176,19 @@ class GameService(
|
||||
sorted.mapNotNull { selector(it.second) }.firstOrNull { it.isNotEmpty() }
|
||||
|
||||
// Collect originalIds for this group
|
||||
val originalIds: Map<String, String> = group
|
||||
val originalIds: Map<String, OriginalIdDto> = group
|
||||
.mapNotNull { (provider, metadata) ->
|
||||
val pluginId = providerToManagementEntry[provider]?.pluginId
|
||||
val providerId = provider.javaClass.name
|
||||
val pluginId = providerToManagementEntry[provider]?.pluginId ?: return@mapNotNull null
|
||||
val originalId = metadata.originalId
|
||||
if (pluginId != null) pluginId to originalId else null
|
||||
if (providerId != null) providerId to OriginalIdDto(pluginId, originalId) else null
|
||||
}
|
||||
.toMap()
|
||||
|
||||
return GameSearchResultDto(
|
||||
title = pick { it.title }!!,
|
||||
coverUrl = pick { it.coverUrl.toString() }!!,
|
||||
release = pick { it.release }!!,
|
||||
coverUrl = pick { it.coverUrl.toString() },
|
||||
release = pick { it.release },
|
||||
publishers = pickList { it.publishedBy?.toList() },
|
||||
developers = pickList { it.developedBy?.toList() },
|
||||
originalIds = originalIds
|
||||
@@ -193,6 +207,56 @@ class GameService(
|
||||
.map { it.first }
|
||||
}
|
||||
|
||||
fun matchManually(
|
||||
originalIds: Map<String, OriginalIdDto>,
|
||||
path: Path,
|
||||
library: Library,
|
||||
replaceGameId: Long? = null
|
||||
): Game? {
|
||||
// Step 0: Query all metadata plugins for metadata on the provided originalIds
|
||||
val metadataResults = runBlocking {
|
||||
coroutineScope {
|
||||
metadataPlugins.associateWith { plugin ->
|
||||
async {
|
||||
val originalId = originalIds[plugin.javaClass.name]?.originalId ?: return@async null
|
||||
try {
|
||||
return@async plugin.fetchById(originalId)
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error fetching metadata for game [id: $originalId] with plugin ${plugin.javaClass.name}" }
|
||||
null
|
||||
}
|
||||
}.await()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Filter out invalid (empty) results
|
||||
// In theory all results should be valid
|
||||
val validResults = metadataResults.filterValuesNotNull()
|
||||
if (validResults.isEmpty()) {
|
||||
log.error { "No results found for originalIds: $originalIds" }
|
||||
return null
|
||||
}
|
||||
|
||||
// Step 3: Merge results into a single Game entity
|
||||
val mergedGame = mergeResults(validResults, path, library)
|
||||
|
||||
// Step 4: If a replaceGameId is provided, set it (overwriting the existing entity)
|
||||
if (replaceGameId != null) {
|
||||
val existingGame = getById(replaceGameId)
|
||||
|
||||
// Copy fields from the existing game to the merged game
|
||||
mergedGame.id = existingGame.id
|
||||
mergedGame.createdAt = existingGame.createdAt
|
||||
mergedGame.metadata.downloadCount = existingGame.metadata.downloadCount
|
||||
}
|
||||
|
||||
mergedGame.metadata.matchConfirmed = true
|
||||
|
||||
// Step 6: Save the game
|
||||
return create(mergedGame)
|
||||
}
|
||||
|
||||
fun matchFromFile(path: Path, library: Library): Game? {
|
||||
val query = FilenameUtils.removeExtension(path.fileName.toString())
|
||||
|
||||
@@ -216,10 +280,6 @@ class GameService(
|
||||
return mergedGame
|
||||
}
|
||||
|
||||
fun getAllByPaths(paths: List<String>): List<Game> {
|
||||
return gameRepository.findAllByMetadata_PathIn(paths)
|
||||
}
|
||||
|
||||
fun getById(id: Long): Game {
|
||||
return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
package de.grimsi.gameyfin.games.dto
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
class GameSearchResultDto(
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val title: String,
|
||||
val coverUrl: String,
|
||||
val release: Instant,
|
||||
val coverUrl: String?,
|
||||
val release: Instant?,
|
||||
val publishers: Collection<String>?,
|
||||
val developers: Collection<String>?,
|
||||
val originalIds: Map<String, String>
|
||||
)
|
||||
val originalIds: Map<String, OriginalIdDto>
|
||||
)
|
||||
|
||||
class OriginalIdDto(
|
||||
val pluginId: String,
|
||||
val originalId: String,
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return "$pluginId:$originalId"
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,11 @@ class Game(
|
||||
var id: Long? = null,
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(updatable = false)
|
||||
@Column(nullable = false, updatable = false)
|
||||
var createdAt: Instant? = null,
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(nullable = false)
|
||||
var updatedAt: Instant? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
|
||||
@@ -15,10 +15,11 @@ class Library(
|
||||
var id: Long? = null,
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(updatable = false)
|
||||
@Column(nullable = false, updatable = false)
|
||||
var createdAt: Instant? = null,
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(nullable = false)
|
||||
var updatedAt: Instant? = null,
|
||||
|
||||
var name: String,
|
||||
|
||||
@@ -80,6 +80,15 @@ class LibraryService(
|
||||
return entities.map { it.toDto() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a library by its ID.
|
||||
*/
|
||||
fun getById(libraryId: Long): Library {
|
||||
val library = libraryRepository.findByIdOrNull(libraryId)
|
||||
?: throw IllegalArgumentException("Library with ID $libraryId not found")
|
||||
return library
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a library in the repository.
|
||||
*
|
||||
@@ -102,7 +111,7 @@ class LibraryService(
|
||||
* @throws IllegalArgumentException if the library ID is null or the library is not found.
|
||||
*/
|
||||
fun update(libraryUpdateDto: LibraryUpdateDto) {
|
||||
var library = libraryRepository.findByIdOrNull(libraryUpdateDto.id)
|
||||
val library = libraryRepository.findByIdOrNull(libraryUpdateDto.id)
|
||||
?: throw IllegalArgumentException("Library with ID $libraryUpdateDto.id not found")
|
||||
|
||||
// Update only non-null fields
|
||||
@@ -133,11 +142,11 @@ class LibraryService(
|
||||
|
||||
fun deleteGameFromLibrary(gameId: Long) {
|
||||
val game = gameService.getById(gameId)
|
||||
var library = game.library
|
||||
val library = game.library
|
||||
|
||||
library.games.removeIf { it.id == gameId }
|
||||
library.unmatchedPaths.add(game.metadata.path)
|
||||
|
||||
|
||||
library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
|
||||
libraryRepository.save(library)
|
||||
}
|
||||
@@ -316,7 +325,7 @@ class LibraryService(
|
||||
libraryRepository.save(library)
|
||||
|
||||
progress.currentStep = LibraryScanStep(description = "Finished")
|
||||
progress.finishedAt = java.time.Instant.now()
|
||||
progress.finishedAt = Instant.now()
|
||||
progress.status = LibraryScanStatus.COMPLETED
|
||||
progress.result = LibraryScanResult(
|
||||
new = persistedGames.size,
|
||||
@@ -333,9 +342,23 @@ class LibraryService(
|
||||
* @param library: The library to add the games to.
|
||||
* @return The updated library.
|
||||
*/
|
||||
private fun addGamesToLibrary(games: Collection<Game>, library: Library): Library {
|
||||
fun addGamesToLibrary(games: Collection<Game>, library: Library, persist: Boolean = false): Library {
|
||||
val newGames = games.filter { game -> library.games.none { it.id == game.id } }
|
||||
library.games.addAll(newGames)
|
||||
|
||||
var removedAnyUnmatchedPaths = false
|
||||
for (game in newGames) {
|
||||
if (library.unmatchedPaths.contains(game.metadata.path)) {
|
||||
library.unmatchedPaths.remove(game.metadata.path)
|
||||
removedAnyUnmatchedPaths = true
|
||||
}
|
||||
}
|
||||
|
||||
if (removedAnyUnmatchedPaths || persist) {
|
||||
library.updatedAt = Instant.now()
|
||||
return libraryRepository.save(library)
|
||||
}
|
||||
|
||||
return library
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user