mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Implement realtime UI for scans
This commit is contained in:
@@ -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
|
||||
<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 /
|
||||
{scan.result?.removed} removed /
|
||||
{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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user