mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +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"/>
|
<Section title="Scanning"/>
|
||||||
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
|
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
|
||||||
|
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
|
||||||
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
|
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
|
||||||
|
|
||||||
<Section title="Metadata"/>
|
<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 {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||||
import Input from "Frontend/components/general/input/Input";
|
import Input from "Frontend/components/general/input/Input";
|
||||||
import PathPickerModal from "Frontend/components/general/modals/PathPickerModal";
|
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 * as Yup from "yup";
|
||||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||||
|
import DirectoryMappingDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/DirectoryMappingDto";
|
||||||
|
|
||||||
interface LibraryCreationModalProps {
|
interface LibraryCreationModalProps {
|
||||||
libraries: LibraryDto[];
|
libraries: LibraryDto[];
|
||||||
@@ -57,7 +58,7 @@ export default function LibraryCreationModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
<Formik initialValues={{name: "", directories: []}}
|
<Formik initialValues={{name: "", directories: []}}
|
||||||
@@ -66,7 +67,7 @@ export default function LibraryCreationModal({
|
|||||||
.required("Library name is required")
|
.required("Library name is required")
|
||||||
.max(255, "Library name must be 255 characters or less"),
|
.max(255, "Library name must be 255 characters or less"),
|
||||||
directories: Yup.array()
|
directories: Yup.array()
|
||||||
.of(Yup.string())
|
.of(Yup.object())
|
||||||
.min(1, "At least one directory is required")
|
.min(1, "At least one directory is required")
|
||||||
})}
|
})}
|
||||||
isInitialValid={false}
|
isInitialValid={false}
|
||||||
@@ -76,12 +77,12 @@ export default function LibraryCreationModal({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(formik) => {
|
{(formik) => {
|
||||||
function addDirectory(directory: string) {
|
function addDirectoryMapping(directory: DirectoryMappingDto) {
|
||||||
formik.setFieldValue("directories", [...formik.values.directories, directory]);
|
formik.setFieldValue("directories", [...formik.values.directories, directory]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDirectory(directory: string) {
|
function removeDirectoryMapping(directory: DirectoryMappingDto) {
|
||||||
formik.setFieldValue("directories", formik.values.directories.filter((d: string) => d !== directory));
|
formik.setFieldValue("directories", formik.values.directories.filter((d: DirectoryMappingDto) => d.internalPath !== directory.internalPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -103,11 +104,38 @@ export default function LibraryCreationModal({
|
|||||||
<Plus/>
|
<Plus/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{formik.values.directories.map((directory: string) => (
|
{formik.values.directories.map((directory: DirectoryMappingDto) => (
|
||||||
<Code className="flex flex-row justify-between items-center">
|
<Code
|
||||||
{directory}
|
className="w-full flex items-center gap-2 overflow-hidden px-2 py-1"
|
||||||
<Button isIconOnly variant="light" size="sm" color="default"
|
key={directory.internalPath}>
|
||||||
onPress={() => removeDirectory(directory)}>
|
<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/>
|
<Minus/>
|
||||||
</Button>
|
</Button>
|
||||||
</Code>
|
</Code>
|
||||||
@@ -134,7 +162,7 @@ export default function LibraryCreationModal({
|
|||||||
{formik.isSubmitting ? "" : "Add"}
|
{formik.isSubmitting ? "" : "Add"}
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
<PathPickerModal returnSelectedPath={addDirectory}
|
<PathPickerModal returnSelectedPath={addDirectoryMapping}
|
||||||
isOpen={pathPickerModal.isOpen}
|
isOpen={pathPickerModal.isOpen}
|
||||||
onOpenChange={pathPickerModal.onOpenChange}/>
|
onOpenChange={pathPickerModal.onOpenChange}/>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -3,45 +3,58 @@ import {Form, Formik} from "formik";
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
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 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 {
|
interface PathPickerModalProps {
|
||||||
returnSelectedPath: (path: string) => void;
|
returnSelectedPath: (path: DirectoryMappingDto) => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: () => void;
|
onOpenChange: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChange}: PathPickerModalProps) {
|
export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChange}: PathPickerModalProps) {
|
||||||
const [currentlySelectedPath, setCurrentlySelectedPath] = useState("");
|
const [internalPath, setInternalPath] = useState("");
|
||||||
|
const [externalPath, setExternalPath] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
<Formik initialValues={{path: currentlySelectedPath}}
|
<Formik initialValues={{internalPath: internalPath, externalPath: externalPath}}
|
||||||
onSubmit={(values: any) => {
|
onSubmit={(values: DirectoryMappingDto) => {
|
||||||
returnSelectedPath(values.path);
|
returnSelectedPath(values);
|
||||||
setCurrentlySelectedPath("");
|
setInternalPath("");
|
||||||
|
setExternalPath("");
|
||||||
onClose();
|
onClose();
|
||||||
}}>
|
}}>
|
||||||
{(formik) => {
|
{(formik) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formik.setFieldValue("path", currentlySelectedPath);
|
formik.setFieldValue("internalPath", internalPath);
|
||||||
}, [currentlySelectedPath]);
|
}, [internalPath]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form>
|
<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>
|
<ModalBody>
|
||||||
<Input
|
<div className="flex flex-row gap-2 items-center">
|
||||||
name="path"
|
<Input
|
||||||
label="Selected directory"
|
name="internalPath"
|
||||||
placeholder=" "
|
label="Selected directory"
|
||||||
value={formik.values.path}
|
placeholder=" "
|
||||||
isDisabled
|
value={formik.values.internalPath}
|
||||||
required
|
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">
|
<div className="h-64 overflow-auto">
|
||||||
<FileTreeView onPathChange={setCurrentlySelectedPath}/>
|
<FileTreeView onPathChange={setInternalPath}/>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ sealed class ConfigProperties<T : Serializable>(
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data object ScanEmptyDirectories : ConfigProperties<Boolean>(
|
||||||
|
Boolean::class,
|
||||||
|
"library.scan.scan-empty-directories",
|
||||||
|
"Scan empty directories",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
data object GameFileExtensions : ConfigProperties<Array<String>>(
|
data object GameFileExtensions : ConfigProperties<Array<String>>(
|
||||||
Array<String>::class,
|
Array<String>::class,
|
||||||
"library.scan.game-file-extensions",
|
"library.scan.game-file-extensions",
|
||||||
@@ -237,7 +244,7 @@ sealed class ConfigProperties<T : Serializable>(
|
|||||||
LogLevel::class,
|
LogLevel::class,
|
||||||
"logs.level.root",
|
"logs.level.root",
|
||||||
"Log level (Root)",
|
"Log level (Root)",
|
||||||
LogLevel.INFO,
|
LogLevel.WARN,
|
||||||
LogLevel.entries
|
LogLevel.entries
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class FilesystemService(
|
|||||||
val gamefileExtensions = gameFileExtensions
|
val gamefileExtensions = gameFileExtensions
|
||||||
|
|
||||||
// Filter out invalid directories (directories could have been changed externally after the library was created)
|
// 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 ->
|
.filter { path ->
|
||||||
if (!path.isDirectory()) {
|
if (!path.isDirectory()) {
|
||||||
log.warn { "Invalid directory '$path' in library '${library.name}'" }
|
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
|
// 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 ->
|
val currentFilesystemPaths = validDirectories.flatMap { validDirectory ->
|
||||||
safeReadDirectoryContents(validDirectory)
|
safeReadDirectoryContents(validDirectory)
|
||||||
.filter { it.isDirectory() || it.extension.lowercase() in gamefileExtensions }
|
.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
|
// 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,
|
var name: String,
|
||||||
|
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
@OneToMany(fetch = FetchType.EAGER, orphanRemoval = true, cascade = [CascadeType.ALL])
|
||||||
var directories: MutableSet<String> = HashSet<String>(),
|
var directories: MutableSet<DirectoryMapping> = HashSet(),
|
||||||
|
|
||||||
@OneToMany(fetch = FetchType.EAGER, orphanRemoval = true)
|
@OneToMany(fetch = FetchType.EAGER, orphanRemoval = true)
|
||||||
var games: MutableSet<Game> = HashSet<Game>(),
|
var games: MutableSet<Game> = HashSet(),
|
||||||
|
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
@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.dto.GameDto
|
||||||
import de.grimsi.gameyfin.games.entities.Game
|
import de.grimsi.gameyfin.games.entities.Game
|
||||||
import de.grimsi.gameyfin.games.toDto
|
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.LibraryDto
|
||||||
import de.grimsi.gameyfin.libraries.dto.LibraryStatsDto
|
import de.grimsi.gameyfin.libraries.dto.LibraryStatsDto
|
||||||
import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto
|
import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto
|
||||||
@@ -56,7 +57,11 @@ class LibraryService(
|
|||||||
|
|
||||||
// Update only non-null fields
|
// Update only non-null fields
|
||||||
libraryDto.name?.let { existingLibrary.name = it }
|
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)
|
val updatedLibrary = libraryRepository.save(existingLibrary)
|
||||||
return toDto(updatedLibrary)
|
return toDto(updatedLibrary)
|
||||||
@@ -300,7 +305,7 @@ class LibraryService(
|
|||||||
return LibraryDto(
|
return LibraryDto(
|
||||||
id = libraryId,
|
id = libraryId,
|
||||||
name = library.name,
|
name = library.name,
|
||||||
directories = library.directories,
|
directories = library.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
|
||||||
stats = statsDto
|
stats = statsDto
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -314,7 +319,9 @@ 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,
|
||||||
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(
|
data class LibraryDto(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val directories: Set<String>,
|
val directories: List<DirectoryMappingDto>,
|
||||||
val stats: LibraryStatsDto?
|
val stats: LibraryStatsDto?
|
||||||
)
|
)
|
||||||
@@ -3,5 +3,5 @@ package de.grimsi.gameyfin.libraries.dto
|
|||||||
data class LibraryUpdateDto(
|
data class LibraryUpdateDto(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val name: String? = null,
|
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:
|
spring:
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:h2:file:./db/${spring.datasource.db-name};AUTO_SERVER=TRUE
|
url: jdbc:h2:file:./db/${spring.datasource.db-name};AUTO_SERVER=TRUE
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
logging.level:
|
logging.level:
|
||||||
|
root: warn
|
||||||
org.atmosphere: warn
|
org.atmosphere: warn
|
||||||
|
de.grimsi.gameyfin: info
|
||||||
|
de.grimsi.gameyfin.GameyfinApplicationKt: warn
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
@@ -8,6 +11,8 @@ server:
|
|||||||
tracking-modes: cookie
|
tracking-modes: cookie
|
||||||
|
|
||||||
management:
|
management:
|
||||||
|
server:
|
||||||
|
port: 8081
|
||||||
endpoints:
|
endpoints:
|
||||||
web:
|
web:
|
||||||
exposure:
|
exposure:
|
||||||
|
|||||||
Reference in New Issue
Block a user