(WIP) Implement manual matching of game files

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