Implement detection of removed games & unmatched paths

Implement persisting unmatched paths
Various minor refactorings
This commit is contained in:
grimsi
2025-05-07 12:55:01 +02:00
parent df7e76aaf8
commit 4e3b6f7152
17 changed files with 152 additions and 50 deletions
@@ -23,6 +23,7 @@ import {
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import LibraryDetailsModal from "Frontend/components/general/modals/LibraryDetailsModal"; import LibraryDetailsModal from "Frontend/components/general/modals/LibraryDetailsModal";
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto"; import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
import ScanType from "Frontend/generated/de/grimsi/gameyfin/libraries/enums/ScanType";
export function LibraryOverviewCard({library, updateLibrary}: { export function LibraryOverviewCard({library, updateLibrary}: {
library: LibraryDto, library: LibraryDto,
@@ -85,7 +86,8 @@ export function LibraryOverviewCard({library, updateLibrary}: {
<div className="absolute right-0 top-0 flex flex-row"> <div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Scan library" placement="bottom" color="foreground"> <Tooltip content="Scan library" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => LibraryEndpoint.triggerScan([library])}> <Button isIconOnly variant="light"
onPress={() => LibraryEndpoint.triggerScan(ScanType.QUICK, [library])}>
<MagnifyingGlass/> <MagnifyingGlass/>
</Button> </Button>
</Tooltip> </Tooltip>
@@ -2,17 +2,14 @@ package de.grimsi.gameyfin.config.entities
import de.grimsi.gameyfin.core.security.EncryptionConverter import de.grimsi.gameyfin.core.security.EncryptionConverter
import jakarta.persistence.* import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
@Entity @Entity
@Table(name = "app_config") @Table(name = "app_config")
class ConfigEntry( class ConfigEntry(
@Id @Id
@NotNull
@Column(name = "`key`", unique = true) @Column(name = "`key`", unique = true)
val key: String, val key: String,
@NotNull
@Column(name = "`value`") @Column(name = "`value`")
@Convert(converter = EncryptionConverter::class) @Convert(converter = EncryptionConverter::class)
var value: String var value: String
@@ -0,0 +1,9 @@
package de.grimsi.gameyfin.core.filesystem
import java.nio.file.Path
data class FilesystemScanResult(
val newPaths: Set<Path>,
val removedGamePaths: Set<Path>,
val removedUnmatchedPaths: Set<Path>
)
@@ -75,7 +75,7 @@ class FilesystemService(
* @param library The library to scan. * @param library The library to scan.
* @return A list of paths representing game files and directories. * @return A list of paths representing game files and directories.
*/ */
fun scanLibraryForGamefiles(library: Library): List<Path> { fun scanLibraryForGamefiles(library: Library): FilesystemScanResult {
// Cache the game file extensions to avoid reading them multiple times in the same scan // Cache the game file extensions to avoid reading them multiple times in the same scan
val gamefileExtensions = gameFileExtensions val gamefileExtensions = gameFileExtensions
@@ -90,11 +90,39 @@ class FilesystemService(
} }
} }
// Return all paths that are directories or match the game file extensions // Get all paths that are directories or match the game file extensions
return 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 }
} }
// Get all paths already in the library as game files or as unmatched paths
val currentLibraryGamePaths = library.games.map { Path(it.path) }
val currentLibraryUnmatchedPaths = library.unmatchedPaths.map { Path(it) }
val allCurrentLibraryPaths = currentLibraryGamePaths + currentLibraryUnmatchedPaths
//Get all paths that are on the filesystem, but not in the library (either as game or as unmatched path)
val newPaths = currentFilesystemPaths.filter { path ->
val isInLibrary = allCurrentLibraryPaths.any { it == path }
!isInLibrary
}.toSet()
//Get all paths that are in the library (either as game or as unmatched path), but not on the filesystem
val removedGamePaths = currentLibraryGamePaths.filter { path ->
val isOnFilesystem = currentFilesystemPaths.any { it == path }
!isOnFilesystem
}.toSet()
val removedUnmatchedPaths = currentLibraryUnmatchedPaths.filter { path ->
val isOnFilesystem = currentFilesystemPaths.any { it == path }
!isOnFilesystem
}.toSet()
return FilesystemScanResult(
newPaths = newPaths,
removedGamePaths = removedGamePaths,
removedUnmatchedPaths = removedUnmatchedPaths
)
} }
private fun safeReadDirectoryContents(path: String): List<FileDto> { private fun safeReadDirectoryContents(path: String): List<FileDto> {
@@ -1,23 +1,15 @@
package de.grimsi.gameyfin.core.plugins.config package de.grimsi.gameyfin.core.plugins.config
import de.grimsi.gameyfin.core.security.EncryptionConverter import de.grimsi.gameyfin.core.security.EncryptionConverter
import jakarta.persistence.Column import jakarta.persistence.*
import jakarta.persistence.Convert
import jakarta.persistence.Embeddable
import jakarta.persistence.EmbeddedId
import jakarta.persistence.Entity
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
import java.io.Serializable import java.io.Serializable
@Entity @Entity
@Table(name = "plugin_config") @Table(name = "plugin_config")
data class PluginConfigEntry( data class PluginConfigEntry(
@NotNull
@EmbeddedId @EmbeddedId
val id: PluginConfigEntryKey, val id: PluginConfigEntryKey,
@NotNull
@Column(name = "`value`") @Column(name = "`value`")
@Convert(converter = EncryptionConverter::class) @Convert(converter = EncryptionConverter::class)
val value: String val value: String
@@ -86,6 +86,10 @@ class GameService(
return mergedGame return mergedGame
} }
fun getAllByPaths(paths: Collection<String>): Collection<Game> {
return gameRepository.findAllByPathIn(paths)
}
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) }
@@ -39,10 +39,10 @@ class Game(
var criticRating: Int? = null, var criticRating: Int? = null,
@ManyToMany(cascade = [CascadeType.ALL]) @ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH])
var publishers: Set<Company> = emptySet(), var publishers: Set<Company> = emptySet(),
@ManyToMany(cascade = [CascadeType.ALL]) @ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH])
var developers: Set<Company> = emptySet(), var developers: Set<Company> = emptySet(),
@ElementCollection(targetClass = Genre::class) @ElementCollection(targetClass = Genre::class)
@@ -1,6 +1,5 @@
package de.grimsi.gameyfin.games.entities package de.grimsi.gameyfin.games.entities
import jakarta.annotation.Nullable
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType import jakarta.persistence.GenerationType
@@ -21,15 +20,12 @@ class Image(
val type: ImageType, val type: ImageType,
@ContentId @ContentId
@Nullable
var contentId: String? = null, var contentId: String? = null,
@ContentLength @ContentLength
@Nullable
var contentLength: Long? = null, var contentLength: Long? = null,
@MimeType @MimeType
@Nullable
var mimeType: String? = null var mimeType: String? = null
) )
@@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository
interface GameRepository : JpaRepository<Game, Long> { interface GameRepository : JpaRepository<Game, Long> {
fun findByPath(path: String): Game? fun findByPath(path: String): Game?
fun findAllByPathIn(paths: Collection<String>): Collection<Game>
} }
@@ -14,6 +14,9 @@ class Library(
@ElementCollection(fetch = FetchType.EAGER) @ElementCollection(fetch = FetchType.EAGER)
var directories: MutableSet<String> = HashSet<String>(), var directories: MutableSet<String> = HashSet<String>(),
@ManyToMany(fetch = FetchType.EAGER) @OneToMany(fetch = FetchType.EAGER, orphanRemoval = true)
var games: MutableSet<Game> = HashSet<Game>() var games: MutableSet<Game> = HashSet<Game>(),
@ElementCollection(fetch = FetchType.EAGER)
var unmatchedPaths: MutableSet<String> = HashSet<String>()
) )
@@ -6,6 +6,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.libraries.dto.LibraryDto import de.grimsi.gameyfin.libraries.dto.LibraryDto
import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto
import de.grimsi.gameyfin.libraries.enums.ScanType
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@@ -24,8 +25,8 @@ class LibraryEndpoint(
} }
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun triggerScan(libraries: Collection<LibraryDto>?) { fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection<LibraryDto>?) {
return libraryService.triggerScan(libraries) return libraryService.triggerScan(scanType, libraries)
} }
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
@@ -0,0 +1,10 @@
package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.games.entities.Game
data class LibraryScanResult(
val libraries: Set<Library>,
val newGames: Set<Game>,
val removedGames: Set<Game>,
val newUnmatchedPaths: Set<String>
)
@@ -1,30 +1,29 @@
package de.grimsi.gameyfin.libraries package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.core.filesystem.FilesystemService import de.grimsi.gameyfin.core.filesystem.FilesystemService
import de.grimsi.gameyfin.games.CompanyService
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 de.grimsi.gameyfin.games.entities.Game
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
import de.grimsi.gameyfin.libraries.enums.ScanType
import de.grimsi.gameyfin.media.ImageService import de.grimsi.gameyfin.media.ImageService
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.util.concurrent.Callable import java.util.concurrent.Callable
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.measureTimedValue
@Service @Service
class LibraryService( class LibraryService(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val filesystemService: FilesystemService, private val filesystemService: FilesystemService,
private val gameService: GameService, private val gameService: GameService,
private val imageService: ImageService, private val imageService: ImageService
private val companyService: CompanyService
) { ) {
companion object { companion object {
@@ -124,29 +123,49 @@ class LibraryService(
*/ */
fun addGamesToLibrary(games: Collection<Game>, library: Library): Library { fun addGamesToLibrary(games: Collection<Game>, library: Library): Library {
val newGames = games.filter { game -> library.games.none { it.id == game.id } } val newGames = games.filter { game -> library.games.none { it.id == game.id } }
library.games = library.games.toMutableSet().apply { addAll(newGames) } library.games.addAll(newGames)
return libraryRepository.save(library) return library
} }
/** /**
* Wrapper function to trigger a scan for a list of libraries. * Wrapper function to trigger a scan for a list of libraries.
*/ */
fun triggerScan(libraryDtos: Collection<LibraryDto>?) = runBlocking { fun triggerScan(scanType: ScanType, libraryDtos: Collection<LibraryDto>?) {
scan(libraryDtos) val scanResult = measureTimedValue {
when (scanType) {
ScanType.QUICK -> quickScan(libraryDtos)
ScanType.FULL -> TODO()
}
}
log.info {
"""
Scan completed in ${scanResult.duration}.
Libraries scanned: ${libraryDtos?.joinToString { it.name } ?: "all libraries"}
Scan type: ${scanType.toString().lowercase()}
New games added: ${scanResult.value.newGames.size}
Removed games: ${scanResult.value.removedGames.size}
New unmatched paths: ${scanResult.value.newUnmatchedPaths.size}
""".trimIndent()
}
} }
/** /**
* Triggers a scan for a list of libraries. * Triggers a quick scan for a list of libraries.
* A quick scan will only scan for new games and deleted games, but will not touch existing games.
* If no list is provided, all libraries will be scanned. * If no list is provided, all libraries will be scanned.
* *
* @param libraryDtos: List of LibraryDto objects to scan. * @param libraryDtos: List of LibraryDto objects to scan.
*/ */
@Transactional(timeout = Integer.MAX_VALUE) fun quickScan(libraryDtos: Collection<LibraryDto>?): LibraryScanResult {
fun scan(libraryDtos: Collection<LibraryDto>?) {
val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll() val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll()
libraries.forEach { library -> val scanResults: List<LibraryScanResult> = libraries.map { library ->
val gamePaths = filesystemService.scanLibraryForGamefiles(library) val scanResult = filesystemService.scanLibraryForGamefiles(library)
val gamePaths = scanResult.newPaths
val removedGamePaths = scanResult.removedGamePaths.map { it.toString() }
val removedUnmatchedPaths = scanResult.removedUnmatchedPaths.map { it.toString() }
val totalPaths = gamePaths.size val totalPaths = gamePaths.size
val completedMetadata = AtomicInteger(0) val completedMetadata = AtomicInteger(0)
val completedImageDownload = AtomicInteger(0) val completedImageDownload = AtomicInteger(0)
@@ -154,22 +173,42 @@ class LibraryService(
log.info { "Scanning library '${library.name}' with $totalPaths paths..." } log.info { "Scanning library '${library.name}' with $totalPaths paths..." }
// 1. Fetch metadata for each game // 1. Fetch metadata for each game
val newUnmatchedPaths = ConcurrentHashMap.newKeySet<String>()
val metadataTasks = gamePaths.map { path -> val metadataTasks = gamePaths.map { path ->
Callable<Game?> { Callable<Game?> {
try { try {
val game = gameService.matchFromFile(path, library) val game = gameService.matchFromFile(path, library)
if (game == null) {
newUnmatchedPaths.add(path.toString())
return@Callable null
}
val progress = completedMetadata.incrementAndGet() val progress = completedMetadata.incrementAndGet()
log.info { "${progress}/${totalPaths} metadata matched" } log.info { "${progress}/${totalPaths} metadata matched" }
game
return@Callable game
} catch (e: Exception) { } catch (e: Exception) {
log.error(e) { "Error processing game: ${e.message}" } log.error(e) { "Error processing game: ${e.message}" }
null newUnmatchedPaths.add(path.toString())
return@Callable null
} }
} }
} }
// 1.1 Wait for all metadata tasks to complete
val matchedGames = executor.invokeAll(metadataTasks).mapNotNull { it.get() } val matchedGames = executor.invokeAll(metadataTasks).mapNotNull { it.get() }
// 1.2 Add unmatched paths to the library
library.unmatchedPaths.removeAll(removedUnmatchedPaths)
library.unmatchedPaths.addAll(newUnmatchedPaths)
// 1.3 Remove deleted games from the library
val removedGames = gameService.getAllByPaths(removedGamePaths)
library.games.removeAll(removedGames)
// 2. Download all images // 2. Download all images
val totalImages = matchedGames.count { it.coverImage != null } + matchedGames.sumOf { it.images.size } val totalImages = matchedGames.count { it.coverImage != null } + matchedGames.sumOf { it.images.size }
@@ -198,13 +237,31 @@ class LibraryService(
val gamesWithImages = executor.invokeAll(imageDownloadTasks).mapNotNull { it.get() } val gamesWithImages = executor.invokeAll(imageDownloadTasks).mapNotNull { it.get() }
// 3. Persist entities // 3. Persist new games
val persistedGames = gameService.create(gamesWithImages) val persistedGames = gameService.create(gamesWithImages)
log.info { "${persistedGames.size}/${totalPaths} saved to database" } log.info { "${persistedGames.size}/${totalPaths} saved to database" }
// 4. Add games to library // 4. Add new games to library
addGamesToLibrary(persistedGames, library) addGamesToLibrary(persistedGames, library)
log.info { "Scan finished, matched ${persistedGames.size} new games" }
// 5. Persist library
libraryRepository.save(library)
return LibraryScanResult(
libraries = setOf(library),
newGames = persistedGames.toSet(),
removedGames = removedGames.toSet(),
newUnmatchedPaths = newUnmatchedPaths
)
}
return scanResults.reduce { acc, scanResult ->
LibraryScanResult(
libraries = acc.libraries + scanResult.libraries,
newGames = acc.newGames + scanResult.newGames,
removedGames = acc.removedGames + scanResult.removedGames,
newUnmatchedPaths = acc.newUnmatchedPaths + scanResult.newUnmatchedPaths
)
} }
} }
@@ -0,0 +1,5 @@
package de.grimsi.gameyfin.libraries.enums
enum class ScanType {
QUICK, FULL
}
@@ -2,16 +2,13 @@ package de.grimsi.gameyfin.users.preferences
import de.grimsi.gameyfin.core.security.EncryptionConverter import de.grimsi.gameyfin.core.security.EncryptionConverter
import jakarta.persistence.* import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
import java.io.Serializable import java.io.Serializable
@Entity @Entity
class UserPreference( class UserPreference(
@NotNull
@EmbeddedId @EmbeddedId
val id: UserPreferenceKey, val id: UserPreferenceKey,
@NotNull
@Column(name = "`value`") @Column(name = "`value`")
@Convert(converter = EncryptionConverter::class) @Convert(converter = EncryptionConverter::class)
var value: String var value: String
+1 -1
View File
@@ -1,4 +1,4 @@
val ktor_version = "3.0.0" val ktor_version = "3.1.2"
plugins { plugins {
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
@@ -122,7 +122,7 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
/** /**
* Often titles on Steam copyright symbols which makes matching between different providers harder * Often titles on Steam contain copyright symbols which makes matching between different providers harder
* This method removes those symbols * This method removes those symbols
*/ */
private fun sanitizeTitle(originalTitle: String): String { private fun sanitizeTitle(originalTitle: String): String {