WIP: Implement library scans

This commit is contained in:
grimsi
2025-04-18 13:36:50 +02:00
parent a8b37f03a1
commit 16759da5d3
16 changed files with 162 additions and 106 deletions
@@ -1,7 +1,7 @@
import {Card, Chip} from "@heroui/react";
import {Button, Card, Chip, Tooltip} from "@heroui/react";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/LibraryDto";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {useEffect, useState} from "react";
import React, {useEffect, useState} from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/GameCover";
import Rand from "rand-seed";
@@ -12,6 +12,7 @@ import {
Ghost,
Joystick,
Lego,
MagnifyingGlass,
Skull,
SoccerBall,
Strategy,
@@ -72,6 +73,14 @@ export function LibraryOverviewCard({library}: { library: LibraryDto }) {
</div>
<p className="absolute text-2xl font-bold">{library.name}</p>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Scan library" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => LibraryEndpoint.triggerScan([library])}>
<MagnifyingGlass/>
</Button>
</Tooltip>
</div>
</div>
{!!library.stats &&
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.transaction.annotation.EnableTransactionManagement
@@ -9,6 +10,7 @@ import org.springframework.transaction.annotation.EnableTransactionManagement
@SpringBootApplication
@EnableScheduling
@EnableTransactionManagement
@EnableAsync
class GameyfinApplication
fun main(args: Array<String>) {
@@ -5,12 +5,10 @@ import de.grimsi.gameyfin.config.dto.ConfigValuePairDto
import de.grimsi.gameyfin.config.entities.ConfigEntry
import de.grimsi.gameyfin.config.persistence.ConfigRepository
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import java.io.Serializable
@Service
@Transactional
class ConfigService(
private val appConfigRepository: ConfigRepository
) {
@@ -4,7 +4,6 @@ import de.grimsi.gameyfin.setup.SetupService
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.entities.User
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import org.springframework.core.env.Environment
@@ -13,7 +12,6 @@ import java.net.InetAddress
@Service
@Transactional
class SetupDataLoader(
private val userService: UserService,
private val setupService: SetupService,
@@ -1,8 +0,0 @@
package de.grimsi.gameyfin.core.events
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.EnableAsync
@Configuration
@EnableAsync
class AsyncConfig
@@ -6,11 +6,13 @@ import jakarta.annotation.security.RolesAllowed
@Endpoint
@RolesAllowed(Role.Names.ADMIN)
class FilesystemEndpoint {
class FilesystemEndpoint(
private val filesystemService: FilesystemService
) {
fun listContents(path: String) = FilesystemService().listContents(path)
fun listContents(path: String) = filesystemService.listContents(path)
fun listSubDirectories(path: String) = FilesystemService().listSubDirectories(path)
fun listSubDirectories(path: String) = filesystemService.listSubDirectories(path)
fun getHostOperatingSystem() = FilesystemService().getHostOperatingSystem()
fun getHostOperatingSystem() = filesystemService.getHostOperatingSystem()
}
@@ -1,17 +1,27 @@
package de.grimsi.gameyfin.core.filesystem
import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.libraries.Library
import io.github.oshai.kotlinlogging.KotlinLogging
import org.apache.commons.io.FilenameUtils
import org.springframework.stereotype.Service
import java.nio.file.FileSystems
import kotlin.io.path.Path
import kotlin.io.path.isDirectory
import java.nio.file.Path
import kotlin.io.path.*
@Service
class FilesystemService {
class FilesystemService(
private val config: ConfigService
) {
private val log = KotlinLogging.logger {}
private val gameFileExtensions
get() = config.get(ConfigProperties.Libraries.Scan.GameFileExtensions)!!
.split(",")
.map { it.trim().lowercase() }
/**
* Lists all files and directories in the given path.
* If the path is null or empty, it lists all root directories.
@@ -61,11 +71,43 @@ class FilesystemService {
}
}
/**
* Scans the given library for files and directories potentially containing games.
*
* @param library The library to scan.
* @return A list of paths representing game files and directories.
*/
fun scanLibraryForGamefiles(library: Library): List<Path> {
// Cache the game file extensions to avoid reading them multiple times in the same scan
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) }
.filter { path ->
if (!path.isDirectory()) {
log.warn { "Invalid directory '$path' in library '${library.name}'" }
false
} else {
true
}
}
// Return all paths that are directories or match the game file extensions
return validDirectories.flatMap {
safeReadDirectoryContents(it)
.filter { it.isDirectory() || it.extension.lowercase() in gamefileExtensions }
}
}
private fun safeReadDirectoryContents(path: String): List<FileDto> {
return safeReadDirectoryContents(Path(path))
.map { FileDto(it.name, if (it.isDirectory()) FileType.DIRECTORY else FileType.FILE, it.hashCode()) }
}
private fun safeReadDirectoryContents(path: Path): List<Path> {
return try {
Path(path).toFile().listFiles()
.filter { !it.isHidden }
.map { FileDto(it.name, if (it.isDirectory) FileType.DIRECTORY else FileType.FILE, it.hashCode()) }
path.listDirectoryEntries()
.filter { !it.isHidden() }
} catch (_: Exception) {
log.warn { "Error reading directory contents of $path" }
emptyList()
@@ -21,7 +21,6 @@ import me.xdrop.fuzzywuzzy.FuzzySearch
import org.pf4j.PluginManager
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.net.URI
import java.net.URLConnection
import java.nio.file.Path
@@ -41,9 +40,7 @@ class GameService(
get() = pluginManager.getExtensions(GameMetadataProvider::class.java)
fun createOrUpdate(game: Game): Game {
gameRepository.findByPath(game.path)?.let {
game.id = it.id
}
gameRepository.findByPath(game.path)?.let { game.id = it.id }
return gameRepository.save(game)
}
@@ -74,7 +71,6 @@ class GameService(
return createOrUpdate(mergedGame)
}
@Transactional(readOnly = true)
fun getAllGames(): Collection<GameDto> {
val entities = gameRepository.findAll()
return entities.map { toDto(it) }
@@ -17,7 +17,7 @@ class Game(
var title: String? = null,
@OneToOne(cascade = [CascadeType.MERGE])
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
var coverImage: Image? = null,
@Lob
@@ -34,10 +34,10 @@ class Game(
var criticRating: Int? = null,
@ManyToMany(cascade = [CascadeType.MERGE])
@ManyToMany
var publishers: Set<Company>? = null,
@ManyToMany(cascade = [CascadeType.MERGE])
@ManyToMany
var developers: Set<Company>? = null,
@ElementCollection(targetClass = Genre::class)
@@ -55,7 +55,7 @@ class Game(
@ElementCollection(targetClass = PlayerPerspective::class)
var perspectives: Set<PlayerPerspective>? = null,
@OneToMany(cascade = [CascadeType.MERGE])
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
var images: Set<Image>? = null,
@ElementCollection
@@ -7,13 +7,13 @@ import jakarta.persistence.*
class Library(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
val id: Long? = null,
var id: Long? = null,
val name: String,
var name: String,
@ElementCollection(fetch = FetchType.EAGER)
val directories: Set<String>,
var directories: MutableSet<String> = HashSet<String>(),
@ManyToMany(cascade = [CascadeType.ALL])
val games: MutableSet<Game> = mutableSetOf()
@ManyToMany(fetch = FetchType.EAGER)
var games: MutableSet<Game> = HashSet<Game>()
)
@@ -21,10 +21,9 @@ class LibraryEndpoint(
return libraryService.getGamesInLibrary(libraryId)
}
// FIXME: Just for testing
@RolesAllowed(Role.Names.ADMIN)
fun test(testString: String): GameDto {
return libraryService.test(testString)
fun triggerScan(libraries: Collection<LibraryDto>?) {
return libraryService.triggerScan(libraries)
}
@RolesAllowed(Role.Names.ADMIN)
@@ -1,60 +1,65 @@
package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.core.filesystem.FilesystemService
import de.grimsi.gameyfin.games.GameService
import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.games.entities.Game
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.runBlocking
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.extension
import kotlin.io.path.isDirectory
import kotlin.io.path.listDirectoryEntries
@Service
class LibraryService(
private val libraryRepository: LibraryRepository,
private val gameService: GameService,
private val config: ConfigService
private val filesystemService: FilesystemService
) {
private val log = KotlinLogging.logger {}
fun test(testString: String): GameDto {
val game = gameService.createFromFile(Path(testString))
val randomLibrary = libraryRepository.findRandomLibrary() ?: throw IllegalArgumentException("No library found")
randomLibrary.games.add(game)
libraryRepository.save(randomLibrary)
return gameService.toDto(game)
}
/**
* Creates or updates a library in the repository.
*
* @param library: The library to create or update.
* @return The created or updated LibraryDto object.
*/
fun createOrUpdate(library: LibraryDto): LibraryDto {
val entity = libraryRepository.save(toEntity(library))
return toDto(entity)
}
@Transactional(readOnly = true)
/**
* Retrieves all libraries from the repository.
*/
fun getAllLibraries(): Collection<LibraryDto> {
val entities = libraryRepository.findAll()
return entities.map { toDto(it) }
}
/**
* Deletes a library from the repository.
*
* @param library: The library to delete.
*/
fun deleteLibrary(library: LibraryDto) {
val entity = toEntity(library)
libraryRepository.delete(entity)
}
/**
* Deletes all libraries from the repository.
*/
fun deleteAllLibraries() {
libraryRepository.deleteAll()
}
@Transactional(readOnly = true)
/**
* Retrieves all games in a library.
*
* @param libraryId: The ID of the library to retrieve games from.
* @return A collection of GameDto objects representing the games in the library.
*/
fun getGamesInLibrary(libraryId: Long): Collection<GameDto> {
val library = libraryRepository.findByIdOrNull(libraryId)
?: throw IllegalArgumentException("Library with ID $libraryId not found")
@@ -65,48 +70,63 @@ class LibraryService(
}
/**
* Triggers a scan for a list of libraries. If no list is provided, all libraries will be scanned.
* Adds a game to the library.
*
* @param game: The game to add.
* @param library: The library to add the game to.
* @return The updated library.
*/
fun scan(libraryDtos: Collection<LibraryDto>?) {
fun addGameToLibrary(game: Game, library: Library): Library {
if (library.games.any { it.id == game.id }) return library
library.games.add(game)
return libraryRepository.save(library)
}
/**
* Adds a collection of games to the library.
*
* @param games: The collection of games to add.
* @param library: The library to add the games to.
* @return The updated library.
*/
fun addGamesToLibrary(games: Collection<Game>, library: Library): Library {
val newGames = games.filter { game -> library.games.none { it.id == game.id } }
library.games = library.games.toMutableSet().apply { addAll(newGames) }
return libraryRepository.save(library)
}
/**
* Wrapper function to trigger a scan for a list of libraries.
*/
fun triggerScan(libraryDtos: Collection<LibraryDto>?) = runBlocking {
scan(libraryDtos)
}
/**
* Triggers a scan for a list of libraries.
* If no list is provided, all libraries will be scanned.
*
* @param libraryDtos: List of LibraryDto objects to scan.
*/
suspend fun scan(libraryDtos: Collection<LibraryDto>?) {
val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll()
libraries.forEach { library ->
val games = scan(library)
games.forEach(gameService::createFromFile)
val gamePaths = filesystemService.scanLibraryForGamefiles(library)
val newGames = gamePaths.map { gameService.createFromFile(it) }
addGamesToLibrary(newGames, library)
}
}
/**
* Return a list of all subfolders and game files in the provided library
* Converts a Library entity to a LibraryDto.
*
* @param library: The Library entity to convert.
* @return The converted LibraryDto.
*/
fun scan(library: Library): List<Path> {
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 {
val gameFileExtensions = config.get(ConfigProperties.Libraries.Scan.GameFileExtensions)!!
.split(",")
.map { it.trim().lowercase() }
return extension.lowercase() in gameFileExtensions
}
private fun toDto(library: Library): LibraryDto {
if (library.id == null) {
throw IllegalArgumentException("Library ID is null")
}
val libraryId = library.id ?: throw IllegalArgumentException("Library ID is null")
val statsDto = LibraryStatsDto(
gamesCount = library.games.size,
@@ -114,17 +134,23 @@ class LibraryService(
)
return LibraryDto(
id = library.id,
id = libraryId,
name = library.name,
directories = library.directories,
stats = statsDto
)
}
/**
* Converts a LibraryDto to a Library entity.
*
* @param library: The LibraryDto to convert.
* @return The converted Library entity.
*/
private fun toEntity(library: LibraryDto): Library {
return libraryRepository.findByIdOrNull(library.id) ?: Library(
name = library.name,
directories = library.directories
directories = library.directories.toMutableSet()
)
}
}
@@ -10,13 +10,11 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationContext
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
import java.util.*
@EnableAsync
@Service
class MessageService(
private val applicationContext: ApplicationContext,
@@ -3,7 +3,6 @@ package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.persistence.UserRepository
import jakarta.transaction.Transactional
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
@@ -11,7 +10,6 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority
import org.springframework.stereotype.Service
@Service
@Transactional
class RoleService(
private val userRepository: UserRepository
) {
@@ -15,7 +15,6 @@ import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.enums.RoleAssignmentResult
import de.grimsi.gameyfin.users.persistence.UserRepository
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
@@ -30,7 +29,6 @@ import org.springframework.stereotype.Service
@Service
@Transactional
class UserService(
private val userRepository: UserRepository,
private val imageService: ImageService,
@@ -2,13 +2,11 @@ package de.grimsi.gameyfin.users.preferences
import de.grimsi.gameyfin.users.UserService
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
import java.io.Serializable
@Service
@Transactional
class UserPreferencesService(
private val userPreferenceRepository: UserPreferenceRepository,
private val userService: UserService