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 {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 (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<Formik initialValues={{name: "", path: selectedPath}}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
>
{(formik) => {
useEffect(() => {
formik.setFieldValue("path", selectedPath);
}, [selectedPath]);
<>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<Formik initialValues={{name: "", directories: []}}
validationSchema={Yup.object({
name: Yup.string()
.required("Library name is required")
.max(255, "Library name must be 255 characters or less"),
directories: Yup.array()
.of(Yup.string())
.min(1, "At least one directory is required")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
>
{(formik) => {
function addDirectory(directory: string) {
formik.setFieldValue("directories", [...formik.values.directories, directory]);
}
return (
<Form>
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-2">
<Input
name="name"
label="Library Name"
placeholder="Enter library name"
value={formik.values.name}
required
/>
<Input
name="path"
label="Library Path"
placeholder="Select a path"
value={formik.values.path}
isDisabled
required
/>
</div>
<div className="h-64 overflow-auto">
<FileTreeView onPathChange={setSelectedPath}/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</ModalFooter>
</Form>
);
}}
</Formik>
)}
</ModalContent>
</Modal>
function removeDirectory(directory: string) {
formik.setFieldValue("directories", formik.values.directories.filter((d: string) => d !== directory));
}
return (
<Form>
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-2">
<Input
name="name"
label="Library Name"
placeholder="Enter library name"
value={formik.values.name}
required
/>
<div className="flex flex-row justify-between items-center">
<p className="font-bold">Directories</p>
<Button isIconOnly variant="light" size="sm" color="default"
onPress={pathPickerModal.onOpen}>
<Plus/>
</Button>
</div>
{formik.values.directories.map((directory: string) => (
<Code className="flex flex-row justify-between items-center">
{directory}
<Button isIconOnly variant="light" size="sm" color="default"
onPress={() => removeDirectory(directory)}>
<Minus/>
</Button>
</Code>
))}
<div className="min-h-6 text-danger">
{(() => {
const meta = formik.getFieldMeta("directories");
return meta.touched && meta.error && (
<SmallInfoField icon={XCircle} message={meta.error}/>
);
})()}
</div>
</div>
</ModalBody>
<ModalFooter>
<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 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<GameDto>();
@@ -74,8 +72,6 @@ export default function TestView() {
</div>
{game && <GameOverviewCard game={game}></GameOverviewCard>}
{game && <>{JSON.stringify(game, null, 2)}</>}
<pre>{selectedPath}</pre>
<FileTreeView onPathChange={setSelectedPath}/>
</div>
</div>
);
@@ -11,9 +11,9 @@ class Library(
val name: String,
@Column(unique = true)
val path: String,
@ElementCollection(fetch = FetchType.EAGER)
val directories: Set<String>,
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
@ManyToMany(cascade = [CascadeType.ALL])
val games: MutableSet<Game> = mutableSetOf()
)
@@ -3,6 +3,6 @@ package de.grimsi.gameyfin.libraries
data class LibraryDto(
val id: Long,
val name: String,
val path: String,
val directories: Set<String>,
val stats: LibraryStatsDto?
)
@@ -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<Path> {
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
)
}
}