mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Implement "full" scan type (#633)
This commit is contained in:
@@ -14,7 +14,7 @@ import {scanState} from "Frontend/state/ScanState";
|
||||
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import {Target} from "@phosphor-icons/react";
|
||||
import {timeBetween, timeUntil} from "Frontend/util/utils";
|
||||
import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils";
|
||||
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function ScanProgressPopover() {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div className="flex flex-col gap-2 m-2 w-96">
|
||||
<div className="flex flex-col gap-2 m-2 min-w-96 w-fit">
|
||||
{scans.length === 0 ?
|
||||
<p className="flex h-12 items-center justify-center text-sm text-default-500">
|
||||
No scans in progress or in history.
|
||||
@@ -60,7 +60,7 @@ export default function ScanProgressPopover() {
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className="flex flex-row justify-between items-center text-default-500 mb-1">
|
||||
<p>Scan for library
|
||||
<p>{toTitleCase(scan.type)} scan for library
|
||||
<Link underline="always"
|
||||
color="foreground"
|
||||
size="sm"
|
||||
@@ -94,6 +94,7 @@ export default function ScanProgressPopover() {
|
||||
:
|
||||
<p>
|
||||
{scan.result?.new} new /
|
||||
{(scan as any).result?.updated != null && `${(scan as any).result.updated} updated / `}
|
||||
{scan.result?.removed} removed /
|
||||
{scan.result?.unmatched} unmatched
|
||||
(in {timeBetween(scan.startedAt, scan.finishedAt!)})
|
||||
|
||||
@@ -4,7 +4,7 @@ import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import React from "react";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import {MagnifyingGlass, SlidersHorizontal} from "@phosphor-icons/react";
|
||||
import {MagnifyingGlass, MagnifyingGlassPlus, SlidersHorizontal} from "@phosphor-icons/react";
|
||||
import ScanType from "Frontend/generated/org/gameyfin/app/libraries/enums/ScanType";
|
||||
import {useNavigate} from "react-router";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
@@ -27,8 +27,8 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
return games.slice(0, MAX_COVER_COUNT);
|
||||
}
|
||||
|
||||
async function triggerScan() {
|
||||
await LibraryEndpoint.triggerScan(ScanType.QUICK, [library]);
|
||||
async function triggerScan(scanType: ScanType) {
|
||||
await LibraryEndpoint.triggerScan(scanType, [library]);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -48,11 +48,16 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
<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={triggerScan}>
|
||||
<Tooltip content="Scan library (quick)" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.QUICK)}>
|
||||
<MagnifyingGlass/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Scan library (full)" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.FULL)}>
|
||||
<MagnifyingGlassPlus/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
|
||||
<SlidersHorizontal/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import jakarta.annotation.security.RolesAllowed
|
||||
import org.gameyfin.app.core.Role
|
||||
import org.gameyfin.app.core.annotations.DynamicPublicAccess
|
||||
import org.gameyfin.app.games.dto.*
|
||||
import org.gameyfin.app.libraries.LibraryCoreService
|
||||
import org.gameyfin.app.libraries.LibraryService
|
||||
import reactor.core.publisher.Flux
|
||||
import java.nio.file.Path
|
||||
@@ -15,7 +16,8 @@ import java.nio.file.Path
|
||||
@AnonymousAllowed
|
||||
class GameEndpoint(
|
||||
private val gameService: GameService,
|
||||
private val libraryService: LibraryService
|
||||
private val libraryService: LibraryService,
|
||||
private val libraryCoreService: LibraryCoreService
|
||||
) {
|
||||
fun subscribe(): Flux<List<GameEvent>> {
|
||||
return GameService.subscribe()
|
||||
@@ -24,11 +26,11 @@ class GameEndpoint(
|
||||
fun getAll(): List<GameDto> = gameService.getAll()
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun updateGame(game: GameUpdateDto) = gameService.update(game)
|
||||
fun updateGame(game: GameUpdateDto) = gameService.edit(game)
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun deleteGame(gameId: Long) {
|
||||
libraryService.deleteGameFromLibrary(gameId)
|
||||
libraryCoreService.deleteGameFromLibrary(gameId)
|
||||
gameService.delete(gameId)
|
||||
}
|
||||
|
||||
@@ -42,7 +44,7 @@ class GameEndpoint(
|
||||
val library = libraryService.getById(libraryId)
|
||||
val game = gameService.matchManually(originalIds, Path.of(path), library, replaceGameId)
|
||||
if (game != null) {
|
||||
libraryService.addGamesToLibrary(listOf(game), library, true)
|
||||
libraryCoreService.addGamesToLibrary(listOf(game), library, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ class GameService(
|
||||
return gameRepository.saveAll(gamesToBePersisted)
|
||||
}
|
||||
|
||||
fun update(gameUpdateDto: GameUpdateDto) {
|
||||
fun edit(gameUpdateDto: GameUpdateDto) {
|
||||
val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
|
||||
?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found")
|
||||
|
||||
@@ -235,6 +235,209 @@ class GameService(
|
||||
gameRepository.save(existingGame)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(game: Game): Game? {
|
||||
var wasGameUpdated = false
|
||||
|
||||
val game = getById(game.id!!)
|
||||
|
||||
val originalIds: Map<String, OriginalIdDto> = game.metadata.originalIds
|
||||
.map { (provider, originalId) ->
|
||||
val providerId = pluginManager.getExtensions(provider.pluginId).first()?.javaClass?.name ?: return null
|
||||
val pluginId = provider.pluginId
|
||||
val originalId = originalId
|
||||
providerId to OriginalIdDto(pluginId, originalId)
|
||||
}
|
||||
.toMap()
|
||||
|
||||
val updatedGame = matchManually(
|
||||
originalIds = originalIds,
|
||||
path = Path.of(game.metadata.path),
|
||||
library = game.library,
|
||||
replaceGameId = game.id,
|
||||
persist = false
|
||||
)
|
||||
|
||||
if (updatedGame == null) {
|
||||
log.warn { "Failed to update game with ID ${game.id}" }
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
fun <T> updateField(
|
||||
fieldName: String,
|
||||
originalValue: T?,
|
||||
updatedValue: T?,
|
||||
setValue: (T?) -> Unit,
|
||||
updatedFieldMetadata: GameFieldMetadata?
|
||||
) {
|
||||
// Hibernate collections are of type "PersistentBag" which does not implement equals() properly when comparing with ArrayList
|
||||
fun areEqual(a: Any?, b: Any?): Boolean {
|
||||
return when {
|
||||
a is Collection<*> && b is Collection<*> -> a.toList() == b.toList()
|
||||
else -> a == b
|
||||
}
|
||||
}
|
||||
|
||||
val fieldSource = game.metadata.fields[fieldName]?.source
|
||||
if (updatedValue != null && fieldSource !is GameFieldUserSource && !areEqual(
|
||||
originalValue,
|
||||
updatedValue
|
||||
) && updatedFieldMetadata != null
|
||||
) {
|
||||
setValue(updatedValue)
|
||||
game.metadata.fields[fieldName] = updatedFieldMetadata
|
||||
wasGameUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
updateField(
|
||||
"title",
|
||||
game.title,
|
||||
updatedGame.title,
|
||||
{ game.title = it },
|
||||
updatedGame.metadata.fields["title"]
|
||||
)
|
||||
|
||||
// Summary
|
||||
updateField(
|
||||
"summary",
|
||||
game.summary,
|
||||
updatedGame.summary,
|
||||
{ game.summary = it },
|
||||
updatedGame.metadata.fields["summary"]
|
||||
)
|
||||
|
||||
// Release
|
||||
updateField(
|
||||
"release",
|
||||
game.release,
|
||||
updatedGame.release,
|
||||
{ game.release = it },
|
||||
updatedGame.metadata.fields["release"]
|
||||
)
|
||||
|
||||
// User Rating
|
||||
updateField(
|
||||
"userRating",
|
||||
game.userRating,
|
||||
updatedGame.userRating,
|
||||
{ game.userRating = it },
|
||||
updatedGame.metadata.fields["userRating"]
|
||||
)
|
||||
|
||||
// Critic Rating
|
||||
updateField(
|
||||
"criticRating",
|
||||
game.criticRating,
|
||||
updatedGame.criticRating,
|
||||
{ game.criticRating = it },
|
||||
updatedGame.metadata.fields["criticRating"]
|
||||
)
|
||||
|
||||
// Cover Image
|
||||
updateField(
|
||||
"coverImage",
|
||||
game.coverImage,
|
||||
updatedGame.coverImage,
|
||||
{ game.coverImage = it },
|
||||
updatedGame.metadata.fields["coverImage"]
|
||||
)
|
||||
|
||||
// Header Image
|
||||
updateField(
|
||||
"headerImage",
|
||||
game.headerImage,
|
||||
updatedGame.headerImage,
|
||||
{ game.headerImage = it },
|
||||
updatedGame.metadata.fields["headerImage"]
|
||||
)
|
||||
|
||||
// Publishers
|
||||
updateField(
|
||||
"publishers",
|
||||
game.publishers,
|
||||
updatedGame.publishers,
|
||||
{ game.publishers = it ?: emptyList() },
|
||||
updatedGame.metadata.fields["publishers"]
|
||||
)
|
||||
|
||||
// Developers
|
||||
updateField(
|
||||
"developers",
|
||||
game.developers,
|
||||
updatedGame.developers,
|
||||
{ game.developers = it ?: emptyList() },
|
||||
updatedGame.metadata.fields["developers"]
|
||||
)
|
||||
|
||||
// Genres
|
||||
updateField(
|
||||
"genres",
|
||||
game.genres,
|
||||
updatedGame.genres,
|
||||
{ game.genres = it ?: emptyList() },
|
||||
updatedGame.metadata.fields["genres"]
|
||||
)
|
||||
|
||||
// Themes
|
||||
updateField(
|
||||
"themes",
|
||||
game.themes,
|
||||
updatedGame.themes,
|
||||
{ game.themes = it ?: emptyList() },
|
||||
updatedGame.metadata.fields["themes"]
|
||||
)
|
||||
|
||||
// Keywords
|
||||
updateField(
|
||||
"keywords",
|
||||
game.keywords,
|
||||
updatedGame.keywords,
|
||||
{ game.keywords = it ?: emptyList() },
|
||||
updatedGame.metadata.fields["keywords"]
|
||||
)
|
||||
|
||||
// Features
|
||||
updateField(
|
||||
"features",
|
||||
game.features,
|
||||
updatedGame.features,
|
||||
{ game.features = it ?: emptyList() },
|
||||
updatedGame.metadata.fields["features"]
|
||||
)
|
||||
|
||||
// Perspectives
|
||||
updateField(
|
||||
"perspectives",
|
||||
game.perspectives,
|
||||
updatedGame.perspectives,
|
||||
{ game.perspectives = it ?: emptyList() },
|
||||
updatedGame.metadata.fields["perspectives"]
|
||||
)
|
||||
|
||||
// Images
|
||||
updateField(
|
||||
"images",
|
||||
game.images,
|
||||
updatedGame.images,
|
||||
{ game.images = it ?: emptyList() },
|
||||
updatedGame.metadata.fields["images"]
|
||||
)
|
||||
|
||||
// Video URLs
|
||||
updateField(
|
||||
"videoUrls",
|
||||
game.videoUrls,
|
||||
updatedGame.videoUrls,
|
||||
{ game.videoUrls = it ?: emptyList() },
|
||||
updatedGame.metadata.fields["videoUrls"]
|
||||
)
|
||||
|
||||
return if (wasGameUpdated) game else null
|
||||
}
|
||||
|
||||
fun delete(gameId: Long) {
|
||||
gameRepository.deleteById(gameId)
|
||||
}
|
||||
@@ -346,7 +549,8 @@ class GameService(
|
||||
originalIds: Map<String, OriginalIdDto>,
|
||||
path: Path,
|
||||
library: Library,
|
||||
replaceGameId: Long? = null
|
||||
replaceGameId: Long? = null,
|
||||
persist: Boolean = true
|
||||
): Game? {
|
||||
// Step 0: Query all metadata plugins for metadata on the provided originalIds
|
||||
val metadataResults = runBlocking {
|
||||
@@ -389,7 +593,7 @@ class GameService(
|
||||
mergedGame.metadata.matchConfirmed = true
|
||||
|
||||
// Step 6: Save the game
|
||||
return create(mergedGame)
|
||||
return if (persist) create(mergedGame) else mergedGame
|
||||
}
|
||||
|
||||
fun matchFromFile(path: Path, library: Library): Game? {
|
||||
|
||||
@@ -10,7 +10,13 @@ class Company(
|
||||
var id: Long? = null,
|
||||
val name: String,
|
||||
val type: CompanyType
|
||||
)
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Company) return false
|
||||
return name == other.name && type == other.type
|
||||
}
|
||||
}
|
||||
|
||||
enum class CompanyType {
|
||||
DEVELOPER,
|
||||
|
||||
@@ -72,7 +72,7 @@ class Game(
|
||||
var features: List<GameFeature> = emptyList(),
|
||||
|
||||
@ElementCollection(targetClass = PlayerPerspective::class)
|
||||
var perspectives: List<PlayerPerspective>? = null,
|
||||
var perspectives: List<PlayerPerspective> = emptyList(),
|
||||
|
||||
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
|
||||
var images: List<Image> = emptyList(),
|
||||
|
||||
@@ -11,9 +11,9 @@ class GameMetadata(
|
||||
var fileSize: Long? = null,
|
||||
|
||||
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.EAGER)
|
||||
var fields: Map<String, GameFieldMetadata> = emptyMap(),
|
||||
var fields: MutableMap<String, GameFieldMetadata> = mutableMapOf(),
|
||||
|
||||
@ElementCollection
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
var originalIds: Map<PluginManagementEntry, String> = emptyMap(),
|
||||
|
||||
var downloadCount: Int = 0,
|
||||
|
||||
@@ -27,7 +27,13 @@ class Image(
|
||||
|
||||
@MimeType
|
||||
var mimeType: String? = null
|
||||
)
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Image) return false
|
||||
return originalUrl.toString() == other.originalUrl.toString()
|
||||
}
|
||||
}
|
||||
|
||||
enum class ImageType {
|
||||
COVER,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.gameyfin.app.libraries
|
||||
|
||||
import org.gameyfin.app.games.GameService
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
import org.gameyfin.app.libraries.dto.DirectoryMappingDto
|
||||
import org.gameyfin.app.libraries.dto.LibraryDto
|
||||
import org.gameyfin.app.libraries.dto.LibraryStatsDto
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Class for shared logic from LibraryService and LibraryScanService.
|
||||
*/
|
||||
@Service
|
||||
class LibraryCoreService(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val gameService: GameService,
|
||||
) {
|
||||
/**
|
||||
* 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, persist: Boolean = false): Library {
|
||||
val newGames = games.filter { game -> library.games.none { it.id == game.id } }
|
||||
library.games.addAll(newGames)
|
||||
|
||||
var removedAnyUnmatchedPaths = false
|
||||
for (game in newGames) {
|
||||
if (library.unmatchedPaths.contains(game.metadata.path)) {
|
||||
library.unmatchedPaths.remove(game.metadata.path)
|
||||
removedAnyUnmatchedPaths = true
|
||||
}
|
||||
}
|
||||
|
||||
if (removedAnyUnmatchedPaths || persist) {
|
||||
library.updatedAt = Instant.now()
|
||||
return libraryRepository.save(library)
|
||||
}
|
||||
|
||||
return library
|
||||
}
|
||||
|
||||
fun deleteGameFromLibrary(gameId: Long) {
|
||||
val game = gameService.getById(gameId)
|
||||
val library = game.library
|
||||
|
||||
library.games.removeIf { it.id == gameId }
|
||||
library.unmatchedPaths.add(game.metadata.path)
|
||||
|
||||
library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
|
||||
libraryRepository.save(library)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts a LibraryDto to a Library entity.
|
||||
*
|
||||
* @param library: The LibraryDto to convert.
|
||||
* @return The converted Library entity.
|
||||
*/
|
||||
fun toEntity(library: LibraryDto): Library {
|
||||
return libraryRepository.findByIdOrNull(library.id) ?: Library(
|
||||
name = library.name,
|
||||
directories = library.directories.distinctBy { it.internalPath }.map {
|
||||
DirectoryMapping(internalPath = it.internalPath, externalPath = it.externalPath)
|
||||
}.toMutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Library.toDto(): LibraryDto {
|
||||
val statsDto = LibraryStatsDto(
|
||||
gamesCount = this.games.size,
|
||||
downloadedGamesCount = this.games.sumOf { it.metadata.downloadCount }
|
||||
)
|
||||
|
||||
return LibraryDto(
|
||||
id = this.id!!,
|
||||
name = this.name,
|
||||
directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
|
||||
games = this.games.mapNotNull { it.id },
|
||||
stats = statsDto,
|
||||
unmatchedPaths = this.unmatchedPaths
|
||||
)
|
||||
}
|
||||
@@ -20,22 +20,23 @@ import reactor.core.publisher.Flux
|
||||
class LibraryEndpoint(
|
||||
private val libraryService: LibraryService,
|
||||
private val userService: UserService,
|
||||
private val libraryScanService: LibraryScanService,
|
||||
) {
|
||||
fun subscribeToLibraryEvents(): Flux<List<LibraryEvent>> {
|
||||
return LibraryService.subscribeToLibraryEvents()
|
||||
}
|
||||
|
||||
fun getAll() = libraryService.getAll()
|
||||
|
||||
|
||||
fun subscribeToScanProgressEvents(): Flux<List<LibraryScanProgress>> {
|
||||
val user = userService.getCurrentUser()
|
||||
return if (user.isAdmin()) LibraryService.subscribeToScanProgressEvents()
|
||||
return if (user.isAdmin()) LibraryScanService.subscribeToScanProgressEvents()
|
||||
else Flux.empty()
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection<LibraryDto>?) =
|
||||
libraryService.triggerScan(scanType, libraries)
|
||||
libraryScanService.triggerScan(scanType, libraries)
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun createLibrary(library: LibraryDto, scanAfterCreation: Boolean = true) =
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
package org.gameyfin.app.libraries
|
||||
|
||||
data class LibraryScanResult(
|
||||
val new: Int,
|
||||
val removed: Int,
|
||||
interface LibraryScanResult {
|
||||
/**
|
||||
* Number of new games found in the library.
|
||||
*/
|
||||
val new: Int
|
||||
|
||||
/**
|
||||
* Number of games removed from the library.
|
||||
*/
|
||||
val removed: Int
|
||||
|
||||
/**
|
||||
* Number of unmatched games that were not found in the library.
|
||||
*/
|
||||
val unmatched: Int
|
||||
)
|
||||
}
|
||||
|
||||
data class QuickScanResult(
|
||||
override val new: Int,
|
||||
override val removed: Int,
|
||||
override val unmatched: Int
|
||||
) : LibraryScanResult
|
||||
|
||||
data class FullScanResult(
|
||||
override val new: Int,
|
||||
override val removed: Int,
|
||||
override val unmatched: Int,
|
||||
/**
|
||||
* Number of games updated in the library.
|
||||
*/
|
||||
val updated: Int
|
||||
) : LibraryScanResult
|
||||
@@ -0,0 +1,437 @@
|
||||
package org.gameyfin.app.libraries
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.gameyfin.app.core.filesystem.FilesystemService
|
||||
import org.gameyfin.app.games.GameService
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
import org.gameyfin.app.libraries.dto.LibraryDto
|
||||
import org.gameyfin.app.libraries.dto.LibraryScanProgress
|
||||
import org.gameyfin.app.libraries.dto.LibraryScanStatus
|
||||
import org.gameyfin.app.libraries.dto.LibraryScanStep
|
||||
import org.gameyfin.app.libraries.enums.ScanType
|
||||
import org.gameyfin.app.libraries.scan.*
|
||||
import org.gameyfin.app.media.ImageService
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Sinks
|
||||
import java.nio.file.Path
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@Service
|
||||
class LibraryScanService(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val filesystemService: FilesystemService,
|
||||
private val libraryCoreService: LibraryCoreService,
|
||||
private val gameService: GameService,
|
||||
private val imageService: ImageService,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
private val SCAN_RESULT_TTL = 24.hours.toJavaDuration()
|
||||
private val scanProgressEvents = Sinks.many().replay().limit<LibraryScanProgress>(SCAN_RESULT_TTL)
|
||||
|
||||
fun subscribeToScanProgressEvents(): Flux<List<LibraryScanProgress>> {
|
||||
log.debug { "New subscription for scanProgressEvents" }
|
||||
return scanProgressEvents.asFlux()
|
||||
.buffer(1.seconds.toJavaDuration())
|
||||
.doOnSubscribe {
|
||||
log.debug { "Subscriber added to scanProgressEvents [${scanProgressEvents.currentSubscriberCount()}]" }
|
||||
}
|
||||
.doFinally {
|
||||
log.debug { "Subscriber removed from scanProgressEvents with signal type $it [${scanProgressEvents.currentSubscriberCount()}]" }
|
||||
}
|
||||
}
|
||||
|
||||
fun emit(scanProgressDto: LibraryScanProgress) {
|
||||
scanProgressEvents.tryEmitNext(scanProgressDto)
|
||||
}
|
||||
|
||||
private val executor = Executors.newVirtualThreadPerTaskExecutor()
|
||||
private val scansInProgress = ConcurrentHashMap<Long, Boolean>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper function to trigger a scan for a list of libraries.
|
||||
*/
|
||||
fun triggerScan(scanType: ScanType, libraryDtos: Collection<LibraryDto>?) {
|
||||
val libraries = libraryDtos?.map { libraryCoreService.toEntity(it) } ?: libraryRepository.findAll()
|
||||
libraries.forEach { library ->
|
||||
val libraryId = library.id!!
|
||||
if (scansInProgress.putIfAbsent(libraryId, true) == null) {
|
||||
executor.submit {
|
||||
try {
|
||||
when (scanType) {
|
||||
ScanType.QUICK -> quickScan(library)
|
||||
ScanType.FULL -> fullScan(library)
|
||||
}
|
||||
} finally {
|
||||
scansInProgress.remove(libraryId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.info { "Scan already in progress for library $libraryId, skipping." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param libraryDtos: List of LibraryDto objects to scan.
|
||||
*/
|
||||
fun quickScan(libraryDtos: Collection<LibraryDto>?) {
|
||||
val libraries = libraryDtos?.map { libraryCoreService.toEntity(it) } ?: libraryRepository.findAll()
|
||||
libraries.forEach { executor.submit { quickScan(it) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a full scan for a list of libraries.
|
||||
* A full scan will rescan all games in the library, including metadata and images.
|
||||
* If no list is provided, all libraries will be scanned.
|
||||
*
|
||||
* @param libraryDtos: List of LibraryDto objects to scan.
|
||||
*/
|
||||
fun fullScan(libraryDtos: Collection<LibraryDto>?) {
|
||||
val libraries = libraryDtos?.map { libraryCoreService.toEntity(it) } ?: libraryRepository.findAll()
|
||||
libraries.forEach { executor.submit { fullScan(it) } }
|
||||
}
|
||||
|
||||
private fun quickScan(library: Library) {
|
||||
val progress = LibraryScanProgress(
|
||||
libraryId = library.id!!,
|
||||
type = ScanType.QUICK,
|
||||
currentStep = LibraryScanStep(
|
||||
description = "Scanning filesystem"
|
||||
)
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val scanResult = filesystemService.scanLibraryForGamefiles(library)
|
||||
val newPaths = scanResult.newPaths
|
||||
val removedGamePaths = scanResult.removedGamePaths.map { it.toString() }
|
||||
val removedUnmatchedPaths = scanResult.removedUnmatchedPaths.map { it.toString() }
|
||||
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Matching new games",
|
||||
current = 0,
|
||||
total = newPaths.size
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
// 1. Match new games
|
||||
val (newUnmatchedPaths, matchedGames) = matchNewGames(library, newPaths, progress)
|
||||
|
||||
val (removedGames) = updateLibrary(
|
||||
library,
|
||||
removedUnmatchedPaths,
|
||||
newUnmatchedPaths,
|
||||
removedGamePaths
|
||||
)
|
||||
|
||||
// 2. Download all images
|
||||
val totalImages = matchedGames.count { it.coverImage != null } +
|
||||
matchedGames.count { it.headerImage !== null } +
|
||||
matchedGames.sumOf { it.images.size }
|
||||
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Downloading images",
|
||||
current = 0,
|
||||
total = totalImages
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val (gamesWithImages) = downloadImages(matchedGames, progress)
|
||||
|
||||
// 3. Calculate game file sizes
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Calculating file sizes",
|
||||
current = 0,
|
||||
total = gamesWithImages.size
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val (gamesWithFileSizes) = calculateFileSizes(gamesWithImages, progress)
|
||||
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Finishing up",
|
||||
current = 0,
|
||||
total = gamesWithFileSizes.size
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val (persistedGames) = finishScan(gamesWithFileSizes, library, progress)
|
||||
|
||||
progress.currentStep = LibraryScanStep(description = "Finished")
|
||||
progress.finishedAt = Instant.now()
|
||||
progress.status = LibraryScanStatus.COMPLETED
|
||||
progress.result = QuickScanResult(
|
||||
new = persistedGames.size,
|
||||
removed = removedGames.size,
|
||||
unmatched = newUnmatchedPaths.size
|
||||
)
|
||||
emit(progress)
|
||||
}
|
||||
|
||||
private fun fullScan(library: Library) {
|
||||
val progress = LibraryScanProgress(
|
||||
libraryId = library.id!!,
|
||||
type = ScanType.FULL,
|
||||
currentStep = LibraryScanStep(
|
||||
description = "Scanning filesystem"
|
||||
)
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val scanResult = filesystemService.scanLibraryForGamefiles(library)
|
||||
val newPaths = scanResult.newPaths
|
||||
val removedGamePaths = scanResult.removedGamePaths.map { it.toString() }
|
||||
val removedUnmatchedPaths = scanResult.removedUnmatchedPaths.map { it.toString() }
|
||||
|
||||
|
||||
// 1. Update existing games
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Updating existing games",
|
||||
current = 0,
|
||||
total = library.games.size
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val (updatedGames) = updateExistingGames(library.games, progress)
|
||||
|
||||
// 2. Match new games
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Matching new games",
|
||||
current = 0,
|
||||
total = newPaths.size
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val (newUnmatchedPaths, newMatchedGames) = matchNewGames(library, newPaths, progress)
|
||||
|
||||
val (removedGames) = updateLibrary(
|
||||
library,
|
||||
removedUnmatchedPaths,
|
||||
newUnmatchedPaths,
|
||||
removedGamePaths
|
||||
)
|
||||
|
||||
// 3. Download all images
|
||||
val newAndUpdatedGames = newMatchedGames + updatedGames
|
||||
|
||||
val totalImages = newAndUpdatedGames.count { it.coverImage != null } +
|
||||
newAndUpdatedGames.count { it.headerImage !== null } +
|
||||
newAndUpdatedGames.sumOf { it.images.size }
|
||||
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Downloading images",
|
||||
current = 0,
|
||||
total = totalImages
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val (gamesWithImages) = downloadImages(newAndUpdatedGames, progress)
|
||||
|
||||
// 4. Calculate game file sizes
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Calculating file sizes",
|
||||
current = 0,
|
||||
total = gamesWithImages.size
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val (gamesWithFileSizes) = calculateFileSizes(gamesWithImages, progress)
|
||||
|
||||
// 5. Finish scan
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Finishing up",
|
||||
current = 0,
|
||||
total = gamesWithFileSizes.size
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val (persistedGames) = finishScan(gamesWithFileSizes, library, progress)
|
||||
|
||||
// 6. Send final progress update
|
||||
progress.currentStep = LibraryScanStep(description = "Finished")
|
||||
progress.finishedAt = Instant.now()
|
||||
progress.status = LibraryScanStatus.COMPLETED
|
||||
progress.result = FullScanResult(
|
||||
new = persistedGames.size,
|
||||
removed = removedGames.size,
|
||||
unmatched = newUnmatchedPaths.size,
|
||||
updated = updatedGames.size
|
||||
)
|
||||
emit(progress)
|
||||
}
|
||||
|
||||
private fun matchNewGames(
|
||||
library: Library,
|
||||
gamePaths: List<Path>,
|
||||
progress: LibraryScanProgress
|
||||
): MatchNewGamesResult {
|
||||
val completedMetadata = AtomicInteger(0)
|
||||
|
||||
// 1. Fetch metadata for each game
|
||||
val newUnmatchedPaths = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val metadataTasks = gamePaths.map { path ->
|
||||
Callable<Game?> {
|
||||
try {
|
||||
val game = gameService.matchFromFile(path, library)
|
||||
|
||||
if (game == null) {
|
||||
newUnmatchedPaths.add(path.toString())
|
||||
return@Callable null
|
||||
}
|
||||
|
||||
return@Callable game
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error processing game: ${e.message}" }
|
||||
newUnmatchedPaths.add(path.toString())
|
||||
|
||||
return@Callable null
|
||||
} finally {
|
||||
progress.currentStep.current = completedMetadata.incrementAndGet()
|
||||
emit(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1.1 Wait for all metadata tasks to complete
|
||||
val matchedGames = executor.invokeAll(metadataTasks).mapNotNull { it.get() }
|
||||
|
||||
return MatchNewGamesResult(
|
||||
unmatchedPaths = newUnmatchedPaths.toList(),
|
||||
matchedGames = matchedGames
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateLibrary(
|
||||
library: Library,
|
||||
removedUnmatchedPaths: List<String>,
|
||||
newUnmatchedPaths: List<String>,
|
||||
removedGamePaths: List<String>
|
||||
): UpdateLibraryResult {
|
||||
// 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 = library.games.filter { removedGamePaths.contains(it.metadata.path) }
|
||||
library.games.removeAll(removedGames)
|
||||
|
||||
return UpdateLibraryResult(removedGames = removedGames)
|
||||
}
|
||||
|
||||
private fun downloadImages(games: List<Game>, progress: LibraryScanProgress): DownloadImagesResult {
|
||||
val completedImageDownload = AtomicInteger(0)
|
||||
|
||||
val imageDownloadTasks = games.map { game ->
|
||||
Callable<Game?> {
|
||||
try {
|
||||
game.coverImage?.let {
|
||||
imageService.downloadIfNew(it)
|
||||
completedImageDownload.andIncrement
|
||||
}
|
||||
|
||||
game.headerImage?.let {
|
||||
imageService.downloadIfNew(it)
|
||||
completedImageDownload.andIncrement
|
||||
}
|
||||
|
||||
game.images.map {
|
||||
imageService.downloadIfNew(it)
|
||||
completedImageDownload.andIncrement
|
||||
}
|
||||
|
||||
game
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error downloading images for game: ${e.message}" }
|
||||
null
|
||||
} finally {
|
||||
progress.currentStep.current = completedImageDownload.get()
|
||||
emit(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val gamesWithImages = executor.invokeAll(imageDownloadTasks).mapNotNull { it.get() }
|
||||
|
||||
return DownloadImagesResult(gamesWithImages = gamesWithImages)
|
||||
}
|
||||
|
||||
private fun calculateFileSizes(games: List<Game>, progress: LibraryScanProgress): CalculateFilesizesResult {
|
||||
val calculatedFileSize = AtomicInteger(0)
|
||||
|
||||
val calculateFileSizeTask = games.map { game ->
|
||||
Callable {
|
||||
game.metadata.path.let { path ->
|
||||
val fileSize = filesystemService.calculateFileSize(path)
|
||||
game.metadata.fileSize = fileSize
|
||||
|
||||
progress.currentStep.current = calculatedFileSize.incrementAndGet()
|
||||
emit(progress)
|
||||
|
||||
game
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val gamesWithFileSizes = executor.invokeAll(calculateFileSizeTask).map { it.get() }
|
||||
|
||||
return CalculateFilesizesResult(gamesWithFilesizes = gamesWithFileSizes)
|
||||
}
|
||||
|
||||
private fun finishScan(games: List<Game>, library: Library, progress: LibraryScanProgress): FinishScanResult {
|
||||
// 4. Persist new games
|
||||
val persistedGames = gameService.create(games)
|
||||
|
||||
progress.currentStep.current = persistedGames.size
|
||||
emit(progress)
|
||||
|
||||
// 5. Add new games to library
|
||||
libraryCoreService.addGamesToLibrary(persistedGames, library)
|
||||
|
||||
// 6. Persist library
|
||||
library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
|
||||
libraryRepository.save(library)
|
||||
|
||||
return FinishScanResult(persistedGames = persistedGames)
|
||||
}
|
||||
|
||||
private fun updateExistingGames(
|
||||
games: List<Game>,
|
||||
progress: LibraryScanProgress
|
||||
): UpdateExistingGamesResult {
|
||||
val completedUpdates = AtomicInteger(0)
|
||||
|
||||
val metadataTasks = games.map { game ->
|
||||
Callable<Game?> {
|
||||
try {
|
||||
val game = gameService.update(game)
|
||||
return@Callable game
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error updating game with id '${game.id}': ${e.message}" }
|
||||
return@Callable null
|
||||
} finally {
|
||||
progress.currentStep.current = completedUpdates.incrementAndGet()
|
||||
emit(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val updatedGames = executor.invokeAll(metadataTasks).mapNotNull { it.get() }
|
||||
return UpdateExistingGamesResult(updatedGames = updatedGames)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,32 @@
|
||||
package org.gameyfin.app.libraries
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.gameyfin.app.core.filesystem.FilesystemService
|
||||
import org.gameyfin.app.games.GameService
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
import org.gameyfin.app.libraries.dto.*
|
||||
import org.gameyfin.app.libraries.dto.LibraryDto
|
||||
import org.gameyfin.app.libraries.dto.LibraryEvent
|
||||
import org.gameyfin.app.libraries.dto.LibraryUpdateDto
|
||||
import org.gameyfin.app.libraries.enums.ScanType
|
||||
import org.gameyfin.app.media.ImageService
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Sinks
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@Service
|
||||
class LibraryService(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val filesystemService: FilesystemService,
|
||||
private val libraryCoreService: LibraryCoreService,
|
||||
private val libraryScanService: LibraryScanService,
|
||||
private val gameService: GameService,
|
||||
private val imageService: ImageService
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val log = KotlinLogging.logger {}
|
||||
private val executor = Executors.newVirtualThreadPerTaskExecutor()
|
||||
private val SCAN_RESULT_TTL = 24.hours.toJavaDuration()
|
||||
|
||||
/* Websockets */
|
||||
private val libraryEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryEvent>(1024, false)
|
||||
private val scanProgressEvents = Sinks.many().replay().limit<LibraryScanProgress>(SCAN_RESULT_TTL)
|
||||
|
||||
fun subscribeToLibraryEvents(): Flux<List<LibraryEvent>> {
|
||||
log.debug { "New subscription for libraryEvents" }
|
||||
@@ -50,25 +40,9 @@ class LibraryService(
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribeToScanProgressEvents(): Flux<List<LibraryScanProgress>> {
|
||||
log.debug { "New subscription for scanProgressEvents" }
|
||||
return scanProgressEvents.asFlux()
|
||||
.buffer(1.seconds.toJavaDuration())
|
||||
.doOnSubscribe {
|
||||
log.debug { "Subscriber added to scanProgressEvents [${scanProgressEvents.currentSubscriberCount()}]" }
|
||||
}
|
||||
.doFinally {
|
||||
log.debug { "Subscriber removed from scanProgressEvents with signal type $it [${scanProgressEvents.currentSubscriberCount()}]" }
|
||||
}
|
||||
}
|
||||
|
||||
fun emit(event: LibraryEvent) {
|
||||
libraryEvents.tryEmitNext(event)
|
||||
}
|
||||
|
||||
fun emit(scanProgressDto: LibraryScanProgress) {
|
||||
scanProgressEvents.tryEmitNext(scanProgressDto)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,10 +70,10 @@ class LibraryService(
|
||||
* @return The created or updated LibraryDto object.
|
||||
*/
|
||||
fun create(library: LibraryDto, scanAfterCreation: Boolean) {
|
||||
val newLibrary = libraryRepository.save(toEntity(library))
|
||||
val newLibrary = libraryRepository.save(libraryCoreService.toEntity(library))
|
||||
|
||||
if (scanAfterCreation) {
|
||||
triggerScanSingleLibrary(ScanType.QUICK, newLibrary)
|
||||
libraryScanService.triggerScan(ScanType.QUICK, listOf(newLibrary.toDto()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,264 +113,4 @@ class LibraryService(
|
||||
fun delete(libraryId: Long) {
|
||||
libraryRepository.deleteById(libraryId)
|
||||
}
|
||||
|
||||
fun deleteGameFromLibrary(gameId: Long) {
|
||||
val game = gameService.getById(gameId)
|
||||
val library = game.library
|
||||
|
||||
library.games.removeIf { it.id == gameId }
|
||||
library.unmatchedPaths.add(game.metadata.path)
|
||||
|
||||
library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
|
||||
libraryRepository.save(library)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper function to trigger a scan for a list of libraries.
|
||||
*/
|
||||
fun triggerScan(scanType: ScanType, libraryDtos: Collection<LibraryDto>?) {
|
||||
executor.submit {
|
||||
when (scanType) {
|
||||
ScanType.QUICK -> quickScan(libraryDtos)
|
||||
ScanType.FULL -> TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerScanSingleLibrary(scanType: ScanType, library: Library) {
|
||||
triggerScan(scanType, listOf(library.toDto()))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param libraryDtos: List of LibraryDto objects to scan.
|
||||
*/
|
||||
fun quickScan(libraryDtos: Collection<LibraryDto>?) {
|
||||
val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll()
|
||||
libraries.forEach { executor.submit { quickScan(it) } }
|
||||
}
|
||||
|
||||
fun quickScan(library: Library) {
|
||||
|
||||
val progress = LibraryScanProgress(
|
||||
libraryId = library.id!!,
|
||||
currentStep = LibraryScanStep(
|
||||
description = "Scanning filesystem"
|
||||
)
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
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 completedMetadata = AtomicInteger(0)
|
||||
val completedImageDownload = AtomicInteger(0)
|
||||
val calculatedFileSize = AtomicInteger(0)
|
||||
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Matching games",
|
||||
current = 0,
|
||||
total = totalPaths
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
// 1. Fetch metadata for each game
|
||||
val newUnmatchedPaths = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val metadataTasks = gamePaths.map { path ->
|
||||
Callable<Game?> {
|
||||
try {
|
||||
val game = gameService.matchFromFile(path, library)
|
||||
|
||||
if (game == null) {
|
||||
newUnmatchedPaths.add(path.toString())
|
||||
return@Callable null
|
||||
}
|
||||
|
||||
progress.currentStep.current = completedMetadata.incrementAndGet()
|
||||
emit(progress)
|
||||
|
||||
return@Callable game
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error processing game: ${e.message}" }
|
||||
newUnmatchedPaths.add(path.toString())
|
||||
|
||||
return@Callable null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1.1 Wait for all metadata tasks to complete
|
||||
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 = library.games.filter { removedGamePaths.contains(it.metadata.path) }
|
||||
library.games.removeAll(removedGames)
|
||||
|
||||
// 2. Download all images
|
||||
val totalImages = matchedGames.count { it.coverImage != null } +
|
||||
matchedGames.count { it.headerImage !== null } +
|
||||
matchedGames.sumOf { it.images.size }
|
||||
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Downloading images",
|
||||
current = 0,
|
||||
total = totalImages
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val imageDownloadTasks = matchedGames.map { game ->
|
||||
Callable<Game?> {
|
||||
try {
|
||||
game.coverImage?.let {
|
||||
imageService.downloadIfNew(it)
|
||||
completedImageDownload.andIncrement
|
||||
}
|
||||
|
||||
game.headerImage?.let {
|
||||
imageService.downloadIfNew(it)
|
||||
completedImageDownload.andIncrement
|
||||
}
|
||||
|
||||
game.images.map {
|
||||
imageService.downloadIfNew(it)
|
||||
completedImageDownload.andIncrement
|
||||
}
|
||||
|
||||
progress.currentStep.current = completedImageDownload.get()
|
||||
emit(progress)
|
||||
|
||||
game
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error downloading images for game: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val gamesWithImages = executor.invokeAll(imageDownloadTasks).mapNotNull { it.get() }
|
||||
|
||||
|
||||
// 3. Calculate game file sizes
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Calculating file sizes",
|
||||
current = 0,
|
||||
total = gamesWithImages.size
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val calculateFileSizeTask = gamesWithImages.map { game ->
|
||||
Callable {
|
||||
game.metadata.path.let { path ->
|
||||
val fileSize = filesystemService.calculateFileSize(path)
|
||||
game.metadata.fileSize = fileSize
|
||||
|
||||
progress.currentStep.current = calculatedFileSize.incrementAndGet()
|
||||
emit(progress)
|
||||
|
||||
game
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val gamesWithFileSizes = executor.invokeAll(calculateFileSizeTask).map { it.get() }
|
||||
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Finishing up",
|
||||
current = 0,
|
||||
total = gamesWithFileSizes.size
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
// 4. Persist new games
|
||||
val persistedGames = gameService.create(gamesWithFileSizes)
|
||||
|
||||
progress.currentStep.current = persistedGames.size
|
||||
emit(progress)
|
||||
|
||||
// 5. Add new games to library
|
||||
addGamesToLibrary(persistedGames, library)
|
||||
|
||||
// 6. Persist library
|
||||
library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
|
||||
libraryRepository.save(library)
|
||||
|
||||
progress.currentStep = LibraryScanStep(description = "Finished")
|
||||
progress.finishedAt = Instant.now()
|
||||
progress.status = LibraryScanStatus.COMPLETED
|
||||
progress.result = LibraryScanResult(
|
||||
new = persistedGames.size,
|
||||
removed = removedGames.size,
|
||||
unmatched = newUnmatchedPaths.size
|
||||
)
|
||||
emit(progress)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, persist: Boolean = false): Library {
|
||||
val newGames = games.filter { game -> library.games.none { it.id == game.id } }
|
||||
library.games.addAll(newGames)
|
||||
|
||||
var removedAnyUnmatchedPaths = false
|
||||
for (game in newGames) {
|
||||
if (library.unmatchedPaths.contains(game.metadata.path)) {
|
||||
library.unmatchedPaths.remove(game.metadata.path)
|
||||
removedAnyUnmatchedPaths = true
|
||||
}
|
||||
}
|
||||
|
||||
if (removedAnyUnmatchedPaths || persist) {
|
||||
library.updatedAt = Instant.now()
|
||||
return libraryRepository.save(library)
|
||||
}
|
||||
|
||||
return library
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.distinctBy { it.internalPath }.map {
|
||||
DirectoryMapping(internalPath = it.internalPath, externalPath = it.externalPath)
|
||||
}.toMutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Library.toDto(): LibraryDto {
|
||||
val statsDto = LibraryStatsDto(
|
||||
gamesCount = this.games.size,
|
||||
downloadedGamesCount = this.games.sumOf { it.metadata.downloadCount }
|
||||
)
|
||||
|
||||
return LibraryDto(
|
||||
id = this.id!!,
|
||||
name = this.name,
|
||||
directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
|
||||
games = this.games.mapNotNull { it.id },
|
||||
stats = statsDto,
|
||||
unmatchedPaths = this.unmatchedPaths
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package org.gameyfin.app.libraries.dto
|
||||
|
||||
import org.gameyfin.app.libraries.LibraryScanResult
|
||||
import org.gameyfin.app.libraries.enums.ScanType
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
data class LibraryScanProgress(
|
||||
val scanId: UUID = UUID.randomUUID(),
|
||||
val libraryId: Long,
|
||||
val type: ScanType,
|
||||
var status: LibraryScanStatus = LibraryScanStatus.IN_PROGRESS,
|
||||
var currentStep: LibraryScanStep,
|
||||
val startedAt: Instant = Instant.now(),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.gameyfin.app.libraries.scan
|
||||
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
|
||||
data class CalculateFilesizesResult(
|
||||
val gamesWithFilesizes: List<Game>,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.gameyfin.app.libraries.scan
|
||||
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
|
||||
data class DownloadImagesResult(
|
||||
val gamesWithImages: List<Game>
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.gameyfin.app.libraries.scan
|
||||
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
|
||||
data class FinishScanResult(
|
||||
val persistedGames: List<Game>,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.gameyfin.app.libraries.scan
|
||||
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
|
||||
data class MatchNewGamesResult(
|
||||
val unmatchedPaths: List<String>,
|
||||
val matchedGames: List<Game>
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.gameyfin.app.libraries.scan
|
||||
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
|
||||
data class UpdateExistingGamesResult(
|
||||
val updatedGames: List<Game>
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.gameyfin.app.libraries.scan
|
||||
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
|
||||
data class UpdateLibraryResult(
|
||||
val removedGames: List<Game>
|
||||
)
|
||||
Reference in New Issue
Block a user