mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 00:30:02 +00:00
Add external path to library directories
Add config option to include empty directories in scan
This commit is contained in:
@@ -68,6 +68,7 @@ function LibraryManagementLayout({getConfig, formik}: any) {
|
||||
|
||||
<Section title="Scanning"/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
|
||||
|
||||
<Section title="Metadata"/>
|
||||
|
||||
@@ -15,9 +15,10 @@ import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/Libr
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import PathPickerModal from "Frontend/components/general/modals/PathPickerModal";
|
||||
import {Minus, Plus, XCircle} from "@phosphor-icons/react";
|
||||
import {ArrowRight, Minus, Plus, XCircle} from "@phosphor-icons/react";
|
||||
import * as Yup from "yup";
|
||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
import DirectoryMappingDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/DirectoryMappingDto";
|
||||
|
||||
interface LibraryCreationModalProps {
|
||||
libraries: LibraryDto[];
|
||||
@@ -57,7 +58,7 @@ export default function LibraryCreationModal({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<Formik initialValues={{name: "", directories: []}}
|
||||
@@ -66,7 +67,7 @@ export default function LibraryCreationModal({
|
||||
.required("Library name is required")
|
||||
.max(255, "Library name must be 255 characters or less"),
|
||||
directories: Yup.array()
|
||||
.of(Yup.string())
|
||||
.of(Yup.object())
|
||||
.min(1, "At least one directory is required")
|
||||
})}
|
||||
isInitialValid={false}
|
||||
@@ -76,12 +77,12 @@ export default function LibraryCreationModal({
|
||||
}}
|
||||
>
|
||||
{(formik) => {
|
||||
function addDirectory(directory: string) {
|
||||
function addDirectoryMapping(directory: DirectoryMappingDto) {
|
||||
formik.setFieldValue("directories", [...formik.values.directories, directory]);
|
||||
}
|
||||
|
||||
function removeDirectory(directory: string) {
|
||||
formik.setFieldValue("directories", formik.values.directories.filter((d: string) => d !== directory));
|
||||
function removeDirectoryMapping(directory: DirectoryMappingDto) {
|
||||
formik.setFieldValue("directories", formik.values.directories.filter((d: DirectoryMappingDto) => d.internalPath !== directory.internalPath));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -103,11 +104,38 @@ export default function LibraryCreationModal({
|
||||
<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)}>
|
||||
{formik.values.directories.map((directory: DirectoryMappingDto) => (
|
||||
<Code
|
||||
className="w-full flex items-center gap-2 overflow-hidden px-2 py-1"
|
||||
key={directory.internalPath}>
|
||||
<input
|
||||
type="text"
|
||||
value={directory.internalPath}
|
||||
readOnly
|
||||
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
|
||||
/>
|
||||
{directory.externalPath && (
|
||||
<>
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center justify-center mx-2">
|
||||
<ArrowRight size={20}/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={directory.externalPath}
|
||||
readOnly
|
||||
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="default"
|
||||
onPress={() => removeDirectoryMapping(directory)}
|
||||
className="ml-2"
|
||||
>
|
||||
<Minus/>
|
||||
</Button>
|
||||
</Code>
|
||||
@@ -134,7 +162,7 @@ export default function LibraryCreationModal({
|
||||
{formik.isSubmitting ? "" : "Add"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
<PathPickerModal returnSelectedPath={addDirectory}
|
||||
<PathPickerModal returnSelectedPath={addDirectoryMapping}
|
||||
isOpen={pathPickerModal.isOpen}
|
||||
onOpenChange={pathPickerModal.onOpenChange}/>
|
||||
</Form>
|
||||
|
||||
@@ -3,45 +3,58 @@ 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";
|
||||
import DirectoryMappingDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/DirectoryMappingDto";
|
||||
import {ArrowRight} from "@phosphor-icons/react";
|
||||
|
||||
interface PathPickerModalProps {
|
||||
returnSelectedPath: (path: string) => void;
|
||||
returnSelectedPath: (path: DirectoryMappingDto) => void;
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChange}: PathPickerModalProps) {
|
||||
const [currentlySelectedPath, setCurrentlySelectedPath] = useState("");
|
||||
const [internalPath, setInternalPath] = useState("");
|
||||
const [externalPath, setExternalPath] = useState("");
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<Formik initialValues={{path: currentlySelectedPath}}
|
||||
onSubmit={(values: any) => {
|
||||
returnSelectedPath(values.path);
|
||||
setCurrentlySelectedPath("");
|
||||
<Formik initialValues={{internalPath: internalPath, externalPath: externalPath}}
|
||||
onSubmit={(values: DirectoryMappingDto) => {
|
||||
returnSelectedPath(values);
|
||||
setInternalPath("");
|
||||
setExternalPath("");
|
||||
onClose();
|
||||
}}>
|
||||
{(formik) => {
|
||||
useEffect(() => {
|
||||
formik.setFieldValue("path", currentlySelectedPath);
|
||||
}, [currentlySelectedPath]);
|
||||
formik.setFieldValue("internalPath", internalPath);
|
||||
}, [internalPath]);
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
|
||||
<ModalHeader className="flex flex-col gap-1">Select a folder</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input
|
||||
name="path"
|
||||
label="Selected directory"
|
||||
placeholder=" "
|
||||
value={formik.values.path}
|
||||
isDisabled
|
||||
required
|
||||
/>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Input
|
||||
name="internalPath"
|
||||
label="Selected directory"
|
||||
placeholder=" "
|
||||
value={formik.values.internalPath}
|
||||
isDisabled
|
||||
required
|
||||
/>
|
||||
<ArrowRight className="mb-8"/>
|
||||
<Input
|
||||
name="externalPath"
|
||||
label="External path (optional)"
|
||||
placeholder=" "
|
||||
value={formik.values.externalPath}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-64 overflow-auto">
|
||||
<FileTreeView onPathChange={setCurrentlySelectedPath}/>
|
||||
<FileTreeView onPathChange={setInternalPath}/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -29,6 +29,13 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
true
|
||||
)
|
||||
|
||||
data object ScanEmptyDirectories : ConfigProperties<Boolean>(
|
||||
Boolean::class,
|
||||
"library.scan.scan-empty-directories",
|
||||
"Scan empty directories",
|
||||
false
|
||||
)
|
||||
|
||||
data object GameFileExtensions : ConfigProperties<Array<String>>(
|
||||
Array<String>::class,
|
||||
"library.scan.game-file-extensions",
|
||||
@@ -237,7 +244,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
LogLevel::class,
|
||||
"logs.level.root",
|
||||
"Log level (Root)",
|
||||
LogLevel.INFO,
|
||||
LogLevel.WARN,
|
||||
LogLevel.entries
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class FilesystemService(
|
||||
val gamefileExtensions = gameFileExtensions
|
||||
|
||||
// Filter out invalid directories (directories could have been changed externally after the library was created)
|
||||
val validDirectories = library.directories.map { Path(it) }
|
||||
val validDirectories = library.directories.map { Path(it.internalPath) }
|
||||
.filter { path ->
|
||||
if (!path.isDirectory()) {
|
||||
log.warn { "Invalid directory '$path' in library '${library.name}'" }
|
||||
@@ -92,9 +92,19 @@ class FilesystemService(
|
||||
}
|
||||
|
||||
// Get all paths that are directories or match the game file extensions
|
||||
// Also check if the directory is empty and if empty directories should be included
|
||||
val currentFilesystemPaths = validDirectories.flatMap { validDirectory ->
|
||||
safeReadDirectoryContents(validDirectory)
|
||||
.filter { it.isDirectory() || it.extension.lowercase() in gamefileExtensions }
|
||||
.filter {
|
||||
val contents = safeReadDirectoryContents(it)
|
||||
if (contents.isEmpty() && !config.get(ConfigProperties.Libraries.Scan.ScanEmptyDirectories)!!) {
|
||||
log.debug { "Directory '$it' is empty and will be ignored" }
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all paths already in the library as game files or as unmatched paths
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.grimsi.gameyfin.libraries
|
||||
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
|
||||
@Entity
|
||||
class DirectoryMapping(
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
var id: Long? = null,
|
||||
|
||||
var internalPath: String,
|
||||
|
||||
var externalPath: String? = null,
|
||||
)
|
||||
@@ -11,12 +11,12 @@ class Library(
|
||||
|
||||
var name: String,
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
var directories: MutableSet<String> = HashSet<String>(),
|
||||
@OneToMany(fetch = FetchType.EAGER, orphanRemoval = true, cascade = [CascadeType.ALL])
|
||||
var directories: MutableSet<DirectoryMapping> = HashSet(),
|
||||
|
||||
@OneToMany(fetch = FetchType.EAGER, orphanRemoval = true)
|
||||
var games: MutableSet<Game> = HashSet<Game>(),
|
||||
var games: MutableSet<Game> = HashSet(),
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
var unmatchedPaths: MutableSet<String> = HashSet<String>()
|
||||
var unmatchedPaths: MutableSet<String> = HashSet()
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import de.grimsi.gameyfin.games.GameService
|
||||
import de.grimsi.gameyfin.games.dto.GameDto
|
||||
import de.grimsi.gameyfin.games.entities.Game
|
||||
import de.grimsi.gameyfin.games.toDto
|
||||
import de.grimsi.gameyfin.libraries.dto.DirectoryMappingDto
|
||||
import de.grimsi.gameyfin.libraries.dto.LibraryDto
|
||||
import de.grimsi.gameyfin.libraries.dto.LibraryStatsDto
|
||||
import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto
|
||||
@@ -56,7 +57,11 @@ class LibraryService(
|
||||
|
||||
// Update only non-null fields
|
||||
libraryDto.name?.let { existingLibrary.name = it }
|
||||
libraryDto.directories?.let { existingLibrary.directories = it.toMutableSet() }
|
||||
libraryDto.directories?.let {
|
||||
existingLibrary.directories = it
|
||||
.map { d -> DirectoryMapping(internalPath = d.internalPath, externalPath = d.externalPath) }
|
||||
.toMutableSet()
|
||||
}
|
||||
|
||||
val updatedLibrary = libraryRepository.save(existingLibrary)
|
||||
return toDto(updatedLibrary)
|
||||
@@ -300,7 +305,7 @@ class LibraryService(
|
||||
return LibraryDto(
|
||||
id = libraryId,
|
||||
name = library.name,
|
||||
directories = library.directories,
|
||||
directories = library.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
|
||||
stats = statsDto
|
||||
)
|
||||
}
|
||||
@@ -314,7 +319,9 @@ class LibraryService(
|
||||
private fun toEntity(library: LibraryDto): Library {
|
||||
return libraryRepository.findByIdOrNull(library.id) ?: Library(
|
||||
name = library.name,
|
||||
directories = library.directories.toMutableSet()
|
||||
directories = library.directories.map {
|
||||
DirectoryMapping(internalPath = it.internalPath, externalPath = it.externalPath)
|
||||
}.toMutableSet()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.grimsi.gameyfin.libraries.dto
|
||||
|
||||
data class DirectoryMappingDto(
|
||||
val internalPath: String,
|
||||
val externalPath: String? = null,
|
||||
)
|
||||
@@ -3,6 +3,6 @@ package de.grimsi.gameyfin.libraries.dto
|
||||
data class LibraryDto(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val directories: Set<String>,
|
||||
val directories: List<DirectoryMappingDto>,
|
||||
val stats: LibraryStatsDto?
|
||||
)
|
||||
@@ -3,5 +3,5 @@ package de.grimsi.gameyfin.libraries.dto
|
||||
data class LibraryUpdateDto(
|
||||
val id: Long,
|
||||
val name: String? = null,
|
||||
val directories: Set<String>? = null,
|
||||
val directories: Set<DirectoryMappingDto>? = null,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
logging.level:
|
||||
root: info
|
||||
de.grimsi.gameyfin.GameyfinApplicationKt: info
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:file:./db/${spring.datasource.db-name};AUTO_SERVER=TRUE
|
||||
@@ -1,5 +1,8 @@
|
||||
logging.level:
|
||||
root: warn
|
||||
org.atmosphere: warn
|
||||
de.grimsi.gameyfin: info
|
||||
de.grimsi.gameyfin.GameyfinApplicationKt: warn
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
@@ -8,6 +11,8 @@ server:
|
||||
tracking-modes: cookie
|
||||
|
||||
management:
|
||||
server:
|
||||
port: 8081
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
|
||||
Reference in New Issue
Block a user