diff --git a/gameyfin/src/main/frontend/App.tsx b/gameyfin/src/main/frontend/App.tsx index 4c262b5..b7f8248 100644 --- a/gameyfin/src/main/frontend/App.tsx +++ b/gameyfin/src/main/frontend/App.tsx @@ -9,12 +9,15 @@ import {AuthProvider} from "Frontend/util/auth"; import {IconContext, X} from "@phosphor-icons/react"; import client from "Frontend/generated/connect-client.default"; import {ErrorHandlingMiddleware} from "Frontend/util/middleware"; +import {initializeLibraryState} from "Frontend/state/LibraryState"; export default function App() { const navigate = useNavigate(); client.middlewares = [ErrorHandlingMiddleware]; + initializeLibraryState(); + return ( diff --git a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx index 2d9da93..1ba0499 100644 --- a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from "react"; +import React, {useEffect} from "react"; import ConfigFormField from "Frontend/components/administration/ConfigFormField"; import withConfigPage from "Frontend/components/administration/withConfigPage"; import Section from "Frontend/components/general/Section"; @@ -10,38 +10,19 @@ import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOver import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal"; import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; +import {useSnapshot} from "valtio/react"; +import {initializeLibraryState, libraryState} from "Frontend/state/LibraryState"; function LibraryManagementLayout({getConfig, formik}: any) { - const [libraries, setLibraries] = useState([]); const libraryCreationModal = useDisclosure(); + const state = useSnapshot(libraryState); useEffect(() => { - LibraryEndpoint.getAllLibraries().then((response) => { - if (response === undefined) return; - let sortedLibraries: LibraryDto[] = response - .filter(l => !!l) - .sort((a: LibraryDto, b: LibraryDto) => { - if (a.name === undefined || b.name === undefined) return 0; - return a.name.localeCompare(b.name); - }); - setLibraries(sortedLibraries); - }); + initializeLibraryState(); }, []); async function updateLibrary(library: LibraryUpdateDto) { - let updatedLibrary = await LibraryEndpoint.updateLibrary(library); - if (updatedLibrary === undefined) return; - - setLibraries((prevLibraries) => { - const index = prevLibraries.findIndex((l) => l.id === updatedLibrary.id); - if (index !== -1) { - const updatedLibraries = [...prevLibraries]; - updatedLibraries[index] = updatedLibrary; - return updatedLibraries; - } - return [...prevLibraries, updatedLibrary]; - }); - + await LibraryEndpoint.updateLibrary(library); addToast({ title: "Library updated", description: `Library ${library.name} has been updated.`, @@ -51,9 +32,6 @@ function LibraryManagementLayout({getConfig, formik}: any) { async function removeLibrary(library: LibraryDto) { await LibraryEndpoint.removeLibrary(library.id); - setLibraries((prevLibraries) => { - return prevLibraries.filter((l) => l.id !== library.id); - }); addToast({ title: "Library removed", description: `Library ${library.name} has been removed.`, @@ -87,10 +65,11 @@ function LibraryManagementLayout({getConfig, formik}: any) { - {libraries.length > 0 ? + {state.sorted.length > 0 ? // Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
- {libraries.map((library) => + {state.sorted.map((library) => + // @ts-ignore )} @@ -99,8 +78,8 @@ function LibraryManagementLayout({getConfig, formik}: any) { } diff --git a/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx b/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx index d8074eb..ba88cc8 100644 --- a/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx +++ b/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx @@ -20,16 +20,15 @@ import { TreasureChest, Trophy } from "@phosphor-icons/react"; -import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto"; import ScanType from "Frontend/generated/de/grimsi/gameyfin/libraries/enums/ScanType"; import {randomGamesFromLibrary} from "Frontend/util/utils"; import {useNavigate} from "react-router"; -export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: { - library: LibraryDto, - updateLibrary: (library: LibraryUpdateDto) => Promise, - removeLibrary: (library: LibraryDto) => Promise -}) { +interface LibraryOverviewCardProps { + library: LibraryDto; +} + +export function LibraryOverviewCard({library}: LibraryOverviewCardProps) { const MAX_COVER_COUNT = 5; const navigate = useNavigate(); const [randomGames, setRandomGames] = useState([]); @@ -42,7 +41,6 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: { async function triggerScan() { await LibraryEndpoint.triggerScan(ScanType.QUICK, [library]); - await updateLibrary({id: library.id}); } return ( diff --git a/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx b/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx index cdd5627..16c74e0 100644 --- a/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx +++ b/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx @@ -16,14 +16,13 @@ interface LibraryCreationModalProps { export default function LibraryCreationModal({ libraries, - setLibraries, isOpen, onOpenChange }: LibraryCreationModalProps) { + async function createLibrary(library: LibraryDto) { try { - const newLibrary = await LibraryEndpoint.createLibrary(library as LibraryDto); - setLibraries([...libraries, newLibrary]); + await LibraryEndpoint.createLibrary(library as LibraryDto); } catch (e) { addToast({ title: "Error creating library", diff --git a/gameyfin/src/main/frontend/state/LibraryState.ts b/gameyfin/src/main/frontend/state/LibraryState.ts new file mode 100644 index 0000000..ba91f78 --- /dev/null +++ b/gameyfin/src/main/frontend/state/LibraryState.ts @@ -0,0 +1,57 @@ +import {Subscription} from "@vaadin/hilla-frontend"; +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"; + +type LibraryState = { + subscription?: Subscription; + isLoaded: boolean; + state: Record; + libraries: LibraryDto[]; + sorted: LibraryDto[]; +}; + +export const libraryState = proxy({ + get isLoaded() { + return this.subscription != null; + }, + state: {}, + get libraries() { + return Object.values(this.state); + }, + get sorted() { + return Object.values(this.state).sort((a, b) => { + if (a.name === undefined || b.name === undefined) return 0; + return a.name.localeCompare(b.name); + }); + } +}); + +/** Subscribe to and process state updates from backend **/ +export async function initializeLibraryState() { + if (libraryState.isLoaded) return libraryState; + + // Fetch initial library list + const initialEntries = await LibraryEndpoint.getAll(); + initialEntries.forEach((library: LibraryDto) => { + libraryState.state[library.id] = library; + }); + + // Subscribe to real-time updates + libraryState.subscription = LibraryEndpoint.subscribe().onNext((libraryEvent) => { + switch (libraryEvent.type) { + case "created": + case "updated": + //@ts-ignore + libraryState.state[libraryEvent.library.id] = libraryEvent.library; + break; + case "deleted": + //@ts-ignore + delete libraryState.state[libraryEvent.libraryId]; + break; + } + }); + + return libraryState; +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/views/HomeView.tsx b/gameyfin/src/main/frontend/views/HomeView.tsx index 0d99d10..a40ae8e 100644 --- a/gameyfin/src/main/frontend/views/HomeView.tsx +++ b/gameyfin/src/main/frontend/views/HomeView.tsx @@ -1,30 +1,28 @@ import {useEffect, useState} from "react"; -import {GameEndpoint, LibraryEndpoint} from "Frontend/generated/endpoints"; -import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; +import {GameEndpoint} from "Frontend/generated/endpoints"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; import {randomGamesFromLibrary} from "Frontend/util/utils"; import {CoverRow} from "Frontend/components/general/CoverRow"; +import {useSnapshot} from "valtio/react"; +import {libraryState} from "Frontend/state/LibraryState"; export default function HomeView() { const [recentlyAddedGames, setRecentlyAddedGames] = useState([]); - const [libraries, setLibraries] = useState([]); const [libraryIdToGames, setLibraryIdToGames] = useState>(new Map()); + const state = useSnapshot(libraryState); useEffect(() => { - LibraryEndpoint.getAllLibraries().then(libraries => { - setLibraries(libraries); + const gamePromises = state.libraries.map((library) => + //@ts-ignore + randomGamesFromLibrary(library).then((games) => [library.id, games] as [number, GameDto[]]) + ); - const gamePromises = libraries.map((library) => - randomGamesFromLibrary(library).then((games) => [library.id, games] as [number, GameDto[]]) - ); - - Promise.all(gamePromises).then((results) => { - const libraryGamesMap = new Map(); - results.forEach(([libraryId, games]) => { - libraryGamesMap.set(libraryId, games); - }); - setLibraryIdToGames(libraryGamesMap); + Promise.all(gamePromises).then((results) => { + const libraryGamesMap = new Map(); + results.forEach(([libraryId, games]) => { + libraryGamesMap.set(libraryId, games); }); + setLibraryIdToGames(libraryGamesMap); }); // TODO: see https://github.com/vaadin/hilla/issues/3470 @@ -38,7 +36,7 @@ export default function HomeView() {
alert("show more of 'Recently added'")}/> - {libraries.map((library) => ( + {state.libraries.map((library) => ( alert(`show more of library '${library.name}'`)} diff --git a/gameyfin/src/main/frontend/views/LibraryManagementView.tsx b/gameyfin/src/main/frontend/views/LibraryManagementView.tsx index 0a3d179..68ece27 100644 --- a/gameyfin/src/main/frontend/views/LibraryManagementView.tsx +++ b/gameyfin/src/main/frontend/views/LibraryManagementView.tsx @@ -1,45 +1,44 @@ -import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import {useNavigate, useParams} from "react-router"; -import React, {useEffect, useState} from "react"; -import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; -import {LibraryEndpoint} from "Frontend/generated/endpoints"; +import React, {useEffect} from "react"; import LibraryHeader from "Frontend/components/general/covers/LibraryHeader"; import {Button, Tab, Tabs} from "@heroui/react"; import {ArrowLeft} from "@phosphor-icons/react"; import LibraryManagementDetails from "Frontend/components/general/library/LibraryManagementDetails"; import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames"; +import {useSnapshot} from "valtio/react"; +import {initializeLibraryState, libraryState} from "Frontend/state/LibraryState"; export default function LibraryManagementView() { const {libraryId} = useParams(); const navigate = useNavigate(); - const [library, setLibrary] = useState(); - const [games, setGames] = useState([]); + const state = useSnapshot(libraryState); useEffect(() => { - if (!libraryId) return; - LibraryEndpoint.getById(parseInt(libraryId)).then((library: LibraryDto) => { - setLibrary(library); - }); - LibraryEndpoint.getGamesInLibrary(parseInt(libraryId)).then((games) => { - setGames(games); + initializeLibraryState().then((state) => { + if (!libraryId || !state.state[libraryId]) { + navigate("/administration/libraries"); + } }); }, []); - return library &&
+ return libraryId && state.state[libraryId] &&

Manage library

- + {/* @ts-ignore */} + - + {/* @ts-ignore */} + - + {/* @ts-ignore */} +

Unmatched paths

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 42baf11..f8eee3a 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt @@ -2,50 +2,37 @@ package de.grimsi.gameyfin.libraries import com.vaadin.hilla.Endpoint import de.grimsi.gameyfin.core.Role -import de.grimsi.gameyfin.games.dto.GameDto import de.grimsi.gameyfin.libraries.dto.LibraryDto +import de.grimsi.gameyfin.libraries.dto.LibraryEvent import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto import de.grimsi.gameyfin.libraries.enums.ScanType import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed +import reactor.core.publisher.Flux @Endpoint @PermitAll class LibraryEndpoint( private val libraryService: LibraryService ) { - fun getAllLibraries(): Collection { - return libraryService.getAllLibraries() + fun subscribe(): Flux { + return libraryService.subscribe() } - fun getById(libraryId: Long): LibraryDto = libraryService.getById(libraryId) + fun getAll() = libraryService.getAll() - fun getGamesInLibrary(libraryId: Long): Collection { - return libraryService.getGamesInLibrary(libraryId) - } + fun getGamesInLibrary(libraryId: Long) = libraryService.getGamesInLibrary(libraryId) @RolesAllowed(Role.Names.ADMIN) - fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection?) { - return libraryService.triggerScan(scanType, libraries) - } + fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection?) = + libraryService.triggerScan(scanType, libraries) @RolesAllowed(Role.Names.ADMIN) - fun createLibrary(library: LibraryDto): LibraryDto { - return libraryService.create(library) - } + fun createLibrary(library: LibraryDto) = libraryService.create(library) @RolesAllowed(Role.Names.ADMIN) - fun updateLibrary(library: LibraryUpdateDto): LibraryDto { - return libraryService.update(library) - } + fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library) @RolesAllowed(Role.Names.ADMIN) - fun removeLibrary(libraryId: Long) { - return libraryService.deleteLibrary(libraryId) - } - - @RolesAllowed(Role.Names.ADMIN) - fun removeLibraries() { - return libraryService.deleteAllLibraries() - } + fun removeLibrary(libraryId: Long) = libraryService.deleteLibrary(libraryId) } \ No newline at end of file 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 a167e9a..21bbe83 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt @@ -5,15 +5,14 @@ import de.grimsi.gameyfin.games.GameService import de.grimsi.gameyfin.games.dto.GameDto import de.grimsi.gameyfin.games.entities.Game import de.grimsi.gameyfin.games.toDto -import de.grimsi.gameyfin.libraries.dto.DirectoryMappingDto -import de.grimsi.gameyfin.libraries.dto.LibraryDto -import de.grimsi.gameyfin.libraries.dto.LibraryStatsDto -import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto +import de.grimsi.gameyfin.libraries.dto.* import de.grimsi.gameyfin.libraries.enums.ScanType import de.grimsi.gameyfin.media.ImageService import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Sinks import java.util.concurrent.Callable import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors @@ -33,15 +32,27 @@ class LibraryService( private val executor = Executors.newVirtualThreadPerTaskExecutor() } + private val libraryEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) + + fun subscribe(): Flux { + log.debug { "New subscription for libraryUpdates" } + return libraryEvents.asFlux() + .doOnSubscribe { log.debug { "Subscriber added to libraryUpdates [${libraryEvents.currentSubscriberCount()}]" } } + .doFinally { + log.debug { "Subscriber removed from libraryUpdates with signal type $it [${libraryEvents.currentSubscriberCount()}]" } + } + } + /** * Creates or updates a library in the repository. * * @param library: The library to create or update. * @return The created or updated LibraryDto object. */ - fun create(library: LibraryDto): LibraryDto { + fun create(library: LibraryDto) { val entity = libraryRepository.save(toEntity(library)) - return toDto(entity) + val libraryDto = toDto(entity) + libraryEvents.tryEmitNext(LibraryEvent.Created(libraryDto)) } /** @@ -51,7 +62,7 @@ class LibraryService( * @return The updated LibraryDto. * @throws IllegalArgumentException if the library ID is null or the library is not found. */ - fun update(libraryDto: LibraryUpdateDto): LibraryDto { + fun update(libraryDto: LibraryUpdateDto) { val existingLibrary = libraryRepository.findByIdOrNull(libraryDto.id) ?: throw IllegalArgumentException("Library with ID $libraryDto.id not found") @@ -64,13 +75,14 @@ class LibraryService( } val updatedLibrary = libraryRepository.save(existingLibrary) - return toDto(updatedLibrary) + val updatedLibraryDto = toDto(updatedLibrary) + libraryEvents.tryEmitNext(LibraryEvent.Updated(updatedLibraryDto)) } /** * Retrieves all libraries from the repository. */ - fun getAllLibraries(): Collection { + fun getAll(): List { val entities = libraryRepository.findAll() return entities.map { toDto(it) } } @@ -95,13 +107,7 @@ class LibraryService( */ fun deleteLibrary(libraryId: Long) { libraryRepository.deleteById(libraryId) - } - - /** - * Deletes all libraries from the repository. - */ - fun deleteAllLibraries() { - libraryRepository.deleteAll() + libraryEvents.tryEmitNext(LibraryEvent.Deleted(libraryId)) } /** diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/dto/LibraryEvent.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/dto/LibraryEvent.kt new file mode 100644 index 0000000..bf3dd8b --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/dto/LibraryEvent.kt @@ -0,0 +1,9 @@ +package de.grimsi.gameyfin.libraries.dto + +sealed class LibraryEvent { + abstract val type: String + + data class Created(val library: LibraryDto, override val type: String = "created") : LibraryEvent() + data class Updated(val library: LibraryDto, override val type: String = "updated") : LibraryEvent() + data class Deleted(val libraryId: Long, override val type: String = "deleted") : LibraryEvent() +} \ No newline at end of file