Add support multiple source directories in a library

This commit is contained in:
grimsi
2025-04-06 19:48:54 +02:00
parent cdab067df8
commit c15c15da93
6 changed files with 195 additions and 77 deletions
@@ -1,10 +1,23 @@
import React, {useEffect, useState} from "react"; import React from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; import {
addToast,
Button,
Code,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
useDisclosure
} from "@heroui/react";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/LibraryDto"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/LibraryDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
import Input from "Frontend/components/general/input/Input"; 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 { interface LibraryCreationModalProps {
libraries: LibraryDto[]; libraries: LibraryDto[];
@@ -19,7 +32,7 @@ export default function LibraryCreationModal({
isOpen, isOpen,
onOpenChange onOpenChange
}: LibraryCreationModalProps) { }: LibraryCreationModalProps) {
const [selectedPath, setSelectedPath] = useState(""); const pathPickerModal = useDisclosure();
async function createLibrary(library: LibraryDto) { async function createLibrary(library: LibraryDto) {
try { try {
@@ -43,63 +56,94 @@ export default function LibraryCreationModal({
} }
return ( return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg"> <>
<ModalContent> <Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
{(onClose) => ( <ModalContent>
<Formik initialValues={{name: "", path: selectedPath}} {(onClose) => (
onSubmit={async (values: any) => { <Formik initialValues={{name: "", directories: []}}
await createLibrary(values); validationSchema={Yup.object({
onClose(); name: Yup.string()
}} .required("Library name is required")
> .max(255, "Library name must be 255 characters or less"),
{(formik) => { directories: Yup.array()
useEffect(() => { .of(Yup.string())
formik.setFieldValue("path", selectedPath); .min(1, "At least one directory is required")
}, [selectedPath]); })}
isInitialValid={false}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
>
{(formik) => {
function addDirectory(directory: string) {
formik.setFieldValue("directories", [...formik.values.directories, directory]);
}
return ( function removeDirectory(directory: string) {
<Form> formik.setFieldValue("directories", formik.values.directories.filter((d: string) => d !== directory));
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader> }
<ModalBody>
<div className="flex flex-col gap-2"> return (
<Input <Form>
name="name" <ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
label="Library Name" <ModalBody>
placeholder="Enter library name" <div className="flex flex-col gap-2">
value={formik.values.name} <Input
required name="name"
/> label="Library Name"
<Input placeholder="Enter library name"
name="path" value={formik.values.name}
label="Library Path" required
placeholder="Select a path" />
value={formik.values.path} <div className="flex flex-row justify-between items-center">
isDisabled <p className="font-bold">Directories</p>
required <Button isIconOnly variant="light" size="sm" color="default"
/> onPress={pathPickerModal.onOpen}>
</div> <Plus/>
<div className="h-64 overflow-auto"> </Button>
<FileTreeView onPathChange={setSelectedPath}/> </div>
</div> {formik.values.directories.map((directory: string) => (
</ModalBody> <Code className="flex flex-row justify-between items-center">
<ModalFooter> {directory}
<Button variant="light" onPress={onClose}> <Button isIconOnly variant="light" size="sm" color="default"
Cancel onPress={() => removeDirectory(directory)}>
</Button> <Minus/>
<Button color="primary" </Button>
isLoading={formik.isSubmitting} </Code>
disabled={formik.isSubmitting} ))}
type="submit" <div className="min-h-6 text-danger">
> {(() => {
{formik.isSubmitting ? "" : "Add"} const meta = formik.getFieldMeta("directories");
</Button> return meta.touched && meta.error && (
</ModalFooter> <SmallInfoField icon={XCircle} message={meta.error}/>
</Form> );
); })()}
}} </div>
</Formik> </div>
)} </ModalBody>
</ModalContent> <ModalFooter>
</Modal> <Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</ModalFooter>
<PathPickerModal returnSelectedPath={addDirectory}
isOpen={pathPickerModal.isOpen}
onOpenChange={pathPickerModal.onOpenChange}/>
</Form>
);
}}
</Formik>
)}
</ModalContent>
</Modal>
</>
); );
} }
@@ -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 (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<Formik initialValues={{path: currentlySelectedPath}}
onSubmit={(values: any) => {
returnSelectedPath(values.path);
onClose();
}}>
{(formik) => {
useEffect(() => {
formik.setFieldValue("path", currentlySelectedPath);
}, [currentlySelectedPath]);
return (
<Form>
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
<ModalBody>
<Input
name="path"
label="Library Path"
placeholder="&nbsp;"
value={formik.values.path}
isDisabled
required
/>
<div className="h-64 overflow-auto">
<FileTreeView onPathChange={setCurrentlySelectedPath}/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Select"}
</Button>
</ModalFooter>
</Form>
);
}}
</Formik>
)}
</ModalContent>
</Modal>
)
}
@@ -4,10 +4,8 @@ import {LibraryEndpoint, SystemEndpoint} from "Frontend/generated/endpoints";
import {useState} from "react"; import {useState} from "react";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {GameOverviewCard} from "Frontend/components/general/cards/GameOverviewCard"; import {GameOverviewCard} from "Frontend/components/general/cards/GameOverviewCard";
import FileTreeView from "Frontend/components/general/input/FileTreeView";
export default function TestView() { export default function TestView() {
const [selectedPath, setSelectedPath] = useState("");
const [gameTitle, setGameTitle] = useState(""); const [gameTitle, setGameTitle] = useState("");
const [game, setGame] = useState<GameDto>(); const [game, setGame] = useState<GameDto>();
@@ -74,8 +72,6 @@ export default function TestView() {
</div> </div>
{game && <GameOverviewCard game={game}></GameOverviewCard>} {game && <GameOverviewCard game={game}></GameOverviewCard>}
{game && <>{JSON.stringify(game, null, 2)}</>} {game && <>{JSON.stringify(game, null, 2)}</>}
<pre>{selectedPath}</pre>
<FileTreeView onPathChange={setSelectedPath}/>
</div> </div>
</div> </div>
); );
@@ -11,9 +11,9 @@ class Library(
val name: String, val name: String,
@Column(unique = true) @ElementCollection(fetch = FetchType.EAGER)
val path: String, val directories: Set<String>,
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true) @ManyToMany(cascade = [CascadeType.ALL])
val games: MutableSet<Game> = mutableSetOf() val games: MutableSet<Game> = mutableSetOf()
) )
@@ -3,6 +3,6 @@ package de.grimsi.gameyfin.libraries
data class LibraryDto( data class LibraryDto(
val id: Long, val id: Long,
val name: String, val name: String,
val path: String, val directories: Set<String>,
val stats: LibraryStatsDto? val stats: LibraryStatsDto?
) )
@@ -4,6 +4,7 @@ import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.games.GameService import de.grimsi.gameyfin.games.GameService
import de.grimsi.gameyfin.games.dto.GameDto import de.grimsi.gameyfin.games.dto.GameDto
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -20,6 +21,8 @@ class LibraryService(
private val config: ConfigService private val config: ConfigService
) { ) {
private val log = KotlinLogging.logger {}
fun test(testString: String): GameDto { fun test(testString: String): GameDto {
val game = gameService.createFromFile(Path(testString)) 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 * Return a list of all subfolders and game files in the provided library
*/ */
fun scan(library: Library): List<Path> { fun scan(library: Library): List<Path> {
val folder = Path(library.path) val validDirectories = library.directories.map { Path(it) }
if (!folder.isDirectory()) throw IllegalArgumentException("The provided path is not a valid directory") .filter { path ->
return folder if (!path.isDirectory()) {
.listDirectoryEntries() log.warn { "Invalid directory '$path' in library '${library.name}'" }
.filter { it.isDirectory() || it.isGameFile() } false
.map { it.fileName } } else {
true
}
}
return validDirectories.flatMap { directory ->
directory.listDirectoryEntries()
.filter { it.isDirectory() || it.isGameFile() }
.map { it.fileName }
}
} }
private fun Path.isGameFile(): Boolean { private fun Path.isGameFile(): Boolean {
@@ -104,7 +116,7 @@ class LibraryService(
return LibraryDto( return LibraryDto(
id = library.id, id = library.id,
name = library.name, name = library.name,
path = library.path, directories = library.directories,
stats = statsDto stats = statsDto
) )
} }
@@ -112,7 +124,7 @@ class LibraryService(
private fun toEntity(library: LibraryDto): Library { private fun toEntity(library: LibraryDto): Library {
return libraryRepository.findByIdOrNull(library.id) ?: Library( return libraryRepository.findByIdOrNull(library.id) ?: Library(
name = library.name, name = library.name,
path = library.path directories = library.directories
) )
} }
} }