mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +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 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=" "
|
||||||
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user