mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-17 00:30:04 +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 {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>;
|
||||||
|
|||||||
+56
-22
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -188,4 +192,19 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
+6
@@ -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,11 +142,11 @@ 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)
|
||||||
|
|
||||||
library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
|
library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
|
||||||
libraryRepository.save(library)
|
libraryRepository.save(library)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user