From 123e8889230222a05f83312cae1f90daf82e25e7 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Tue, 27 May 2025 17:05:04 +0200 Subject: [PATCH] Implement realtime UI for scans --- gameyfin/src/main/frontend/App.tsx | 2 + .../general/ScanProgressPopover.tsx | 94 ++++++ .../modals/PasswortResetTokenModal.tsx | 6 +- .../src/main/frontend/state/LibraryState.ts | 5 +- gameyfin/src/main/frontend/state/ScanState.ts | 57 ++++ gameyfin/src/main/frontend/util/utils.ts | 28 +- .../src/main/frontend/views/MainLayout.tsx | 9 +- .../gameyfin/libraries/LibraryEndpoint.kt | 15 +- .../gameyfin/libraries/LibraryScanResult.kt | 9 +- .../gameyfin/libraries/LibraryService.kt | 285 ++++++++++-------- .../libraries/dto/LibraryScanProgress.kt | 27 ++ 11 files changed, 399 insertions(+), 138 deletions(-) create mode 100644 gameyfin/src/main/frontend/components/general/ScanProgressPopover.tsx create mode 100644 gameyfin/src/main/frontend/state/ScanState.ts create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/dto/LibraryScanProgress.kt diff --git a/gameyfin/src/main/frontend/App.tsx b/gameyfin/src/main/frontend/App.tsx index 349cd56..26a580d 100644 --- a/gameyfin/src/main/frontend/App.tsx +++ b/gameyfin/src/main/frontend/App.tsx @@ -11,6 +11,7 @@ import client from "Frontend/generated/connect-client.default"; import {ErrorHandlingMiddleware} from "Frontend/util/middleware"; import {initializeLibraryState} from "Frontend/state/LibraryState"; import {initializeGameState} from "Frontend/state/GameState"; +import {initializeScanState} from "Frontend/state/ScanState"; export default function App() { const navigate = useNavigate(); @@ -19,6 +20,7 @@ export default function App() { initializeLibraryState(); initializeGameState(); + initializeScanState(); return ( diff --git a/gameyfin/src/main/frontend/components/general/ScanProgressPopover.tsx b/gameyfin/src/main/frontend/components/general/ScanProgressPopover.tsx new file mode 100644 index 0000000..42e80a9 --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/ScanProgressPopover.tsx @@ -0,0 +1,94 @@ +import { + Button, + Divider, + Link, + Popover, + PopoverContent, + PopoverTrigger, + Progress, + ScrollShadow, + Spinner +} from "@heroui/react"; +import {useSnapshot} from "valtio/react"; +import {clear, scanState} from "Frontend/state/ScanState"; +import LibraryScanProgress from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryScanProgress"; +import {libraryState} from "Frontend/state/LibraryState"; +import {Target} from "@phosphor-icons/react"; +import {timeUntil} from "Frontend/util/utils"; +import LibraryScanStatus from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryScanStatus"; + +export default function ScanProgressPopover() { + const libraries = useSnapshot(libraryState).state; + const scans = useSnapshot(scanState).sortedByStartTime as LibraryScanProgress[]; + const scanInProgress = useSnapshot(scanState).isScanning; + + return ( + + + + + +
+ {scans.length === 0 ? +

+ No scans in progress. +

: +
+ + Clear + + + {scans.map((scan, index) => +
+
+

Scan for library  + + {libraries[scan.libraryId].name} + +

+ {scan.finishedAt ? +

Finished {timeUntil(scan.finishedAt)}

: +

Started {timeUntil(scan.startedAt)}

+ } +
+ {scan.status === LibraryScanStatus.IN_PROGRESS ? + scan.currentStep.current && scan.currentStep.total ? +
+

+ {`${scan.currentStep.description} (${scan.currentStep.current} / ${scan.currentStep.total})`} +

+ +
: +
+

{scan.currentStep.description}

+ +
+ : +

+ {scan.result?.new} new /  + {scan.result?.removed} removed /  + {scan.result?.unmatched} unmatched +

+ } + {scans.length > 1 && index < (scans.length - 1) && } +
+ )} +
+
+ } +
+
+
+ ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/modals/PasswortResetTokenModal.tsx b/gameyfin/src/main/frontend/components/general/modals/PasswortResetTokenModal.tsx index e480485..5a6ca04 100644 --- a/gameyfin/src/main/frontend/components/general/modals/PasswortResetTokenModal.tsx +++ b/gameyfin/src/main/frontend/components/general/modals/PasswortResetTokenModal.tsx @@ -43,12 +43,12 @@ export default function PasswordResetTokenModal({isOpen, onOpenChange, token}: P {passwordResetLink()} { - !timeUntilExpiry.startsWith("-") + !timeUntilExpiry.endsWith("ago") ? - This link will expire in {timeUntilExpiry} + This link will expire {timeUntilExpiry} : - This link has expired {timeUntilExpiry.substring(1)} ago + This link has expired {timeUntilExpiry} } diff --git a/gameyfin/src/main/frontend/state/LibraryState.ts b/gameyfin/src/main/frontend/state/LibraryState.ts index f1365ce..5f11f8c 100644 --- a/gameyfin/src/main/frontend/state/LibraryState.ts +++ b/gameyfin/src/main/frontend/state/LibraryState.ts @@ -3,6 +3,7 @@ import {proxy} from "valtio/index"; import {LibraryEndpoint} from "Frontend/generated/endpoints"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import LibraryEvent from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryEvent"; +import {handleLibraryDeletion} from "./ScanState"; type LibraryState = { subscription?: Subscription; @@ -39,7 +40,7 @@ export async function initializeLibraryState() { }); // Subscribe to real-time updates - libraryState.subscription = LibraryEndpoint.subscribe().onNext((libraryEvents: LibraryEvent[]) => { + libraryState.subscription = LibraryEndpoint.subscribeToLibraryEvents().onNext((libraryEvents: LibraryEvent[]) => { libraryEvents.forEach((libraryEvent: LibraryEvent) => { switch (libraryEvent.type) { case "created": @@ -48,6 +49,8 @@ export async function initializeLibraryState() { libraryState.state[libraryEvent.library.id] = libraryEvent.library; break; case "deleted": + //@ts-ignore + handleLibraryDeletion(libraryEvent.libraryId); //@ts-ignore delete libraryState.state[libraryEvent.libraryId]; break; diff --git a/gameyfin/src/main/frontend/state/ScanState.ts b/gameyfin/src/main/frontend/state/ScanState.ts new file mode 100644 index 0000000..d4e3c6d --- /dev/null +++ b/gameyfin/src/main/frontend/state/ScanState.ts @@ -0,0 +1,57 @@ +import {proxy} from 'valtio'; +import type LibraryScanProgress from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryScanProgress"; +import {LibraryEndpoint} from "Frontend/generated/endpoints"; +import {Subscription} from "@vaadin/hilla-frontend"; +import LibraryScanStatus from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryScanStatus"; +import {libraryState} from "Frontend/state/LibraryState"; + +type ScanState = { + subscription?: Subscription; + state: Record; + hasContent: boolean, + isScanning: boolean, + sortedByStartTime: LibraryScanProgress[]; +}; + +export const scanState = proxy({ + state: {}, + get hasContent(): boolean { + return Object.values(this.state).length > 0; + }, + get isScanning(): boolean { + return Object.values(this.state) + .some((scanProgress: LibraryScanProgress) => scanProgress.status === LibraryScanStatus.IN_PROGRESS); + }, + get sortedByStartTime(): LibraryScanProgress[] { + return Object.values(this.state).sort((a: LibraryScanProgress, b: LibraryScanProgress) => { + return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(); + }); + } +}); + +/** Subscribe to and process state updates from backend **/ +export function initializeScanState() { + if (scanState.subscription) return; + + // Subscribe to real-time updates + scanState.subscription = LibraryEndpoint.subscribeToScanProgressEvents().onNext((scanProgresses: LibraryScanProgress[]) => { + scanProgresses.forEach((scanProgress: LibraryScanProgress) => { + // Filter out scans for libraries that are not in the current state + if (!libraryState.state[scanProgress.libraryId]) return; + + scanState.state[scanProgress.scanId] = scanProgress; + }) + }); +} + +export function clear() { + scanState.state = {}; +} + +export function handleLibraryDeletion(libraryId: number) { + for (const scanId in scanState.state) { + if (scanState.state[scanId].libraryId === libraryId) { + delete scanState.state[scanId]; + } + } +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/util/utils.ts b/gameyfin/src/main/frontend/util/utils.ts index a056083..3f471d6 100644 --- a/gameyfin/src/main/frontend/util/utils.ts +++ b/gameyfin/src/main/frontend/util/utils.ts @@ -68,13 +68,39 @@ export function timeUntil(instantString: string, timeZone: string = moment.tz.gu for (const unit of units) { const value = Math.floor(absDiffInSeconds / unit.seconds); if (value >= 1) { - return `${isPast ? '-' : ''}${value} ${unit.name}${value > 1 ? 's' : ''}`; + return `${isPast ? '' : 'in'} ${value} ${unit.name}${value > 1 ? 's' : ''} ${isPast ? 'ago' : ''}`; } } return "just now"; } +export function timeBetween(start: string, end: string, timeZone: string = moment.tz.guess()): string { + const startDate = moment.tz(start, timeZone); + const endDate = moment.tz(end, timeZone); + const diffInSeconds = startDate.diff(endDate, 'seconds'); + + const units = [ + {name: "year", seconds: 31536000}, + {name: "month", seconds: 2592000}, + {name: "day", seconds: 86400}, + {name: "hour", seconds: 3600}, + {name: "minute", seconds: 60}, + {name: "second", seconds: 1} + ]; + + const absDiffInSeconds = Math.abs(diffInSeconds); + + for (const unit of units) { + const value = Math.floor(absDiffInSeconds / unit.seconds); + if (value >= 1) { + return `${value} ${unit.name}${value > 1 ? 's' : ''}`; + } + } + + return "under a second"; +} + /** * Format bytes as human-readable text. * diff --git a/gameyfin/src/main/frontend/views/MainLayout.tsx b/gameyfin/src/main/frontend/views/MainLayout.tsx index 2d1e589..a014f66 100644 --- a/gameyfin/src/main/frontend/views/MainLayout.tsx +++ b/gameyfin/src/main/frontend/views/MainLayout.tsx @@ -13,6 +13,8 @@ import {UserPreferenceService} from "Frontend/util/user-preference-service"; import SearchBar from "Frontend/components/general/SearchBar"; import {useSnapshot} from "valtio/react"; import {gameState} from "Frontend/state/GameState"; +import {scanState} from "Frontend/state/ScanState"; +import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover"; export default function MainLayout() { const navigate = useNavigate(); @@ -24,6 +26,7 @@ export default function MainLayout() { const isHomePage = location.pathname === "/"; const [isExploding, setIsExploding] = useState(false); const games = useSnapshot(gameState).games; + const scans = useSnapshot(scanState); useEffect(() => { let newTitle = `Gameyfin - ${routeMetadata?.title}`; @@ -96,12 +99,10 @@ export default function MainLayout() { } - {auth.state.user?.emailConfirmed === false ? + {auth.state.user?.roles?.some(a => a?.includes("ADMIN")) && - Please confirm your email + - : - "" } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt index c86f87d..e9de6c5 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt @@ -4,10 +4,14 @@ import com.vaadin.hilla.Endpoint import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.libraries.dto.LibraryDto import de.grimsi.gameyfin.libraries.dto.LibraryEvent +import de.grimsi.gameyfin.libraries.dto.LibraryScanProgress import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto import de.grimsi.gameyfin.libraries.enums.ScanType +import de.grimsi.gameyfin.users.util.isAdmin import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetails import reactor.core.publisher.Flux @Endpoint @@ -15,12 +19,19 @@ import reactor.core.publisher.Flux class LibraryEndpoint( private val libraryService: LibraryService ) { - fun subscribe(): Flux> { - return LibraryService.subscribe() + fun subscribeToLibraryEvents(): Flux> { + return LibraryService.subscribeToLibraryEvents() } fun getAll() = libraryService.getAll() + + fun subscribeToScanProgressEvents(): Flux> { + val user = SecurityContextHolder.getContext().authentication.principal as UserDetails + return if (user.isAdmin()) LibraryService.subscribeToScanProgressEvents() + else Flux.empty() + } + @RolesAllowed(Role.Names.ADMIN) fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection?) = libraryService.triggerScan(scanType, libraries) diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryScanResult.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryScanResult.kt index 2035c20..8b389ca 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryScanResult.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryScanResult.kt @@ -1,10 +1,7 @@ package de.grimsi.gameyfin.libraries -import de.grimsi.gameyfin.games.entities.Game - data class LibraryScanResult( - val libraries: List, - val newGames: List, - val removedGames: List, - val newUnmatchedPaths: List + val new: Int, + val removed: Int, + val unmatched: Int ) diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt index 6ab817b..e5659d2 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt @@ -15,8 +15,9 @@ 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.measureTimedValue +import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @Service @@ -30,11 +31,13 @@ class LibraryService( 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(1024, false) + private val scanProgressEvents = Sinks.many().replay().limit(SCAN_RESULT_TTL) - fun subscribe(): Flux> { + fun subscribeToLibraryEvents(): Flux> { log.debug { "New subscription for libraryEvents" } return libraryEvents.asFlux() .buffer(100.milliseconds.toJavaDuration()) @@ -46,9 +49,25 @@ class LibraryService( } } + fun subscribeToScanProgressEvents(): Flux> { + 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) + } } @@ -67,7 +86,7 @@ class LibraryService( * @return The created or updated LibraryDto object. */ fun create(library: LibraryDto) { - val entity = libraryRepository.save(toEntity(library)) + libraryRepository.save(toEntity(library)) } /** @@ -106,23 +125,12 @@ class LibraryService( * Wrapper function to trigger a scan for a list of libraries. */ fun triggerScan(scanType: ScanType, libraryDtos: Collection?) { - val scanResult = measureTimedValue { + executor.submit { 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() - } } /** @@ -132,129 +140,164 @@ class LibraryService( * * @param libraryDtos: List of LibraryDto objects to scan. */ - fun quickScan(libraryDtos: Collection?): LibraryScanResult { + fun quickScan(libraryDtos: Collection?) { val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll() + libraries.forEach { executor.submit { quickScan(it) } } + } - val scanResults: List = libraries.map { library -> - val scanResult = filesystemService.scanLibraryForGamefiles(library) - val gamePaths = scanResult.newPaths - val removedGamePaths = scanResult.removedGamePaths.map { it.toString() } - val removedUnmatchedPaths = scanResult.removedUnmatchedPaths.map { it.toString() } + fun quickScan(library: Library) { - val totalPaths = gamePaths.size - val completedMetadata = AtomicInteger(0) - val completedImageDownload = AtomicInteger(0) - val calculatedFileSize = AtomicInteger(0) + val progress = LibraryScanProgress( + libraryId = library.id!!, + currentStep = LibraryScanStep( + description = "Scanning filesystem" + ) + ) + emit(progress) - log.info { "Scanning library '${library.name}' with $totalPaths paths..." } + val scanResult = filesystemService.scanLibraryForGamefiles(library) + val gamePaths = scanResult.newPaths + val removedGamePaths = scanResult.removedGamePaths.map { it.toString() } + val removedUnmatchedPaths = scanResult.removedUnmatchedPaths.map { it.toString() } - // 1. Fetch metadata for each game - val newUnmatchedPaths = ConcurrentHashMap.newKeySet() + val totalPaths = gamePaths.size + val completedMetadata = AtomicInteger(0) + val completedImageDownload = AtomicInteger(0) + val calculatedFileSize = AtomicInteger(0) - val metadataTasks = gamePaths.map { path -> - Callable { - try { - val game = gameService.matchFromFile(path, library) + progress.currentStep = LibraryScanStep( + description = "Matching games", + current = 0, + total = totalPaths + ) + emit(progress) - if (game == null) { - newUnmatchedPaths.add(path.toString()) - return@Callable null - } + // 1. Fetch metadata for each game + val newUnmatchedPaths = ConcurrentHashMap.newKeySet() - val progress = completedMetadata.incrementAndGet() - log.debug { "${progress}/${totalPaths} metadata matched" } + val metadataTasks = gamePaths.map { path -> + Callable { + try { + val game = gameService.matchFromFile(path, library) - return@Callable game - } catch (e: Exception) { - log.error(e) { "Error processing game: ${e.message}" } + 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 = gameService.getAllByPaths(removedGamePaths) - library.games.removeAll(removedGames) - - // 2. Download all images - val totalImages = matchedGames.count { it.coverImage != null } + matchedGames.sumOf { it.images.size } - - val imageDownloadTasks = matchedGames.map { game -> - Callable { - try { - game.coverImage?.let { - imageService.downloadIfNew(it) - completedImageDownload.andIncrement - } - - game.images.map { - imageService.downloadIfNew(it) - completedImageDownload.andIncrement - } - - log.debug { "${completedImageDownload}/${totalImages} images downloaded" } - - 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 - val calculateFileSizeTask = matchedGames.map { game -> - Callable { - game.path.let { path -> - val fileSize = filesystemService.calculateFileSize(path) - game.fileSize = fileSize - val progress = calculatedFileSize.incrementAndGet() - log.debug { "${progress}/${totalPaths} file sizes calculated" } - game - } - } - } - - val gamesWithFileSizes = executor.invokeAll(calculateFileSizeTask).map { it.get() } - - - // 4. Persist new games - val persistedGames = gameService.create(gamesWithImages) - log.debug { "${persistedGames.size}/${totalPaths} saved to database" } - - // 5. Add new games to library - addGamesToLibrary(persistedGames, library) - - // 6. Persist library - libraryRepository.save(library) - - return LibraryScanResult( - libraries = listOf(library), - newGames = persistedGames, - removedGames = removedGames, - newUnmatchedPaths = newUnmatchedPaths.toList() - ) } - 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 - ) + // 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 = gameService.getAllByPaths(removedGamePaths) + library.games.removeAll(removedGames) + + // 2. Download all images + val totalImages = matchedGames.count { it.coverImage != null } + matchedGames.sumOf { it.images.size } + + progress.currentStep = LibraryScanStep( + description = "Downloading images", + current = 0, + total = totalImages + ) + emit(progress) + + val imageDownloadTasks = matchedGames.map { game -> + Callable { + try { + game.coverImage?.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.path.let { path -> + val fileSize = filesystemService.calculateFileSize(path) + game.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 + libraryRepository.save(library) + + progress.currentStep = LibraryScanStep(description = "Finished") + progress.finishedAt = java.time.Instant.now() + progress.status = LibraryScanStatus.COMPLETED + progress.result = LibraryScanResult( + new = persistedGames.size, + removed = removedGames.size, + unmatched = removedUnmatchedPaths.size + ) + emit(progress) } /** diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/dto/LibraryScanProgress.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/dto/LibraryScanProgress.kt new file mode 100644 index 0000000..cc4ebf0 --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/dto/LibraryScanProgress.kt @@ -0,0 +1,27 @@ +package de.grimsi.gameyfin.libraries.dto + +import de.grimsi.gameyfin.libraries.LibraryScanResult +import java.time.Instant +import java.util.* + +data class LibraryScanProgress( + val scanId: UUID = UUID.randomUUID(), + val libraryId: Long, + var status: LibraryScanStatus = LibraryScanStatus.IN_PROGRESS, + var currentStep: LibraryScanStep, + val startedAt: Instant = Instant.now(), + var finishedAt: Instant? = null, + var result: LibraryScanResult? = null +) + +data class LibraryScanStep( + val description: String, + var current: Int? = null, + var total: Int? = null +) + +enum class LibraryScanStatus { + IN_PROGRESS, + COMPLETED, + FAILED +} \ No newline at end of file