mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Add support multiple source directories in a library
This commit is contained in:
@@ -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=" "
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user