From a02fae883c69efc53ffc091616da53b39098125b Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Tue, 1 Apr 2025 17:34:18 +0200 Subject: [PATCH] First prototype of game libraries --- .run/UI debug.run.xml | 2 +- gameyfin/package-lock.json | 20 ++++ gameyfin/package.json | 8 +- .../administration/LibraryManagement.tsx | 41 +++++--- .../frontend/components/general/GameCover.tsx | 20 ++++ .../general/cards/GameOverviewCard.tsx | 11 +-- .../general/cards/LibraryOverviewCard.tsx | 86 ++++++++++++++++- .../general/modals/LibraryCreationModal.tsx | 93 +++++++++++++++++++ gameyfin/src/main/frontend/views/TestView.tsx | 11 +++ .../de/grimsi/gameyfin/games/GameService.kt | 10 +- .../de/grimsi/gameyfin/games/entities/Game.kt | 4 +- .../de/grimsi/gameyfin/libraries/Library.kt | 4 +- .../grimsi/gameyfin/libraries/LibraryDto.kt | 3 +- .../gameyfin/libraries/LibraryEndpoint.kt | 17 +++- .../gameyfin/libraries/LibraryRepository.kt | 8 +- .../gameyfin/libraries/LibraryService.kt | 35 ++++++- .../gameyfin/libraries/LibraryStatsDto.kt | 6 ++ 17 files changed, 332 insertions(+), 47 deletions(-) create mode 100644 gameyfin/src/main/frontend/components/general/GameCover.tsx create mode 100644 gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryStatsDto.kt diff --git a/.run/UI debug.run.xml b/.run/UI debug.run.xml index ccfbe55..1a2afd9 100644 --- a/.run/UI debug.run.xml +++ b/.run/UI debug.run.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/gameyfin/package-lock.json b/gameyfin/package-lock.json index b1423b2..7c63649 100644 --- a/gameyfin/package-lock.json +++ b/gameyfin/package-lock.json @@ -42,7 +42,9 @@ "moment": "^2.30.1", "moment-timezone": "^0.5.47", "next-themes": "^0.4.6", + "rand-seed": "^2.1.7", "react": "18.3.1", + "react-accessible-treeview": "^2.11.1", "react-aria-components": "^1.7.1", "react-confetti-boom": "^1.0.0", "react-dom": "18.3.1", @@ -15550,6 +15552,12 @@ "node": ">=8" } }, + "node_modules/rand-seed": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/rand-seed/-/rand-seed-2.1.7.tgz", + "integrity": "sha512-Yaz75D2fTWtIr69iDd+PGwtQkFkqOIMQZl+W8U3NMR6F2a1UEk2FmnQkNd6Z1eNtL96Z5nznw5PKYk1Z9m6lxw==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -15572,6 +15580,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-accessible-treeview": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/react-accessible-treeview/-/react-accessible-treeview-2.11.1.tgz", + "integrity": "sha512-lFegHjFJp2OvtoHMtbIqjby7N3MGDRASlbJsMLqElxQHwZ97xIYho2S4QvXKK7l3/nII0IKDQFJXZNBj6ecG3g==", + "license": "MIT", + "peerDependencies": { + "classnames": "^2.2.6", + "prop-types": "^15.7.2", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-aria": { "version": "3.38.1", "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.38.1.tgz", diff --git a/gameyfin/package.json b/gameyfin/package.json index ed1c720..32c9b0b 100644 --- a/gameyfin/package.json +++ b/gameyfin/package.json @@ -37,7 +37,9 @@ "moment": "^2.30.1", "moment-timezone": "^0.5.47", "next-themes": "^0.4.6", + "rand-seed": "^2.1.7", "react": "18.3.1", + "react-accessible-treeview": "^2.11.1", "react-aria-components": "^1.7.1", "react-confetti-boom": "^1.0.0", "react-dom": "18.3.1", @@ -123,7 +125,9 @@ "@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles", "@react-types/shared": "$@react-types/shared", "@react-stately/data": "$@react-stately/data", - "react-aria-components": "$react-aria-components" + "react-aria-components": "$react-aria-components", + "react-accessible-treeview": "$react-accessible-treeview", + "rand-seed": "$rand-seed" }, "vaadin": { "dependencies": { @@ -183,6 +187,6 @@ "workbox-core": "7.3.0", "workbox-precaching": "7.3.0" }, - "hash": "081663e5e6c1316ae1baa055271a5fe7aebc8015d2310320fd58e3e20c1dd342" + "hash": "a951d6d827dd2a43a7d0fa6330d5030cc19c1077490488a24cfd7908bd43aac3" } } diff --git a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx index 72c6a72..ee017af 100644 --- a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx @@ -1,27 +1,34 @@ -import React, {useEffect} from "react"; +import React, {useEffect, useState} from "react"; import ConfigFormField from "Frontend/components/administration/ConfigFormField"; import withConfigPage from "Frontend/components/administration/withConfigPage"; import Section from "Frontend/components/general/Section"; import * as Yup from 'yup'; -import {Button, Divider, Tooltip} from "@heroui/react"; +import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react"; import {Plus} from "@phosphor-icons/react"; import {LibraryEndpoint} from "Frontend/generated/endpoints"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/LibraryDto"; import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard"; -import {ListData, useListData} from "@react-stately/data"; +import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal"; function LibraryManagementLayout({getConfig, formik}: any) { - const libraries: ListData = useListData({}); + const [libraries, setLibraries] = useState([]); + const libraryCreationModal = useDisclosure(); useEffect(() => { - LibraryEndpoint.getAllLibraries().then( - (response) => libraries.items = response as LibraryDto[] - ); + 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); + }); }, []); return (
-
@@ -38,20 +45,26 @@ function LibraryManagementLayout({getConfig, formik}: any) {

Libraries

-
- {libraries.items.length > 0 ? -
- {libraries.items.map((library) => )} + {libraries.length > 0 ? + // Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px +
+ {libraries.map((library) => )}
: "No libraries configured. Add your first library!" } + +
); } diff --git a/gameyfin/src/main/frontend/components/general/GameCover.tsx b/gameyfin/src/main/frontend/components/general/GameCover.tsx new file mode 100644 index 0000000..ce62f66 --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/GameCover.tsx @@ -0,0 +1,20 @@ +import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; +import {Image} from "@heroui/react"; + +interface GameCoverProps { + game: GameDto; + size?: number; + radius?: "none" | "sm" | "md" | "lg" | "full"; +} + +export function GameCover({game, size = 300, radius}: GameCoverProps) { + return ( + {game.title} + ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/cards/GameOverviewCard.tsx b/gameyfin/src/main/frontend/components/general/cards/GameOverviewCard.tsx index 1b2cc57..ad46140 100644 --- a/gameyfin/src/main/frontend/components/general/cards/GameOverviewCard.tsx +++ b/gameyfin/src/main/frontend/components/general/cards/GameOverviewCard.tsx @@ -1,15 +1,8 @@ +import {GameCover} from "Frontend/components/general/GameCover"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; -import {Card, Image} from "@heroui/react"; export function GameOverviewCard({game}: { game: GameDto }) { return ( - - {game.title} - + ); } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx b/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx index 8e6c6f6..0d430c2 100644 --- a/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx +++ b/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx @@ -1,13 +1,89 @@ -import {Card} from "@heroui/react"; +import {Card, Chip} from "@heroui/react"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/LibraryDto"; +import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; +import {useEffect, useState} from "react"; +import {LibraryEndpoint} from "Frontend/generated/endpoints"; +import {GameCover} from "Frontend/components/general/GameCover"; +import Rand from "rand-seed"; +import { + Alien, + CastleTurret, + GameController, + Ghost, + Joystick, + Lego, + Skull, + SoccerBall, + Strategy, + Sword, + TreasureChest, + Trophy +} from "@phosphor-icons/react"; export function LibraryOverviewCard({library}: { library: LibraryDto }) { + const MAX_COVER_COUNT = 5; + const rand = new Rand(library.id.toString()); + + const [randomGamesFromLibrary, setRandomGamesFromLibrary] = useState([]); + + useEffect(() => { + LibraryEndpoint.getGamesInLibrary(library.id).then( + (response) => { + if (response === undefined) return; + const count = Math.min(response.length, MAX_COVER_COUNT) + + let gamesFromLibrary: GameDto[] = response + .filter(g => !!g) + .sort(() => rand.next() - 0.5) + .slice(0, count) + + setRandomGamesFromLibrary(gamesFromLibrary); + } + ) + }, []); + return ( - -
-

{library.name}

-

{library.path}

+ +
+
+
+ + + + + + + + + + + + +
+ {randomGamesFromLibrary.length > 0 && +
+ {randomGamesFromLibrary.map((game) => ( + + ))} +
+ } +
+ +

{library.name}

+ + {!!library.stats && +
+

Games

+

Downloads

+

Platforms

+

{library.stats.gamesCount}

+

{library.stats.downloadedGamesCount}

+ PC +
+ }
); } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx b/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx new file mode 100644 index 0000000..79f480e --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; +import {Form, Formik} from "formik"; +import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/LibraryDto"; +import {LibraryEndpoint} from "Frontend/generated/endpoints"; +import Input from "Frontend/components/general/input/Input"; + +interface LibraryCreationModalProps { + libraries: LibraryDto[]; + setLibraries: (libraries: LibraryDto[]) => void; + isOpen: boolean; + onOpenChange: () => void; +} + +export default function LibraryCreationModal({ + libraries, + setLibraries, + isOpen, + onOpenChange + }: LibraryCreationModalProps) { + async function createLibrary(library: LibraryDto) { + try { + const newLibrary = await LibraryEndpoint.createLibrary(library as LibraryDto); + if (newLibrary === undefined) return; + setLibraries([...libraries, newLibrary]); + } catch (e) { + addToast({ + title: "Error creating library", + description: `Library ${library.name} could not be created!`, + color: "warning" + }); + return; + } + + addToast({ + title: "New library created", + description: `Library ${library.name} created!`, + color: "success" + }); + } + + return ( + + + {(onClose) => ( + { + await createLibrary(values); + onClose(); + }} + > + {(formik) => ( +
+ Add a new library + +

Details

+
+ + +
+
+ + + + +
+ )} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/views/TestView.tsx b/gameyfin/src/main/frontend/views/TestView.tsx index 47852cf..8de0cf0 100644 --- a/gameyfin/src/main/frontend/views/TestView.tsx +++ b/gameyfin/src/main/frontend/views/TestView.tsx @@ -27,6 +27,16 @@ export default function TestView() { }); } + function removeLibraries() { + LibraryEndpoint.removeLibraries().then(() => { + addToast({ + title: "Success", + description: "Libraries removed", + color: "success" + }) + }); + } + return (
@@ -58,6 +68,7 @@ export default function TestView() { +
{game && } {game && <>{JSON.stringify(game, null, 2)}} diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt index dc5b8cf..e865dc3 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt @@ -21,6 +21,7 @@ import me.xdrop.fuzzywuzzy.FuzzySearch import org.pf4j.PluginManager import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.net.URI import java.net.URLConnection import java.nio.file.Path @@ -46,7 +47,7 @@ class GameService( return gameRepository.save(game) } - fun createFromFile(path: Path): GameDto { + fun createFromFile(path: Path): Game { val query = path.fileName.toString() // Step 0: Query all metadata plugins for metadata on the provided game title @@ -70,11 +71,10 @@ class GameService( val mergedGame = mergeResults(sortedResults, path) // Step 5: Save the new game - val savedGame = createOrUpdate(mergedGame) - - return toDto(savedGame) + return createOrUpdate(mergedGame) } + @Transactional(readOnly = true) fun getAllGames(): Collection { val entities = gameRepository.findAll() return entities.map { toDto(it) } @@ -252,7 +252,7 @@ class GameService( return mergedGame } - private fun toDto(game: Game): GameDto { + fun toDto(game: Game): GameDto { val gameId = game.id ?: throw IllegalArgumentException("Game ID is null") return GameDto( diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt index 435d467..5aedfe0 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/entities/Game.kt @@ -68,5 +68,7 @@ class Game( var metadata: Map = emptyMap(), @ElementCollection - var originalIds: Map = emptyMap() + var originalIds: Map = emptyMap(), + + var downloadCount: Int = 0 ) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/Library.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/Library.kt index 8ef3407..88514b1 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/Library.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/Library.kt @@ -14,6 +14,6 @@ class Library( @Column(unique = true) val path: String, - @OneToMany - val games: Set = emptySet() + @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true) + val games: MutableSet = mutableSetOf() ) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryDto.kt index cfdb3cb..9f5aa82 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryDto.kt @@ -3,5 +3,6 @@ package de.grimsi.gameyfin.libraries data class LibraryDto( val id: Long, val name: String, - val path: String + val path: String, + val stats: LibraryStatsDto? ) \ No newline at end of file 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 44fc0d4..37daafd 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt @@ -4,26 +4,37 @@ import com.vaadin.hilla.Endpoint import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.games.GameService import de.grimsi.gameyfin.games.dto.GameDto +import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed @Endpoint +@PermitAll class LibraryEndpoint( private val libraryService: LibraryService, private val gameService: GameService ) { - @RolesAllowed(Role.Names.ADMIN) fun getAllLibraries(): Collection { return libraryService.getAllLibraries() } + fun getGamesInLibrary(libraryId: Long): Collection { + return libraryService.getGamesInLibrary(libraryId) + } + + // FIXME: Just for testing + @RolesAllowed(Role.Names.ADMIN) + fun test(testString: String): GameDto { + return libraryService.test(testString) + } + @RolesAllowed(Role.Names.ADMIN) fun createLibrary(library: LibraryDto): LibraryDto { return libraryService.createOrUpdate(library) } @RolesAllowed(Role.Names.ADMIN) - fun test(testString: String): GameDto { - return libraryService.test(testString) + fun removeLibraries() { + return libraryService.deleteAllLibraries() } @RolesAllowed(Role.Names.ADMIN) diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryRepository.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryRepository.kt index 066ff5d..47fd84e 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryRepository.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryRepository.kt @@ -1,5 +1,11 @@ package de.grimsi.gameyfin.libraries +import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query -interface LibraryRepository : JpaRepository \ No newline at end of file +interface LibraryRepository : JpaRepository { + @EntityGraph(attributePaths = ["games"]) + @Query("SELECT l FROM Library l ORDER BY function('RAND') LIMIT 1") + fun findRandomLibrary(): Library? +} \ 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 0406e47..71c060b 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt @@ -6,6 +6,7 @@ import de.grimsi.gameyfin.games.GameService import de.grimsi.gameyfin.games.dto.GameDto import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.nio.file.Path import kotlin.io.path.Path import kotlin.io.path.extension @@ -18,8 +19,16 @@ class LibraryService( private val gameService: GameService, private val config: ConfigService ) { + fun test(testString: String): GameDto { - return gameService.createFromFile(Path(testString)) + val game = gameService.createFromFile(Path(testString)) + + val randomLibrary = libraryRepository.findRandomLibrary() ?: throw IllegalArgumentException("No library found") + + randomLibrary.games.add(game) + libraryRepository.save(randomLibrary) + + return gameService.toDto(game) } fun createOrUpdate(library: LibraryDto): LibraryDto { @@ -27,6 +36,7 @@ class LibraryService( return toDto(entity) } + @Transactional(readOnly = true) fun getAllLibraries(): Collection { val entities = libraryRepository.findAll() return entities.map { toDto(it) } @@ -37,6 +47,20 @@ class LibraryService( libraryRepository.delete(entity) } + fun deleteAllLibraries() { + libraryRepository.deleteAll() + } + + @Transactional(readOnly = true) + fun getGamesInLibrary(libraryId: Long): Collection { + val library = libraryRepository.findByIdOrNull(libraryId) + ?: throw IllegalArgumentException("Library with ID $libraryId not found") + + val games = library.games.map { gameService.toDto(it) } + + return games + } + /** * Triggers a scan for a list of libraries. If no list is provided, all libraries will be scanned. */ @@ -72,16 +96,21 @@ class LibraryService( throw IllegalArgumentException("Library ID is null") } + val statsDto = LibraryStatsDto( + gamesCount = library.games.size, + downloadedGamesCount = library.games.sumOf { it.downloadCount } + ) + return LibraryDto( id = library.id, name = library.name, - path = library.path + path = library.path, + stats = statsDto ) } private fun toEntity(library: LibraryDto): Library { return libraryRepository.findByIdOrNull(library.id) ?: Library( - id = library.id, name = library.name, path = library.path ) diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryStatsDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryStatsDto.kt new file mode 100644 index 0000000..953b13e --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryStatsDto.kt @@ -0,0 +1,6 @@ +package de.grimsi.gameyfin.libraries + +data class LibraryStatsDto( + val gamesCount: Int, + val downloadedGamesCount: Int, +) \ No newline at end of file