diff --git a/gameyfin/src/main/frontend/components/general/SearchBar.tsx b/gameyfin/src/main/frontend/components/general/SearchBar.tsx
index ed90002..b160046 100644
--- a/gameyfin/src/main/frontend/components/general/SearchBar.tsx
+++ b/gameyfin/src/main/frontend/components/general/SearchBar.tsx
@@ -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"
},
}}
diff --git a/gameyfin/src/main/frontend/components/general/library/LibraryManagementGames.tsx b/gameyfin/src/main/frontend/components/general/library/LibraryManagementGames.tsx
index ba8bb3c..1b841bb 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,
+ 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
(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
+ return selectedGame &&
Manage games in library
-
+
+ setSearchTerm(e.target.value)}
+ onClear={() => setSearchTerm("")}
+ />
Show only non confirmed
-
+ ;
diff --git a/gameyfin/src/main/frontend/components/general/library/LibraryManagementUnmatchedPaths.tsx b/gameyfin/src/main/frontend/components/general/library/LibraryManagementUnmatchedPaths.tsx
index b0f29d2..aafc18c 100644
--- a/gameyfin/src/main/frontend/components/general/library/LibraryManagementUnmatchedPaths.tsx
+++ b/gameyfin/src/main/frontend/components/general/library/LibraryManagementUnmatchedPaths.tsx
@@ -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
Manage unmatched paths
+
setSearchTerm(e.target.value)}
+ onClear={() => setSearchTerm("")}
+ />
@@ -68,14 +87,29 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
{item.path}
- deleteUnmatchedPath(item.path)}>
-
+
+ {
+ setSelectedPath(item.path);
+ matchGameModal.onOpenChange();
+ }}>
+
+
+
+
+ deleteUnmatchedPath(item.path)}>
+
+
)}
+ {selectedPath &&
+ }
;
-}
-type UnmatchedPathItem = { key: number; path: string };
\ No newline at end of file
+}
\ 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
index 4224437..f9ba3f3 100644
--- a/gameyfin/src/main/frontend/components/general/modals/MatchGameModal.tsx
+++ b/gameyfin/src/main/frontend/components/general/modals/MatchGameModal.tsx
@@ -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([]);
- const [isLoading, setIsLoading] = useState(false);
+ const [isSearching, setIsSearching] = useState(false);
+ const [isMatching, setIsMatching] = useState(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 (
-
+
-
-
-
-
-
-
-
+ {(onClose) => (
+
+
+
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ await search();
+ }
+ }}
+ />
+
+
+
+
-
- {searchResults.length === 0 ?
-
No results found.
:
-
- {searchResults.map((result, index) => (
-
-
{result.title} ({new Date(result.release).getFullYear()})
- {Object.keys(result.originalIds)
- .map(pluginId =>
)
- }
-
- ))}
-
- }
-
-
+
+
+
+ Title & Release
+ Developer(s)
+ Publisher(s)
+ {/* width={1} keeps the column as far to the right as possible*/}
+ Sources
+
+
+
+ {(item) => (
+
+
+ {item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
+
+
+
+ {item.developers ? item.developers.map(
+ developer =>
{developer}
+ ) : "unknown"}
+
+
+
+
+ {item.publishers ? item.publishers.map(
+ publisher =>
{publisher}
+ ) : "unknown"}
+
+
+
+
+ {Object.values(item.originalIds).map(
+ originalId =>
+ )}
+
+
+
+
+ {
+ setIsMatching(item.id);
+ await matchGame(item);
+ setIsMatching(null);
+ onClose();
+ }}>
+
+
+
+
+
+ )}
+
+
+
+
+ )}
);
diff --git a/gameyfin/src/main/frontend/components/general/plugin/PluginIcon.tsx b/gameyfin/src/main/frontend/components/general/plugin/PluginIcon.tsx
index b203af4..ec76dd2 100644
--- a/gameyfin/src/main/frontend/components/general/plugin/PluginIcon.tsx
+++ b/gameyfin/src/main/frontend/components/general/plugin/PluginIcon.tsx
@@ -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 && (
{state.state[pluginId].hasLogo ?
diff --git a/gameyfin/src/main/frontend/components/general/withSideMenu.tsx b/gameyfin/src/main/frontend/components/general/withSideMenu.tsx
index 3c00e74..05e1f7e 100644
--- a/gameyfin/src/main/frontend/components/general/withSideMenu.tsx
+++ b/gameyfin/src/main/frontend/components/general/withSideMenu.tsx
@@ -43,7 +43,7 @@ export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
return (
-
+
{menuItems.map((i) => (
setSelectedItem(i.url)}
@@ -53,7 +53,7 @@ export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
))}
-
diff --git a/gameyfin/src/main/frontend/util/utils.ts b/gameyfin/src/main/frontend/util/utils.ts
index 1547dcc..5aac492 100644
--- a/gameyfin/src/main/frontend/util/utils.ts
+++ b/gameyfin/src/main/frontend/util/utils.ts
@@ -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
(initial: T, current: T): Partial {
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);
}
\ No newline at end of file
diff --git a/gameyfin/src/main/frontend/views/GameView.tsx b/gameyfin/src/main/frontend/views/GameView.tsx
index 28826ef..04e58b8 100644
--- a/gameyfin/src/main/frontend/views/GameView.tsx
+++ b/gameyfin/src/main/frontend/views/GameView.tsx
@@ -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() {
- {downloadOptions && }
+
+ {isAdmin(auth) &&
+
+
+
+ }
+ {downloadOptions && }
+
@@ -181,6 +195,12 @@ export default function GameView() {
+
);
}
\ No newline at end of file
diff --git a/gameyfin/src/main/frontend/views/MainLayout.tsx b/gameyfin/src/main/frontend/views/MainLayout.tsx
index a014f66..8c02d61 100644
--- a/gameyfin/src/main/frontend/views/MainLayout.tsx
+++ b/gameyfin/src/main/frontend/views/MainLayout.tsx
@@ -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() {
}
- {auth.state.user?.roles?.some(a => a?.includes("ADMIN")) &&
+ {isAdmin(auth) &&
diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt
index 0c5dea9..36427bb 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt
@@ -209,6 +209,12 @@ class GameyfinPluginManager(
.map { it.simpleName }
}
+ fun getPluginForExtension(extensionClass: Class): 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")
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 e40fa91..05f599b 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameEndpoint.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameEndpoint.kt
@@ -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 {
return gameService.getPotentialMatches(searchTerm)
}
+
+ @RolesAllowed(Role.Names.ADMIN)
+ fun matchManually(originalIds: Map, 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)
+ }
+ }
}
\ 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 6c0a381..7b08d5c 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt
@@ -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): List {
val gamesToBePersisted = games.filter { it.id == null }
@@ -121,12 +147,10 @@ class GameService(
}
fun getPotentialMatches(searchTerm: String): List {
- // 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 = group
+ val originalIds: Map = 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,
+ 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): List {
- return gameRepository.findAllByMetadata_PathIn(paths)
- }
-
fun getById(id: Long): Game {
return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found")
}
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
index 667f796..4654ffc 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameSearchResultDto.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameSearchResultDto.kt
@@ -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?,
val developers: Collection?,
- val originalIds: Map
-)
\ No newline at end of file
+ val originalIds: Map
+)
+
+class OriginalIdDto(
+ val pluginId: String,
+ val originalId: String,
+) {
+ override fun toString(): String {
+ return "$pluginId:$originalId"
+ }
+}
\ No newline at end of file
diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt
index 59a80b8..f03124f 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt
@@ -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)
diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/Library.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/Library.kt
index b1e0ca1..c4e78dd 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/Library.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/Library.kt
@@ -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,
diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt
index 8aac724..b005f49 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt
@@ -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, library: Library): Library {
+ fun addGamesToLibrary(games: Collection, 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
}