mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
(WIP) Implement manual matching of game files
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user