Implement manual game matching

This commit is contained in:
grimsi
2025-06-13 19:37:53 +02:00
parent d9f97f1de5
commit 382d26373e
17 changed files with 416 additions and 131 deletions
@@ -1,6 +1,6 @@
import React, {useEffect} from "react"; import React from "react";
import {PluginManagementSection} from "Frontend/components/general/plugin/PluginManagementSection"; 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"; import {useSnapshot} from "valtio/react";
export default function PluginManagement() { export default function PluginManagement() {
@@ -10,10 +10,6 @@ export default function PluginManagement() {
const state = useSnapshot(pluginState); const state = useSnapshot(pluginState);
useEffect(() => {
initializePluginState();
}, []);
return state.isLoaded && ( return state.isLoaded && (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between mb-8"> <div className="flex flex-row flex-grow justify-between mb-8">
@@ -21,7 +21,7 @@ export default function SearchBar() {
defaultItems={games} defaultItems={games}
inputProps={{ inputProps={{
classNames: { 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" 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 GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import { import {
Button, Button,
Input,
Link, Link,
Pagination, Pagination,
Select, Select,
@@ -33,6 +34,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
const state = useSnapshot(gameState); const state = useSnapshot(gameState);
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 [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all"); const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
const [selectedGame, setSelectedGame] = useState<GameDto>(games[0]); const [selectedGame, setSelectedGame] = useState<GameDto>(games[0]);
@@ -49,17 +51,24 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
const end = start + rowsPerPage; const end = start + rowsPerPage;
return getFilteredGames().slice(start, end); return getFilteredGames().slice(start, end);
}, [page, games, filter]); }, [page, games, filter, searchTerm]);
function getFilteredGames() { 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") { if (filter === "confirmed") {
return games.filter(g => g.metadata.matchConfirmed); return filteredGames.filter(g => g.metadata.matchConfirmed);
} }
if (filter === "nonConfirmed") { 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) { async function toggleMatchConfirmed(game: GameDto) {
@@ -75,9 +84,17 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
await GameEndpoint.deleteGame(game.id); 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> <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 <Select
selectedKeys={[filter]} selectedKeys={[filter]}
disallowEmptySelection disallowEmptySelection
@@ -89,9 +106,9 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<SelectItem key="nonConfirmed">Show only non confirmed</SelectItem> <SelectItem key="nonConfirmed">Show only non confirmed</SelectItem>
</Select> </Select>
</div> </div>
<Table removeWrapper isStriped isHeaderSticky <Table removeWrapper isStriped
bottomContent={ bottomContent={
<div className="flex w-full justify-center"> <div className="flex w-full justify-center sticky">
{items.length > 0 && {items.length > 0 &&
<Pagination <Pagination
isCompact isCompact
@@ -171,7 +188,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<EditGameMetadataModal game={selectedGame} <EditGameMetadataModal game={selectedGame}
isOpen={editGameModal.isOpen} isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/> onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal initialSearchTerm={selectedGame.title} <MatchGameModal path={selectedGame.metadata.path!!}
libraryId={library.id}
replaceGameId={selectedGame.id}
initialSearchTerm={selectedGame.title}
isOpen={matchGameModal.isOpen} isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/> onOpenChange={matchGameModal.onOpenChange}/>
</div>; </div>;
@@ -1,29 +1,47 @@
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {Button, Pagination, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@heroui/react"; import {
import {Trash} from "@phosphor-icons/react"; 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 {LibraryEndpoint} from "Frontend/generated/endpoints";
import {useMemo, useState} from "react"; import {useMemo, useState} from "react";
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto"; 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 { interface LibraryManagementUnmatchedPathsProps {
library: LibraryDto; library: LibraryDto;
} }
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) { export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) {
const matchGameModal = useDisclosure();
const [searchTerm, setSearchTerm] = useState("");
const [page, setPage] = useState(1);
const rowsPerPage = 25; const rowsPerPage = 25;
const [page, setPage] = useState(1); const filteredItems = useMemo(() => {
const pages = useMemo(() => { return library.unmatchedPaths!
return Math.ceil(library.unmatchedPaths!.length / rowsPerPage); .filter((path) => path.toLowerCase().includes(searchTerm.toLowerCase()))
}, [library]); .map((path) => ({key: hashCode(path), path}));
}, [searchTerm, library]);
const pages = useMemo(() => Math.ceil(filteredItems.length / rowsPerPage), [filteredItems]);
const items = useMemo(() => { const items = useMemo(() => {
const start = (page - 1) * rowsPerPage; const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage; return filteredItems.slice(start, start + rowsPerPage);
}, [page, filteredItems]);
return unmatchedPathItems().slice(start, end); const [selectedPath, setSelectedPath] = useState(library.unmatchedPaths ? library.unmatchedPaths[0] : null);
}, [page, library]);
async function deleteUnmatchedPath(unmatchedPath: string) { async function deleteUnmatchedPath(unmatchedPath: string) {
const libraryUpdateDto: LibraryUpdateDto = { const libraryUpdateDto: LibraryUpdateDto = {
@@ -33,15 +51,16 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
await LibraryEndpoint.updateLibrary(libraryUpdateDto); 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"> return <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage unmatched paths</h1> <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 <Table removeWrapper isStriped isHeaderSticky
bottomContent={ bottomContent={
<div className="flex w-full justify-center"> <div className="flex w-full justify-center">
@@ -68,14 +87,29 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
{item.path} {item.path}
</TableCell> </TableCell>
<TableCell className="flex flex-row gap-2"> <TableCell className="flex flex-row gap-2">
<Button isIconOnly size="sm" color="danger" <Tooltip content="Match game">
onPress={() => deleteUnmatchedPath(item.path)}><Trash/> <Button isIconOnly size="sm" onPress={() => {
</Button> 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> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>
{selectedPath && <MatchGameModal path={selectedPath}
libraryId={library.id}
initialSearchTerm={fileNameFromPath(selectedPath, false)}
isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
}
</div>; </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 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 {GameEndpoint} from "Frontend/generated/endpoints";
import GameSearchResultDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameSearchResultDto"; import GameSearchResultDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameSearchResultDto";
import PluginIcon from "../plugin/PluginIcon"; import PluginIcon from "../plugin/PluginIcon";
interface EditGameMetadataModalProps { interface EditGameMetadataModalProps {
path: string;
libraryId: number;
replaceGameId?: number;
initialSearchTerm: string; initialSearchTerm: string;
isOpen: boolean; isOpen: boolean;
onOpenChange: () => void; 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 [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]); const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [isMatching, setIsMatching] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setSearchTerm(initialSearchTerm); setSearchTerm(initialSearchTerm);
setSearchResults([]); setSearchResults([]);
}, [isOpen]); }, [isOpen]);
async function matchGame(result: GameSearchResultDto) {
await GameEndpoint.matchManually(result.originalIds, path, libraryId, replaceGameId);
}
async function search() { async function search() {
setIsLoading(true); setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm); const results = await GameEndpoint.getPotentialMatches(searchTerm);
setSearchResults(results); setSearchResults(results);
setIsLoading(false); setIsSearching(false);
} }
return ( 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> <ModalContent>
<ModalBody className="my-4"> {(onClose) => (
<div className="flex flex-row gap-2 mb-4"> <ModalBody className="my-4">
<Input value={searchTerm} onValueChange={setSearchTerm}/> <div className="flex flex-col items-center">
<Button isIconOnly onPress={search} color="primary" isLoading={isLoading}> <pre>{path}</pre>
<MagnifyingGlass/> </div>
</Button> <div className="flex flex-row gap-2 mb-4">
</div> <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"> <div>
{searchResults.length === 0 ? <Table removeWrapper isStriped isHeaderSticky
<p className="text-gray-500 text-center">No results found.</p> : classNames={{
<div className="flex flex-col gap-2"> base: "h-80 overflow-scroll",
{searchResults.map((result, index) => ( }}
<div className="flex flex-row items-center gap-2"> >
<p key={index}>{result.title} ({new Date(result.release).getFullYear()})</p> <TableHeader>
{Object.keys(result.originalIds) <TableColumn>Title & Release</TableColumn>
.map(pluginId => <PluginIcon pluginId={pluginId}/>) <TableColumn>Developer(s)</TableColumn>
} <TableColumn>Publisher(s)</TableColumn>
</div> {/* width={1} keeps the column as far to the right as possible*/}
))} <TableColumn>Sources</TableColumn>
</div> <TableColumn width={1}> </TableColumn>
} </TableHeader>
</div> <TableBody emptyContent="Your filter did not match any games." items={searchResults}>
</ModalBody> {(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> </ModalContent>
</Modal> </Modal>
); );
@@ -1,8 +1,7 @@
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 {initializePluginState, pluginState} from "Frontend/state/PluginState"; import {pluginState} from "Frontend/state/PluginState";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {useEffect} from "react";
interface PluginLogoProps { interface PluginLogoProps {
pluginId: string; pluginId: string;
@@ -11,10 +10,6 @@ interface PluginLogoProps {
export default function PluginIcon({pluginId}: PluginLogoProps) { export default function PluginIcon({pluginId}: PluginLogoProps) {
const state = useSnapshot(pluginState); const state = useSnapshot(pluginState);
useEffect(() => {
initializePluginState();
}, []);
return state.isLoaded && ( return state.isLoaded && (
<Tooltip content={state.state[pluginId].name}> <Tooltip content={state.state[pluginId].name}>
{state.state[pluginId].hasLogo ? {state.state[pluginId].hasLogo ?
@@ -43,7 +43,7 @@ export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
return ( return (
<div className="flex flex-row"> <div className="flex flex-row">
<div className="flex flex-col pr-8"> <div className="flex flex-col pr-8">
<Listbox className="min-w-60" color="primary"> <Listbox className="w-60 fixed" color="primary">
{menuItems.map((i) => ( {menuItems.map((i) => (
<ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)} <ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)}
onPress={() => setSelectedItem(i.url)} onPress={() => setSelectedItem(i.url)}
@@ -53,7 +53,7 @@ export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
))} ))}
</Listbox> </Listbox>
</div> </div>
<div className="flex-1 overflow-auto"> <div className="ml-60 flex-1 overflow-auto">
<Outlet/> <Outlet/>
</div> </div>
</div> </div>
+19
View File
@@ -1,6 +1,10 @@
import {getCsrfToken} from "Frontend/util/auth"; import {getCsrfToken} from "Frontend/util/auth";
import moment from 'moment-timezone'; 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) { export function roleToRoleName(role: string) {
role = role.replace("ROLE_", "").toLowerCase(); role = role.replace("ROLE_", "").toLowerCase();
return role.charAt(0).toUpperCase() + role.slice(1); return role.charAt(0).toUpperCase() + role.slice(1);
@@ -189,3 +193,18 @@ export function deepDiff<T extends object>(initial: T, current: T): Partial<T> {
const result = compareObjects(initial, current); const result = compareObjects(initial, current);
return result || {}; 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);
}
+27 -7
View File
@@ -4,17 +4,24 @@ import {useNavigate, useParams} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover"; import {GameCover} from "Frontend/components/general/covers/GameCover";
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton"; import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel"; import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
import {Chip, Link, Tooltip} from "@heroui/react"; import {Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react";
import {humanFileSize, toTitleCase} from "Frontend/util/utils"; import {humanFileSize, isAdmin, toTitleCase} from "Frontend/util/utils";
import {DownloadEndpoint} from "Frontend/endpoints/endpoints"; import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
import {gameState, initializeGameState} from "Frontend/state/GameState"; import {gameState, initializeGameState} from "Frontend/state/GameState";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; 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() { export default function GameView() {
const {gameId} = useParams(); const {gameId} = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const auth = useAuth();
const matchGameModal = useDisclosure();
const state = useSnapshot(gameState); const state = useSnapshot(gameState);
const game = gameId ? state.state[parseInt(gameId)] as GameDto : undefined; const game = gameId ? state.state[parseInt(gameId)] as GameDto : undefined;
@@ -76,10 +83,17 @@ export default function GameView() {
</div> </div>
</div> </div>
</div> </div>
{downloadOptions && <ComboButton description={humanFileSize(game.metadata.fileSize)} <div className="flex flex-row items-center gap-8">
options={downloadOptions} {isAdmin(auth) && <Tooltip content="Edit game">
preferredOptionKey="preferred-download-method" <Button isIconOnly onPress={matchGameModal.onOpenChange}>
/>} <MagnifyingGlass/>
</Button>
</Tooltip>}
{downloadOptions && <ComboButton description={humanFileSize(game.metadata.fileSize)}
options={downloadOptions}
preferredOptionKey="preferred-download-method"
/>}
</div>
</div> </div>
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="flex flex-row gap-12"> <div className="flex flex-row gap-12">
@@ -181,6 +195,12 @@ export default function GameView() {
</div> </div>
</div> </div>
</div> </div>
<MatchGameModal path={game.metadata.path!!}
libraryId={game.libraryId}
replaceGameId={game.id}
initialSearchTerm={game.title}
isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
</div> </div>
); );
} }
@@ -15,6 +15,7 @@ import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import {scanState} from "Frontend/state/ScanState"; import {scanState} from "Frontend/state/ScanState";
import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover"; import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover";
import {isAdmin} from "Frontend/util/utils";
export default function MainLayout() { export default function MainLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -99,7 +100,7 @@ export default function MainLayout() {
</Tooltip> </Tooltip>
</NavbarContent>} </NavbarContent>}
<NavbarContent justify="end"> <NavbarContent justify="end">
{auth.state.user?.roles?.some(a => a?.includes("ADMIN")) && {isAdmin(auth) &&
<NavbarItem> <NavbarItem>
<ScanProgressPopover/> <ScanProgressPopover/>
</NavbarItem> </NavbarItem>
@@ -209,6 +209,12 @@ class GameyfinPluginManager(
.map { it.simpleName } .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 { fun getManagementEntry(pluginId: String): PluginManagementEntry {
return pluginManagementRepository.findByIdOrNull(pluginId) return pluginManagementRepository.findByIdOrNull(pluginId)
?: throw IllegalArgumentException("Plugin with ID $pluginId not found") ?: throw IllegalArgumentException("Plugin with ID $pluginId not found")
@@ -2,14 +2,12 @@ package de.grimsi.gameyfin.games
import com.vaadin.hilla.Endpoint 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.*
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.libraries.LibraryService import de.grimsi.gameyfin.libraries.LibraryService
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import java.nio.file.Path
@Endpoint @Endpoint
@PermitAll @PermitAll
@@ -36,4 +34,13 @@ class GameEndpoint(
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> { fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
return gameService.getPotentialMatches(searchTerm) 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.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.core.alphaNumeric import de.grimsi.gameyfin.core.alphaNumeric
import de.grimsi.gameyfin.core.filesystem.FilesystemService
import de.grimsi.gameyfin.core.filterValuesNotNull import de.grimsi.gameyfin.core.filterValuesNotNull
import de.grimsi.gameyfin.core.plugins.PluginService 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.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.core.replaceRomanNumerals import de.grimsi.gameyfin.core.replaceRomanNumerals
import de.grimsi.gameyfin.games.dto.* import de.grimsi.gameyfin.games.dto.*
import de.grimsi.gameyfin.games.entities.* 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.media.ImageService
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import de.grimsi.gameyfin.users.UserService import de.grimsi.gameyfin.users.UserService
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
@@ -19,7 +22,6 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import me.xdrop.fuzzywuzzy.FuzzySearch import me.xdrop.fuzzywuzzy.FuzzySearch
import org.apache.commons.io.FilenameUtils import org.apache.commons.io.FilenameUtils
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.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails 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.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
@Service @Service
class GameService( class GameService(
private val pluginManager: PluginManager, private val gameRepository: GameRepository,
private val pluginManager: GameyfinPluginManager,
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 userService: UserService,
private val userService: UserService private val imageService: ImageService,
private val filesystemService: FilesystemService
) { ) {
companion object { companion object {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@@ -74,6 +77,29 @@ class GameService(
return entities.map { it.toDto() } 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 @Transactional
fun create(games: List<Game>): List<Game> { fun create(games: List<Game>): List<Game> {
val gamesToBePersisted = games.filter { it.id == null } val gamesToBePersisted = games.filter { it.id == null }
@@ -121,12 +147,10 @@ class GameService(
} }
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> { 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 -> val results = metadataPlugins.flatMap { plugin ->
try { try {
plugin.fetchByTitle(searchTerm, 5) plugin.fetchByTitle(searchTerm, 10)
// Filter out invalid results (null release or coverUrl)
.filter { it.release != null && it.coverUrl != null }
.map { plugin to it } .map { plugin to it }
} catch (e: Exception) { } catch (e: Exception) {
log.error(e) { "Error fetching metadata for game with plugin ${plugin.javaClass.name}" } 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 // 2. Group by title
data class GroupKey(val title: String, val year: Int?, val month: Int?) // (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() }
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 // 3. Merge each group into one GameSearchResultDto using plugin priorities
val providerToManagementEntry = val providerToManagementEntry =
@@ -163,18 +176,19 @@ class GameService(
sorted.mapNotNull { selector(it.second) }.firstOrNull { it.isNotEmpty() } sorted.mapNotNull { selector(it.second) }.firstOrNull { it.isNotEmpty() }
// Collect originalIds for this group // Collect originalIds for this group
val originalIds: Map<String, String> = group val originalIds: Map<String, OriginalIdDto> = group
.mapNotNull { (provider, metadata) -> .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 val originalId = metadata.originalId
if (pluginId != null) pluginId to originalId else null if (providerId != null) providerId to OriginalIdDto(pluginId, originalId) else null
} }
.toMap() .toMap()
return GameSearchResultDto( return GameSearchResultDto(
title = pick { it.title }!!, title = pick { it.title }!!,
coverUrl = pick { it.coverUrl.toString() }!!, coverUrl = pick { it.coverUrl.toString() },
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() },
originalIds = originalIds originalIds = originalIds
@@ -193,6 +207,56 @@ class GameService(
.map { it.first } .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? { fun matchFromFile(path: Path, library: Library): Game? {
val query = FilenameUtils.removeExtension(path.fileName.toString()) val query = FilenameUtils.removeExtension(path.fileName.toString())
@@ -216,10 +280,6 @@ class GameService(
return mergedGame return mergedGame
} }
fun getAllByPaths(paths: List<String>): List<Game> {
return gameRepository.findAllByMetadata_PathIn(paths)
}
fun getById(id: Long): Game { fun getById(id: Long): Game {
return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found") return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found")
} }
@@ -1,12 +1,23 @@
package de.grimsi.gameyfin.games.dto package de.grimsi.gameyfin.games.dto
import java.time.Instant import java.time.Instant
import java.util.*
class GameSearchResultDto( class GameSearchResultDto(
val id: UUID = UUID.randomUUID(),
val title: String, val title: String,
val coverUrl: String, val coverUrl: String?,
val release: Instant, val release: Instant?,
val publishers: Collection<String>?, val publishers: Collection<String>?,
val developers: 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, var id: Long? = null,
@CreationTimestamp @CreationTimestamp
@Column(updatable = false) @Column(nullable = false, updatable = false)
var createdAt: Instant? = null, var createdAt: Instant? = null,
@UpdateTimestamp @UpdateTimestamp
@Column(nullable = false)
var updatedAt: Instant? = null, var updatedAt: Instant? = null,
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@@ -15,10 +15,11 @@ class Library(
var id: Long? = null, var id: Long? = null,
@CreationTimestamp @CreationTimestamp
@Column(updatable = false) @Column(nullable = false, updatable = false)
var createdAt: Instant? = null, var createdAt: Instant? = null,
@UpdateTimestamp @UpdateTimestamp
@Column(nullable = false)
var updatedAt: Instant? = null, var updatedAt: Instant? = null,
var name: String, var name: String,
@@ -80,6 +80,15 @@ class LibraryService(
return entities.map { it.toDto() } 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. * 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. * @throws IllegalArgumentException if the library ID is null or the library is not found.
*/ */
fun update(libraryUpdateDto: LibraryUpdateDto) { 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") ?: throw IllegalArgumentException("Library with ID $libraryUpdateDto.id not found")
// Update only non-null fields // Update only non-null fields
@@ -133,7 +142,7 @@ class LibraryService(
fun deleteGameFromLibrary(gameId: Long) { fun deleteGameFromLibrary(gameId: Long) {
val game = gameService.getById(gameId) val game = gameService.getById(gameId)
var library = game.library val library = game.library
library.games.removeIf { it.id == gameId } library.games.removeIf { it.id == gameId }
library.unmatchedPaths.add(game.metadata.path) library.unmatchedPaths.add(game.metadata.path)
@@ -316,7 +325,7 @@ class LibraryService(
libraryRepository.save(library) libraryRepository.save(library)
progress.currentStep = LibraryScanStep(description = "Finished") progress.currentStep = LibraryScanStep(description = "Finished")
progress.finishedAt = java.time.Instant.now() progress.finishedAt = Instant.now()
progress.status = LibraryScanStatus.COMPLETED progress.status = LibraryScanStatus.COMPLETED
progress.result = LibraryScanResult( progress.result = LibraryScanResult(
new = persistedGames.size, new = persistedGames.size,
@@ -333,9 +342,23 @@ class LibraryService(
* @param library: The library to add the games to. * @param library: The library to add the games to.
* @return The updated library. * @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 } } val newGames = games.filter { game -> library.games.none { it.id == game.id } }
library.games.addAll(newGames) 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 return library
} }