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 LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/LibraryDto";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; 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 {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/GameCover"; import {GameCover} from "Frontend/components/general/GameCover";
import Rand from "rand-seed"; import Rand from "rand-seed";
@@ -12,6 +12,7 @@ import {
Ghost, Ghost,
Joystick, Joystick,
Lego, Lego,
MagnifyingGlass,
Skull, Skull,
SoccerBall, SoccerBall,
Strategy, Strategy,
@@ -72,6 +73,14 @@ export function LibraryOverviewCard({library}: { library: LibraryDto }) {
</div> </div>
<p className="absolute text-2xl font-bold">{library.name}</p> <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> </div>
{!!library.stats && {!!library.stats &&
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.transaction.annotation.EnableTransactionManagement import org.springframework.transaction.annotation.EnableTransactionManagement
@@ -9,6 +10,7 @@ import org.springframework.transaction.annotation.EnableTransactionManagement
@SpringBootApplication @SpringBootApplication
@EnableScheduling @EnableScheduling
@EnableTransactionManagement @EnableTransactionManagement
@EnableAsync
class GameyfinApplication class GameyfinApplication
fun main(args: Array<String>) { 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.entities.ConfigEntry
import de.grimsi.gameyfin.config.persistence.ConfigRepository import de.grimsi.gameyfin.config.persistence.ConfigRepository
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.Serializable import java.io.Serializable
@Service @Service
@Transactional
class ConfigService( class ConfigService(
private val appConfigRepository: ConfigRepository 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.UserService
import de.grimsi.gameyfin.users.entities.User import de.grimsi.gameyfin.users.entities.User
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener import org.springframework.context.event.EventListener
import org.springframework.core.env.Environment import org.springframework.core.env.Environment
@@ -13,7 +12,6 @@ import java.net.InetAddress
@Service @Service
@Transactional
class SetupDataLoader( class SetupDataLoader(
private val userService: UserService, private val userService: UserService,
private val setupService: SetupService, 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 @Endpoint
@RolesAllowed(Role.Names.ADMIN) @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 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 io.github.oshai.kotlinlogging.KotlinLogging
import org.apache.commons.io.FilenameUtils import org.apache.commons.io.FilenameUtils
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.nio.file.FileSystems import java.nio.file.FileSystems
import kotlin.io.path.Path import java.nio.file.Path
import kotlin.io.path.isDirectory import kotlin.io.path.*
@Service @Service
class FilesystemService { class FilesystemService(
private val config: ConfigService
) {
private val log = KotlinLogging.logger {} 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. * Lists all files and directories in the given path.
* If the path is null or empty, it lists all root directories. * 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> { 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 { return try {
Path(path).toFile().listFiles() path.listDirectoryEntries()
.filter { !it.isHidden } .filter { !it.isHidden() }
.map { FileDto(it.name, if (it.isDirectory) FileType.DIRECTORY else FileType.FILE, it.hashCode()) }
} catch (_: Exception) { } catch (_: Exception) {
log.warn { "Error reading directory contents of $path" } log.warn { "Error reading directory contents of $path" }
emptyList() emptyList()
@@ -21,7 +21,6 @@ import me.xdrop.fuzzywuzzy.FuzzySearch
import org.pf4j.PluginManager import org.pf4j.PluginManager
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 java.net.URI import java.net.URI
import java.net.URLConnection import java.net.URLConnection
import java.nio.file.Path import java.nio.file.Path
@@ -41,9 +40,7 @@ class GameService(
get() = pluginManager.getExtensions(GameMetadataProvider::class.java) get() = pluginManager.getExtensions(GameMetadataProvider::class.java)
fun createOrUpdate(game: Game): Game { fun createOrUpdate(game: Game): Game {
gameRepository.findByPath(game.path)?.let { gameRepository.findByPath(game.path)?.let { game.id = it.id }
game.id = it.id
}
return gameRepository.save(game) return gameRepository.save(game)
} }
@@ -74,7 +71,6 @@ class GameService(
return createOrUpdate(mergedGame) return createOrUpdate(mergedGame)
} }
@Transactional(readOnly = true)
fun getAllGames(): Collection<GameDto> { fun getAllGames(): Collection<GameDto> {
val entities = gameRepository.findAll() val entities = gameRepository.findAll()
return entities.map { toDto(it) } return entities.map { toDto(it) }
@@ -17,7 +17,7 @@ class Game(
var title: String? = null, var title: String? = null,
@OneToOne(cascade = [CascadeType.MERGE]) @OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
var coverImage: Image? = null, var coverImage: Image? = null,
@Lob @Lob
@@ -34,10 +34,10 @@ class Game(
var criticRating: Int? = null, var criticRating: Int? = null,
@ManyToMany(cascade = [CascadeType.MERGE]) @ManyToMany
var publishers: Set<Company>? = null, var publishers: Set<Company>? = null,
@ManyToMany(cascade = [CascadeType.MERGE]) @ManyToMany
var developers: Set<Company>? = null, var developers: Set<Company>? = null,
@ElementCollection(targetClass = Genre::class) @ElementCollection(targetClass = Genre::class)
@@ -55,7 +55,7 @@ class Game(
@ElementCollection(targetClass = PlayerPerspective::class) @ElementCollection(targetClass = PlayerPerspective::class)
var perspectives: Set<PlayerPerspective>? = null, var perspectives: Set<PlayerPerspective>? = null,
@OneToMany(cascade = [CascadeType.MERGE]) @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
var images: Set<Image>? = null, var images: Set<Image>? = null,
@ElementCollection @ElementCollection
@@ -7,13 +7,13 @@ import jakarta.persistence.*
class Library( class Library(
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
val id: Long? = null, var id: Long? = null,
val name: String, var name: String,
@ElementCollection(fetch = FetchType.EAGER) @ElementCollection(fetch = FetchType.EAGER)
val directories: Set<String>, var directories: MutableSet<String> = HashSet<String>(),
@ManyToMany(cascade = [CascadeType.ALL]) @ManyToMany(fetch = FetchType.EAGER)
val games: MutableSet<Game> = mutableSetOf() var games: MutableSet<Game> = HashSet<Game>()
) )
@@ -21,10 +21,9 @@ class LibraryEndpoint(
return libraryService.getGamesInLibrary(libraryId) return libraryService.getGamesInLibrary(libraryId)
} }
// FIXME: Just for testing
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun test(testString: String): GameDto { fun triggerScan(libraries: Collection<LibraryDto>?) {
return libraryService.test(testString) return libraryService.triggerScan(libraries)
} }
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
@@ -1,60 +1,65 @@
package de.grimsi.gameyfin.libraries package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.config.ConfigProperties import de.grimsi.gameyfin.core.filesystem.FilesystemService
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 de.grimsi.gameyfin.games.entities.Game
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.runBlocking
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 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 @Service
class LibraryService( class LibraryService(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val gameService: GameService, private val gameService: GameService,
private val config: ConfigService private val filesystemService: FilesystemService
) { ) {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
fun test(testString: String): GameDto { /**
val game = gameService.createFromFile(Path(testString)) * Creates or updates a library in the repository.
*
val randomLibrary = libraryRepository.findRandomLibrary() ?: throw IllegalArgumentException("No library found") * @param library: The library to create or update.
* @return The created or updated LibraryDto object.
randomLibrary.games.add(game) */
libraryRepository.save(randomLibrary)
return gameService.toDto(game)
}
fun createOrUpdate(library: LibraryDto): LibraryDto { fun createOrUpdate(library: LibraryDto): LibraryDto {
val entity = libraryRepository.save(toEntity(library)) val entity = libraryRepository.save(toEntity(library))
return toDto(entity) return toDto(entity)
} }
@Transactional(readOnly = true) /**
* Retrieves all libraries from the repository.
*/
fun getAllLibraries(): Collection<LibraryDto> { fun getAllLibraries(): Collection<LibraryDto> {
val entities = libraryRepository.findAll() val entities = libraryRepository.findAll()
return entities.map { toDto(it) } return entities.map { toDto(it) }
} }
/**
* Deletes a library from the repository.
*
* @param library: The library to delete.
*/
fun deleteLibrary(library: LibraryDto) { fun deleteLibrary(library: LibraryDto) {
val entity = toEntity(library) val entity = toEntity(library)
libraryRepository.delete(entity) libraryRepository.delete(entity)
} }
/**
* Deletes all libraries from the repository.
*/
fun deleteAllLibraries() { fun deleteAllLibraries() {
libraryRepository.deleteAll() 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> { fun getGamesInLibrary(libraryId: Long): Collection<GameDto> {
val library = libraryRepository.findByIdOrNull(libraryId) val library = libraryRepository.findByIdOrNull(libraryId)
?: throw IllegalArgumentException("Library with ID $libraryId not found") ?: 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() val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll()
libraries.forEach { library -> libraries.forEach { library ->
val games = scan(library) val gamePaths = filesystemService.scanLibraryForGamefiles(library)
games.forEach(gameService::createFromFile) 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 { private fun toDto(library: Library): LibraryDto {
if (library.id == null) { val libraryId = library.id ?: throw IllegalArgumentException("Library ID is null")
throw IllegalArgumentException("Library ID is null")
}
val statsDto = LibraryStatsDto( val statsDto = LibraryStatsDto(
gamesCount = library.games.size, gamesCount = library.games.size,
@@ -114,17 +134,23 @@ class LibraryService(
) )
return LibraryDto( return LibraryDto(
id = library.id, id = libraryId,
name = library.name, name = library.name,
directories = library.directories, directories = library.directories,
stats = statsDto 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 { 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 directories = library.directories.toMutableSet()
) )
} }
} }
@@ -10,13 +10,11 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContext
import org.springframework.context.event.EventListener import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async import org.springframework.scheduling.annotation.Async
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.* import java.util.*
@EnableAsync
@Service @Service
class MessageService( class MessageService(
private val applicationContext: ApplicationContext, private val applicationContext: ApplicationContext,
@@ -3,7 +3,6 @@ package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.users.entities.User import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.persistence.UserRepository import de.grimsi.gameyfin.users.persistence.UserRepository
import jakarta.transaction.Transactional
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority 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 import org.springframework.stereotype.Service
@Service @Service
@Transactional
class RoleService( class RoleService(
private val userRepository: UserRepository 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.enums.RoleAssignmentResult
import de.grimsi.gameyfin.users.persistence.UserRepository import de.grimsi.gameyfin.users.persistence.UserRepository
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.GrantedAuthority
@@ -30,7 +29,6 @@ import org.springframework.stereotype.Service
@Service @Service
@Transactional
class UserService( class UserService(
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val imageService: ImageService, private val imageService: ImageService,
@@ -2,13 +2,11 @@ package de.grimsi.gameyfin.users.preferences
import de.grimsi.gameyfin.users.UserService import de.grimsi.gameyfin.users.UserService
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.Serializable import java.io.Serializable
@Service @Service
@Transactional
class UserPreferencesService( class UserPreferencesService(
private val userPreferenceRepository: UserPreferenceRepository, private val userPreferenceRepository: UserPreferenceRepository,
private val userService: UserService private val userService: UserService