From ddfaeed34acd3ab4af51dcbb2562a9ce74905a9d Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Thu, 12 Jun 2025 19:29:26 +0200 Subject: [PATCH] (WIP) Implement manual matching of game files --- .../administration/ProfileManagement.tsx | 2 +- .../general/input/TextAreaInput.tsx | 21 ++++ .../library/LibraryManagementGames.tsx | 52 ++++++++- .../general/modals/EditGameMetadataModal.tsx | 68 +++++++++++ .../general/modals/LibraryDetailsModal.tsx | 77 ------------- .../general/modals/MatchGameModal.tsx | 61 ++++++++++ .../general/modals/PathPickerModal.tsx | 2 +- .../components/general/plugin/PluginIcon.tsx | 26 +++++ .../core/download/DownloadEndpoint.kt | 1 + .../de/grimsi/gameyfin/games/GameEndpoint.kt | 6 + .../de/grimsi/gameyfin/games/GameService.kt | 107 ++++++++++++++++-- .../games/dto/GameFieldMetadataDto.kt | 3 +- .../gameyfin/games/dto/GameSearchResultDto.kt | 12 ++ .../games/entities/GameFieldMetadata.kt | 2 +- 14 files changed, 345 insertions(+), 95 deletions(-) create mode 100644 gameyfin/src/main/frontend/components/general/input/TextAreaInput.tsx create mode 100644 gameyfin/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx delete mode 100644 gameyfin/src/main/frontend/components/general/modals/LibraryDetailsModal.tsx create mode 100644 gameyfin/src/main/frontend/components/general/modals/MatchGameModal.tsx create mode 100644 gameyfin/src/main/frontend/components/general/plugin/PluginIcon.tsx create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameSearchResultDto.kt diff --git a/gameyfin/src/main/frontend/components/administration/ProfileManagement.tsx b/gameyfin/src/main/frontend/components/administration/ProfileManagement.tsx index df32632..8518b47 100644 --- a/gameyfin/src/main/frontend/components/administration/ProfileManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/ProfileManagement.tsx @@ -97,7 +97,7 @@ export default function ProfileManagement() { {formik.isSubmitting ? "" : configSaved ? : "Save"} diff --git a/gameyfin/src/main/frontend/components/general/input/TextAreaInput.tsx b/gameyfin/src/main/frontend/components/general/input/TextAreaInput.tsx new file mode 100644 index 0000000..2d58425 --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/input/TextAreaInput.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/library/LibraryManagementGames.tsx b/gameyfin/src/main/frontend/components/general/library/LibraryManagementGames.tsx index 30fdd5d..ba8bb3c 100644 --- a/gameyfin/src/main/frontend/components/general/library/LibraryManagementGames.tsx +++ b/gameyfin/src/main/frontend/components/general/library/LibraryManagementGames.tsx @@ -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(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 Game Added to library + Download count Path {/* width={1} keeps the column as far to the right as possible*/} Actions @@ -106,11 +116,18 @@ export default function LibraryManagementGames({library}: LibraryManagementGames {(item) => ( - {item.title} ({item.release !== undefined ? new Date(item.release).getFullYear() : "unknown"}) + {item.title} ({item.release !== undefined ? new Date(item.release).getFullYear() : "unknown"}) + {new Date(item.createdAt).toLocaleString()} + + {item.metadata.downloadCount} + {item.metadata.path} @@ -124,13 +141,38 @@ export default function LibraryManagementGames({library}: LibraryManagementGames } - + { + setSelectedGame(item); + editGameModal.onOpenChange(); + }}> + + + + + { + setSelectedGame(item); + matchGameModal.onOpenChange(); + }}> + + + + deleteGame(item)}> + onPress={() => deleteGame(item)}> + + + + )} + + ; } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx b/gameyfin/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx new file mode 100644 index 0000000..4f79862 --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx @@ -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 ( + + + {(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: any) => ( + + + Update game metadata + + + + + + + + + Cancel + + + {formik.isSubmitting ? "" : "Save"} + + + + )} + + ) + }} + + + ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/modals/LibraryDetailsModal.tsx b/gameyfin/src/main/frontend/components/general/modals/LibraryDetailsModal.tsx deleted file mode 100644 index 87b82f1..0000000 --- a/gameyfin/src/main/frontend/components/general/modals/LibraryDetailsModal.tsx +++ /dev/null @@ -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; - removeLibrary: (library: LibraryDto) => Promise; -} - -export default function LibraryDetailsModal({ - library, - isOpen, - onOpenChange, - updateLibrary, - removeLibrary - }: LibraryDetailsModalProps) { - return ( - - - {(onClose) => { - async function update(values: LibraryUpdateDto) { - await updateLibrary(values); - onClose(); - } - - async function remove(library: LibraryDto) { - await removeLibrary(library); - onClose(); - } - - return ( - update(values)} - > - {(formik: { isSubmitting: any; }) => ( - - - Edit library - - - - - - remove(library)} color="danger"> - Delete this library - - - - - Cancel - - - {formik.isSubmitting ? "" : "Save"} - - - - )} - - ) - }} - - - ); -} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/modals/MatchGameModal.tsx b/gameyfin/src/main/frontend/components/general/modals/MatchGameModal.tsx new file mode 100644 index 0000000..4224437 --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/modals/MatchGameModal.tsx @@ -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([]); + 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 ( + + + + + + + + + + + + {searchResults.length === 0 ? + No results found. : + + {searchResults.map((result, index) => ( + + {result.title} ({new Date(result.release).getFullYear()}) + {Object.keys(result.originalIds) + .map(pluginId => ) + } + + ))} + + } + + + + + ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/modals/PathPickerModal.tsx b/gameyfin/src/main/frontend/components/general/modals/PathPickerModal.tsx index 2529a95..4a185dd 100644 --- a/gameyfin/src/main/frontend/components/general/modals/PathPickerModal.tsx +++ b/gameyfin/src/main/frontend/components/general/modals/PathPickerModal.tsx @@ -63,7 +63,7 @@ export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChang {formik.isSubmitting ? "" : "Select"} diff --git a/gameyfin/src/main/frontend/components/general/plugin/PluginIcon.tsx b/gameyfin/src/main/frontend/components/general/plugin/PluginIcon.tsx new file mode 100644 index 0000000..b203af4 --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/plugin/PluginIcon.tsx @@ -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 && ( + + {state.state[pluginId].hasLogo ? + : + + } + + ) +} \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt index 3d5496d..c4d22a0 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/download/DownloadEndpoint.kt @@ -21,6 +21,7 @@ class DownloadEndpoint( @RequestParam provider: String ): ResponseEntity { val game = gameService.getById(gameId) + gameService.incrementDownloadCount(game) val download = downloadService.getDownload(game.metadata.path, provider) return when (download) { diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameEndpoint.kt index ca27896..e40fa91 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameEndpoint.kt @@ -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 { + return gameService.getPotentialMatches(searchTerm) + } } \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt index 21bcdcb..4299b74 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt @@ -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 { + // 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>): GameSearchResultDto { + val sorted = group.sortedByDescending { (provider, _) -> pluginPriority(provider) } + + fun pick(selector: (PluginApiMetadata) -> T?): T? = sorted.firstNotNullOfOrNull { selector(it.second) } + fun pickList(selector: (PluginApiMetadata) -> List?): List? = + sorted.mapNotNull { selector(it.second) }.firstOrNull { it.isNotEmpty() } + + // Collect originalIds for this group + val originalIds: Map = 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!! ) } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameFieldMetadataDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameFieldMetadataDto.kt index e3664cc..284748b 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameFieldMetadataDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameFieldMetadataDto.kt @@ -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 ) diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameSearchResultDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameSearchResultDto.kt new file mode 100644 index 0000000..667f796 --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameSearchResultDto.kt @@ -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?, + val developers: Collection?, + val originalIds: Map +) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/GameFieldMetadata.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/GameFieldMetadata.kt index ab97685..69a432a 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/GameFieldMetadata.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/GameFieldMetadata.kt @@ -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()
No results found.
{result.title} ({new Date(result.release).getFullYear()})