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 {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>;
@@ -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>
+19
View File
@@ -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);
}
+27 -7
View File
@@ -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>
@@ -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
}