Add external path to library directories

Add config option to include empty directories in scan
This commit is contained in:
grimsi
2025-05-17 15:18:33 +02:00
parent 26f89a02b3
commit 9a467fd1ce
13 changed files with 141 additions and 42 deletions
@@ -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="&nbsp;"
value={formik.values.path}
isDisabled
required
/>
<div className="flex flex-row gap-2 items-center">
<Input
name="internalPath"
label="Selected directory"
placeholder="&nbsp;"
value={formik.values.internalPath}
isDisabled
required
/>
<ArrowRight className="mb-8"/>
<Input
name="externalPath"
label="External path (optional)"
placeholder="&nbsp;"
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: