From c15c15da93abf166413691483e808c8a2e92d54f Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Sun, 6 Apr 2025 19:48:54 +0200 Subject: [PATCH] Add support multiple source directories in a library --- .../general/modals/LibraryCreationModal.tsx | 166 +++++++++++------- .../general/modals/PathPickerModal.tsx | 66 +++++++ gameyfin/src/main/frontend/views/TestView.tsx | 4 - .../de/grimsi/gameyfin/libraries/Library.kt | 6 +- .../grimsi/gameyfin/libraries/LibraryDto.kt | 2 +- .../gameyfin/libraries/LibraryService.kt | 28 ++- 6 files changed, 195 insertions(+), 77 deletions(-) create mode 100644 gameyfin/src/main/frontend/components/general/modals/PathPickerModal.tsx diff --git a/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx b/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx index 5f2c62a..3c6442f 100644 --- a/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx +++ b/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx @@ -1,10 +1,23 @@ -import React, {useEffect, useState} from "react"; -import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; +import React from "react"; +import { + addToast, + Button, + Code, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + useDisclosure +} from "@heroui/react"; import {Form, Formik} from "formik"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/LibraryDto"; import {LibraryEndpoint} from "Frontend/generated/endpoints"; import Input from "Frontend/components/general/input/Input"; -import FileTreeView from "Frontend/components/general/input/FileTreeView"; +import PathPickerModal from "Frontend/components/general/modals/PathPickerModal"; +import {Minus, Plus, XCircle} from "@phosphor-icons/react"; +import * as Yup from "yup"; +import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; interface LibraryCreationModalProps { libraries: LibraryDto[]; @@ -19,7 +32,7 @@ export default function LibraryCreationModal({ isOpen, onOpenChange }: LibraryCreationModalProps) { - const [selectedPath, setSelectedPath] = useState(""); + const pathPickerModal = useDisclosure(); async function createLibrary(library: LibraryDto) { try { @@ -43,63 +56,94 @@ export default function LibraryCreationModal({ } return ( - - - {(onClose) => ( - { - await createLibrary(values); - onClose(); - }} - > - {(formik) => { - useEffect(() => { - formik.setFieldValue("path", selectedPath); - }, [selectedPath]); + <> + + + {(onClose) => ( + { + await createLibrary(values); + onClose(); + }} + > + {(formik) => { + function addDirectory(directory: string) { + formik.setFieldValue("directories", [...formik.values.directories, directory]); + } - return ( -
- Add a new library - -
- - -
-
- -
-
- - - - -
- ); - }} -
- )} -
-
+ function removeDirectory(directory: string) { + formik.setFieldValue("directories", formik.values.directories.filter((d: string) => d !== directory)); + } + + return ( +
+ Add a new library + +
+ +
+

Directories

+ +
+ {formik.values.directories.map((directory: string) => ( + + {directory} + + + ))} +
+ {(() => { + const meta = formik.getFieldMeta("directories"); + return meta.touched && meta.error && ( + + ); + })()} +
+
+
+ + + + + + + ); + }} +
+ )} +
+
+ ); } \ 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 new file mode 100644 index 0000000..76340ae --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/modals/PathPickerModal.tsx @@ -0,0 +1,66 @@ +import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; +import {Form, Formik} from "formik"; +import React, {useEffect, useState} from "react"; +import Input from "Frontend/components/general/input/Input"; +import FileTreeView from "Frontend/components/general/input/FileTreeView"; + +interface PathPickerModalProps { + returnSelectedPath: (path: string) => void; + isOpen: boolean; + onOpenChange: () => void; +} + +export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChange}: PathPickerModalProps) { + const [currentlySelectedPath, setCurrentlySelectedPath] = useState(""); + + return ( + + + {(onClose) => ( + { + returnSelectedPath(values.path); + onClose(); + }}> + {(formik) => { + useEffect(() => { + formik.setFieldValue("path", currentlySelectedPath); + }, [currentlySelectedPath]); + + return ( +
+ Add a new library + + +
+ +
+
+ + + + +
+ ); + }} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/views/TestView.tsx b/gameyfin/src/main/frontend/views/TestView.tsx index f6ac3b8..762e464 100644 --- a/gameyfin/src/main/frontend/views/TestView.tsx +++ b/gameyfin/src/main/frontend/views/TestView.tsx @@ -4,10 +4,8 @@ import {LibraryEndpoint, SystemEndpoint} from "Frontend/generated/endpoints"; import {useState} from "react"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; import {GameOverviewCard} from "Frontend/components/general/cards/GameOverviewCard"; -import FileTreeView from "Frontend/components/general/input/FileTreeView"; export default function TestView() { - const [selectedPath, setSelectedPath] = useState(""); const [gameTitle, setGameTitle] = useState(""); const [game, setGame] = useState(); @@ -74,8 +72,6 @@ export default function TestView() { {game && } {game && <>{JSON.stringify(game, null, 2)}} -
{selectedPath}
- ); 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 88514b1..d9e8fdf 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/Library.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/Library.kt @@ -11,9 +11,9 @@ class Library( val name: String, - @Column(unique = true) - val path: String, + @ElementCollection(fetch = FetchType.EAGER) + val directories: Set, - @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true) + @ManyToMany(cascade = [CascadeType.ALL]) val games: MutableSet = mutableSetOf() ) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryDto.kt index 9f5aa82..b62e8cd 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryDto.kt @@ -3,6 +3,6 @@ package de.grimsi.gameyfin.libraries data class LibraryDto( val id: Long, val name: String, - val path: String, + val directories: Set, val stats: LibraryStatsDto? ) \ No newline at end of file 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 71c060b..f17235e 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt @@ -4,6 +4,7 @@ import de.grimsi.gameyfin.config.ConfigProperties import de.grimsi.gameyfin.config.ConfigService import de.grimsi.gameyfin.games.GameService import de.grimsi.gameyfin.games.dto.GameDto +import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -20,6 +21,8 @@ class LibraryService( private val config: ConfigService ) { + private val log = KotlinLogging.logger {} + fun test(testString: String): GameDto { val game = gameService.createFromFile(Path(testString)) @@ -76,12 +79,21 @@ class LibraryService( * Return a list of all subfolders and game files in the provided library */ fun scan(library: Library): List { - val folder = Path(library.path) - if (!folder.isDirectory()) throw IllegalArgumentException("The provided path is not a valid directory") - return folder - .listDirectoryEntries() - .filter { it.isDirectory() || it.isGameFile() } - .map { it.fileName } + val validDirectories = library.directories.map { Path(it) } + .filter { path -> + if (!path.isDirectory()) { + log.warn { "Invalid directory '$path' in library '${library.name}'" } + false + } else { + true + } + } + + return validDirectories.flatMap { directory -> + directory.listDirectoryEntries() + .filter { it.isDirectory() || it.isGameFile() } + .map { it.fileName } + } } private fun Path.isGameFile(): Boolean { @@ -104,7 +116,7 @@ class LibraryService( return LibraryDto( id = library.id, name = library.name, - path = library.path, + directories = library.directories, stats = statsDto ) } @@ -112,7 +124,7 @@ class LibraryService( private fun toEntity(library: LibraryDto): Library { return libraryRepository.findByIdOrNull(library.id) ?: Library( name = library.name, - path = library.path + directories = library.directories ) } } \ No newline at end of file