mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-17 00:30:04 +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 {ErrorHandlingMiddleware} from "Frontend/util/middleware";
|
||||||
import {initializeLibraryState} from "Frontend/state/LibraryState";
|
import {initializeLibraryState} from "Frontend/state/LibraryState";
|
||||||
import {initializeGameState} from "Frontend/state/GameState";
|
import {initializeGameState} from "Frontend/state/GameState";
|
||||||
|
import {initializeScanState} from "Frontend/state/ScanState";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -19,6 +20,7 @@ export default function App() {
|
|||||||
|
|
||||||
initializeLibraryState();
|
initializeLibraryState();
|
||||||
initializeGameState();
|
initializeGameState();
|
||||||
|
initializeScanState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeroUIProvider className="size-full" navigate={navigate} useHref={useHref}>
|
<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>
|
<ModalBody>
|
||||||
<Snippet symbol="">{passwordResetLink()}</Snippet>
|
<Snippet symbol="">{passwordResetLink()}</Snippet>
|
||||||
{
|
{
|
||||||
!timeUntilExpiry.startsWith("-")
|
!timeUntilExpiry.endsWith("ago")
|
||||||
? <small className="text-warning">
|
? <small className="text-warning">
|
||||||
This link will expire in {timeUntilExpiry}
|
This link will expire {timeUntilExpiry}
|
||||||
</small>
|
</small>
|
||||||
: <small className="text-danger">
|
: <small className="text-danger">
|
||||||
This link has expired {timeUntilExpiry.substring(1)} ago
|
This link has expired {timeUntilExpiry}
|
||||||
</small>
|
</small>
|
||||||
}
|
}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {proxy} from "valtio/index";
|
|||||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||||
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
|
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
|
||||||
import LibraryEvent from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryEvent";
|
import LibraryEvent from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryEvent";
|
||||||
|
import {handleLibraryDeletion} from "./ScanState";
|
||||||
|
|
||||||
type LibraryState = {
|
type LibraryState = {
|
||||||
subscription?: Subscription<LibraryEvent[]>;
|
subscription?: Subscription<LibraryEvent[]>;
|
||||||
@@ -39,7 +40,7 @@ export async function initializeLibraryState() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to real-time updates
|
// Subscribe to real-time updates
|
||||||
libraryState.subscription = LibraryEndpoint.subscribe().onNext((libraryEvents: LibraryEvent[]) => {
|
libraryState.subscription = LibraryEndpoint.subscribeToLibraryEvents().onNext((libraryEvents: LibraryEvent[]) => {
|
||||||
libraryEvents.forEach((libraryEvent: LibraryEvent) => {
|
libraryEvents.forEach((libraryEvent: LibraryEvent) => {
|
||||||
switch (libraryEvent.type) {
|
switch (libraryEvent.type) {
|
||||||
case "created":
|
case "created":
|
||||||
@@ -48,6 +49,8 @@ export async function initializeLibraryState() {
|
|||||||
libraryState.state[libraryEvent.library.id] = libraryEvent.library;
|
libraryState.state[libraryEvent.library.id] = libraryEvent.library;
|
||||||
break;
|
break;
|
||||||
case "deleted":
|
case "deleted":
|
||||||
|
//@ts-ignore
|
||||||
|
handleLibraryDeletion(libraryEvent.libraryId);
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
delete libraryState.state[libraryEvent.libraryId];
|
delete libraryState.state[libraryEvent.libraryId];
|
||||||
break;
|
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) {
|
for (const unit of units) {
|
||||||
const value = Math.floor(absDiffInSeconds / unit.seconds);
|
const value = Math.floor(absDiffInSeconds / unit.seconds);
|
||||||
if (value >= 1) {
|
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";
|
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.
|
* 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 SearchBar from "Frontend/components/general/SearchBar";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
|
import {scanState} from "Frontend/state/ScanState";
|
||||||
|
import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover";
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -24,6 +26,7 @@ export default function MainLayout() {
|
|||||||
const isHomePage = location.pathname === "/";
|
const isHomePage = location.pathname === "/";
|
||||||
const [isExploding, setIsExploding] = useState(false);
|
const [isExploding, setIsExploding] = useState(false);
|
||||||
const games = useSnapshot(gameState).games;
|
const games = useSnapshot(gameState).games;
|
||||||
|
const scans = useSnapshot(scanState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let newTitle = `Gameyfin - ${routeMetadata?.title}`;
|
let newTitle = `Gameyfin - ${routeMetadata?.title}`;
|
||||||
@@ -96,12 +99,10 @@ export default function MainLayout() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</NavbarContent>}
|
</NavbarContent>}
|
||||||
<NavbarContent justify="end">
|
<NavbarContent justify="end">
|
||||||
{auth.state.user?.emailConfirmed === false ?
|
{auth.state.user?.roles?.some(a => a?.includes("ADMIN")) &&
|
||||||
<NavbarItem>
|
<NavbarItem>
|
||||||
<small className="text-warning">Please confirm your email</small>
|
<ScanProgressPopover/>
|
||||||
</NavbarItem>
|
</NavbarItem>
|
||||||
:
|
|
||||||
""
|
|
||||||
}
|
}
|
||||||
<NavbarItem>
|
<NavbarItem>
|
||||||
<ProfileMenu/>
|
<ProfileMenu/>
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import com.vaadin.hilla.Endpoint
|
|||||||
import de.grimsi.gameyfin.core.Role
|
import de.grimsi.gameyfin.core.Role
|
||||||
import de.grimsi.gameyfin.libraries.dto.LibraryDto
|
import de.grimsi.gameyfin.libraries.dto.LibraryDto
|
||||||
import de.grimsi.gameyfin.libraries.dto.LibraryEvent
|
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.dto.LibraryUpdateDto
|
||||||
import de.grimsi.gameyfin.libraries.enums.ScanType
|
import de.grimsi.gameyfin.libraries.enums.ScanType
|
||||||
|
import de.grimsi.gameyfin.users.util.isAdmin
|
||||||
import jakarta.annotation.security.PermitAll
|
import jakarta.annotation.security.PermitAll
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@@ -15,12 +19,19 @@ import reactor.core.publisher.Flux
|
|||||||
class LibraryEndpoint(
|
class LibraryEndpoint(
|
||||||
private val libraryService: LibraryService
|
private val libraryService: LibraryService
|
||||||
) {
|
) {
|
||||||
fun subscribe(): Flux<List<LibraryEvent>> {
|
fun subscribeToLibraryEvents(): Flux<List<LibraryEvent>> {
|
||||||
return LibraryService.subscribe()
|
return LibraryService.subscribeToLibraryEvents()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAll() = libraryService.getAll()
|
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)
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection<LibraryDto>?) =
|
fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection<LibraryDto>?) =
|
||||||
libraryService.triggerScan(scanType, libraries)
|
libraryService.triggerScan(scanType, libraries)
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package de.grimsi.gameyfin.libraries
|
package de.grimsi.gameyfin.libraries
|
||||||
|
|
||||||
import de.grimsi.gameyfin.games.entities.Game
|
|
||||||
|
|
||||||
data class LibraryScanResult(
|
data class LibraryScanResult(
|
||||||
val libraries: List<Library>,
|
val new: Int,
|
||||||
val newGames: List<Game>,
|
val removed: Int,
|
||||||
val removedGames: List<Game>,
|
val unmatched: Int
|
||||||
val newUnmatchedPaths: List<String>
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import java.util.concurrent.Callable
|
|||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.measureTimedValue
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.toJavaDuration
|
import kotlin.time.toJavaDuration
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -30,11 +31,13 @@ class LibraryService(
|
|||||||
companion object {
|
companion object {
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
private val executor = Executors.newVirtualThreadPerTaskExecutor()
|
private val executor = Executors.newVirtualThreadPerTaskExecutor()
|
||||||
|
private val SCAN_RESULT_TTL = 24.hours.toJavaDuration()
|
||||||
|
|
||||||
/* Websockets */
|
/* Websockets */
|
||||||
private val libraryEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryEvent>(1024, false)
|
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" }
|
log.debug { "New subscription for libraryEvents" }
|
||||||
return libraryEvents.asFlux()
|
return libraryEvents.asFlux()
|
||||||
.buffer(100.milliseconds.toJavaDuration())
|
.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) {
|
fun emit(event: LibraryEvent) {
|
||||||
libraryEvents.tryEmitNext(event)
|
libraryEvents.tryEmitNext(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun emit(scanProgressDto: LibraryScanProgress) {
|
||||||
|
scanProgressEvents.tryEmitNext(scanProgressDto)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +86,7 @@ class LibraryService(
|
|||||||
* @return The created or updated LibraryDto object.
|
* @return The created or updated LibraryDto object.
|
||||||
*/
|
*/
|
||||||
fun create(library: LibraryDto) {
|
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.
|
* Wrapper function to trigger a scan for a list of libraries.
|
||||||
*/
|
*/
|
||||||
fun triggerScan(scanType: ScanType, libraryDtos: Collection<LibraryDto>?) {
|
fun triggerScan(scanType: ScanType, libraryDtos: Collection<LibraryDto>?) {
|
||||||
val scanResult = measureTimedValue {
|
executor.submit {
|
||||||
when (scanType) {
|
when (scanType) {
|
||||||
ScanType.QUICK -> quickScan(libraryDtos)
|
ScanType.QUICK -> quickScan(libraryDtos)
|
||||||
ScanType.FULL -> TODO()
|
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.
|
* @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()
|
val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll()
|
||||||
|
libraries.forEach { executor.submit { quickScan(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
val scanResults: List<LibraryScanResult> = libraries.map { library ->
|
fun quickScan(library: Library) {
|
||||||
val scanResult = filesystemService.scanLibraryForGamefiles(library)
|
|
||||||
val gamePaths = scanResult.newPaths
|
|
||||||
val removedGamePaths = scanResult.removedGamePaths.map { it.toString() }
|
|
||||||
val removedUnmatchedPaths = scanResult.removedUnmatchedPaths.map { it.toString() }
|
|
||||||
|
|
||||||
val totalPaths = gamePaths.size
|
val progress = LibraryScanProgress(
|
||||||
val completedMetadata = AtomicInteger(0)
|
libraryId = library.id!!,
|
||||||
val completedImageDownload = AtomicInteger(0)
|
currentStep = LibraryScanStep(
|
||||||
val calculatedFileSize = AtomicInteger(0)
|
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 totalPaths = gamePaths.size
|
||||||
val newUnmatchedPaths = ConcurrentHashMap.newKeySet<String>()
|
val completedMetadata = AtomicInteger(0)
|
||||||
|
val completedImageDownload = AtomicInteger(0)
|
||||||
|
val calculatedFileSize = AtomicInteger(0)
|
||||||
|
|
||||||
val metadataTasks = gamePaths.map { path ->
|
progress.currentStep = LibraryScanStep(
|
||||||
Callable<Game?> {
|
description = "Matching games",
|
||||||
try {
|
current = 0,
|
||||||
val game = gameService.matchFromFile(path, library)
|
total = totalPaths
|
||||||
|
)
|
||||||
|
emit(progress)
|
||||||
|
|
||||||
if (game == null) {
|
// 1. Fetch metadata for each game
|
||||||
newUnmatchedPaths.add(path.toString())
|
val newUnmatchedPaths = ConcurrentHashMap.newKeySet<String>()
|
||||||
return@Callable null
|
|
||||||
}
|
|
||||||
|
|
||||||
val progress = completedMetadata.incrementAndGet()
|
val metadataTasks = gamePaths.map { path ->
|
||||||
log.debug { "${progress}/${totalPaths} metadata matched" }
|
Callable<Game?> {
|
||||||
|
try {
|
||||||
|
val game = gameService.matchFromFile(path, library)
|
||||||
|
|
||||||
return@Callable game
|
if (game == null) {
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error(e) { "Error processing game: ${e.message}" }
|
|
||||||
newUnmatchedPaths.add(path.toString())
|
newUnmatchedPaths.add(path.toString())
|
||||||
|
|
||||||
return@Callable null
|
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 ->
|
// 1.1 Wait for all metadata tasks to complete
|
||||||
LibraryScanResult(
|
val matchedGames = executor.invokeAll(metadataTasks).mapNotNull { it.get() }
|
||||||
libraries = acc.libraries + scanResult.libraries,
|
|
||||||
newGames = acc.newGames + scanResult.newGames,
|
// 1.2 Add unmatched paths to the library
|
||||||
removedGames = acc.removedGames + scanResult.removedGames,
|
library.unmatchedPaths.removeAll(removedUnmatchedPaths)
|
||||||
newUnmatchedPaths = acc.newUnmatchedPaths + scanResult.newUnmatchedPaths
|
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