Implement realtime UI for scans

This commit is contained in:
grimsi
2025-05-27 17:05:04 +02:00
parent e47543e2ce
commit 123e888923
11 changed files with 399 additions and 138 deletions
+2
View File
@@ -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 (
<HeroUIProvider className="size-full" navigate={navigate} useHref={useHref}>
@@ -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 (
<Popover placement="bottom-end" showArrow={true}>
<PopoverTrigger>
<Button isIconOnly variant="light">
{scanInProgress ?
<Spinner size="sm" color="default" variant="simple"/> :
<Target/>
}
</Button>
</PopoverTrigger>
<PopoverContent>
<div className="flex flex-col gap-2 m-2 w-96">
{scans.length === 0 ?
<p className="flex h-12 items-center justify-center text-sm text-default-500">
No scans in progress.
</p> :
<div className="flex flex-col gap-4">
<Link underline="always" size="sm" href="#" onPress={clear} className="justify-end">
Clear
</Link>
<ScrollShadow hideScrollBar className="max-h-96">
{scans.map((scan, index) =>
<div className="flex flex-col">
<div
className="flex flex-row justify-between items-center text-default-500 mb-1">
<p>Scan for library&nbsp;
<Link underline="always"
color="foreground"
size="sm"
href={`/administration/libraries/library/${scan.libraryId}`}>
{libraries[scan.libraryId].name}
</Link>
</p>
{scan.finishedAt ?
<p className="text-default-500">Finished {timeUntil(scan.finishedAt)}</p> :
<p className="text-default-500">Started {timeUntil(scan.startedAt)}</p>
}
</div>
{scan.status === LibraryScanStatus.IN_PROGRESS ?
scan.currentStep.current && scan.currentStep.total ?
<div>
<p className="text-default-500">
{`${scan.currentStep.description} (${scan.currentStep.current} / ${scan.currentStep.total})`}
</p>
<Progress
value={scan.currentStep.current / scan.currentStep.total * 100}
size="sm"/>
</div> :
<div>
<p className="text-default-500">{scan.currentStep.description}</p>
<Progress isIndeterminate size="sm"/>
</div>
:
<p>
{scan.result?.new} new /&nbsp;
{scan.result?.removed} removed /&nbsp;
{scan.result?.unmatched} unmatched
</p>
}
{scans.length > 1 && index < (scans.length - 1) && <Divider className="my-2"/>}
</div>
)}
</ScrollShadow>
</div>
}
</div>
</PopoverContent>
</Popover>
);
}
@@ -43,12 +43,12 @@ export default function PasswordResetTokenModal({isOpen, onOpenChange, token}: P
<ModalBody>
<Snippet symbol="">{passwordResetLink()}</Snippet>
{
!timeUntilExpiry.startsWith("-")
!timeUntilExpiry.endsWith("ago")
? <small className="text-warning">
This link will expire in {timeUntilExpiry}
This link will expire {timeUntilExpiry}
</small>
: <small className="text-danger">
This link has expired {timeUntilExpiry.substring(1)} ago
This link has expired {timeUntilExpiry}
</small>
}
</ModalBody>
@@ -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<LibraryEvent[]>;
@@ -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;
@@ -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<LibraryScanProgress[]>;
state: Record<string, LibraryScanProgress>;
hasContent: boolean,
isScanning: boolean,
sortedByStartTime: LibraryScanProgress[];
};
export const scanState = proxy<ScanState>({
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];
}
}
}
+27 -1
View File
@@ -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.
*
@@ -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() {
</Tooltip>
</NavbarContent>}
<NavbarContent justify="end">
{auth.state.user?.emailConfirmed === false ?
{auth.state.user?.roles?.some(a => a?.includes("ADMIN")) &&
<NavbarItem>
<small className="text-warning">Please confirm your email</small>
<ScanProgressPopover/>
</NavbarItem>
:
""
}
<NavbarItem>
<ProfileMenu/>
@@ -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<List<LibraryEvent>> {
return LibraryService.subscribe()
fun subscribeToLibraryEvents(): Flux<List<LibraryEvent>> {
return LibraryService.subscribeToLibraryEvents()
}
fun getAll() = libraryService.getAll()
fun subscribeToScanProgressEvents(): Flux<List<LibraryScanProgress>> {
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<LibraryDto>?) =
libraryService.triggerScan(scanType, libraries)
@@ -1,10 +1,7 @@
package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.games.entities.Game
data class LibraryScanResult(
val libraries: List<Library>,
val newGames: List<Game>,
val removedGames: List<Game>,
val newUnmatchedPaths: List<String>
val new: Int,
val removed: Int,
val unmatched: Int
)
@@ -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<LibraryEvent>(1024, false)
private val scanProgressEvents = Sinks.many().replay().limit<LibraryScanProgress>(SCAN_RESULT_TTL)
fun subscribe(): Flux<List<LibraryEvent>> {
fun subscribeToLibraryEvents(): Flux<List<LibraryEvent>> {
log.debug { "New subscription for libraryEvents" }
return libraryEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration())
@@ -46,9 +49,25 @@ 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)
}
}
@@ -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<LibraryDto>?) {
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<LibraryDto>?): LibraryScanResult {
fun quickScan(libraryDtos: Collection<LibraryDto>?) {
val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll()
libraries.forEach { executor.submit { quickScan(it) } }
}
val scanResults: List<LibraryScanResult> = 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<String>()
val totalPaths = gamePaths.size
val completedMetadata = AtomicInteger(0)
val completedImageDownload = AtomicInteger(0)
val calculatedFileSize = AtomicInteger(0)
val metadataTasks = gamePaths.map { path ->
Callable<Game?> {
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<String>()
val progress = completedMetadata.incrementAndGet()
log.debug { "${progress}/${totalPaths} metadata matched" }
val metadataTasks = gamePaths.map { path ->
Callable<Game?> {
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<Game?> {
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<Game?> {
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)
}
/**
@@ -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
}