First prototype of game libraries

This commit is contained in:
grimsi
2025-04-01 17:34:18 +02:00
parent caf8a0641c
commit a02fae883c
17 changed files with 332 additions and 47 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080">
<configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080">
<method v="2" />
</configuration>
</component>
+20
View File
@@ -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",
+6 -2
View File
@@ -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"
}
}
@@ -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<LibraryDto> = useListData({});
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
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 (
<div className="flex flex-col">
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("library.allow-public-access")}/>
@@ -38,20 +45,26 @@ function LibraryManagementLayout({getConfig, formik}: any) {
<div className="flex flex-row items-baseline justify-between">
<h2 className={"text-xl font-bold mt-8 mb-1"}>Libraries</h2>
<Tooltip content="Add new library">
<Button isIconOnly variant="flat" onPress={() => {
libraries.append({id: 1, name: "Library", path: "/path/to/library"} as LibraryDto)
}}>
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
<Plus/>
</Button>
</Tooltip>
</div>
<Divider className="mb-4"/>
{libraries.items.length > 0 ?
<div className="grid grid-cols-300px gap-4">
{libraries.items.map((library) => <LibraryOverviewCard library={library} key={library.name}/>)}
{libraries.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{libraries.map((library) => <LibraryOverviewCard library={library} key={library.name}/>)}
</div> :
"No libraries configured. Add your first library!"
}
<LibraryCreationModal
libraries={libraries}
setLibraries={setLibraries}
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>
</div>
);
}
@@ -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 (
<Image
alt={game.title}
className="z-0 w-full h-full object-cover aspect-[12/17]"
src={`images/cover/${game.coverId}`}
radius={radius}
height={size}
/>
);
}
@@ -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 (
<Card className="h-80 aspect-[12/17]">
<Image
removeWrapper
alt={game.title}
className="z-0 w-full h-full object-cover"
src={`images/cover/${game.coverId}`}
/>
</Card>
<GameCover game={game} radius="sm"></GameCover>
);
}
@@ -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<GameDto[]>([]);
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 (
<Card className="flex flex-row justify-between p-2">
<div className="flex flex-col flex-1 items-center gap-4">
<p className="text-2xl font-bold">{library.name}</p>
<p>{library.path}</p>
<Card className="flex flex-col justify-between w-[353px]">
<div className="flex flex-1 justify-center items-center">
<div className="flex flex-1 opacity-10 min-h-[100px]">
<div className="absolute w-full h-full opacity-50">
<GameController size={32}
className="absolute fill-primary top-[10%] left-[10%] rotate-[350deg]"/>
<SoccerBall size={34}
className="absolute fill-primary top-[50%] left-[35%] rotate-[60deg]"/>
<Joystick size={40} className="absolute top-[30%] left-[50%] rotate-[90deg]"/>
<Strategy size={36} className="absolute fill-primary top-[50%] left-[70%] rotate-[30deg]"/>
<Sword size={28} className="absolute top-[70%] left-[10%] rotate-[60deg]"/>
<Alien size={34} className="absolute fill-primary top-[10%] left-[85%] rotate-[15deg]"/>
<CastleTurret size={30} className="absolute top-[5%] left-[40%] rotate-[320deg]"/>
<Ghost size={38} className="absolute fill-primary top-[40%] left-[5%] rotate-[300deg]"/>
<Skull size={32} className="absolute top-[80%] left-[30%] rotate-[90deg]"/>
<Trophy size={36} className="absolute fill-primary top-[10%] left-[60%] rotate-[45deg]"/>
<Lego size={28} className="absolute top-[30%] left-[20%] rotate-[30deg]"/>
<TreasureChest size={40} className="absolute top-[70%] left-[50%] rotate-[75deg]"/>
</div>
{randomGamesFromLibrary.length > 0 &&
<div className="absolute flex flex-row">
{randomGamesFromLibrary.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
))}
</div>
}
</div>
<p className="absolute text-2xl font-bold">{library.name}</p>
</div>
{!!library.stats &&
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
<p>Games</p>
<p>Downloads</p>
<p>Platforms</p>
<p className="font-bold">{library.stats.gamesCount}</p>
<p className="font-bold">{library.stats.downloadedGamesCount}</p>
<Chip size="sm">PC</Chip>
</div>
}
</Card>
);
}
@@ -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 (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<Formik initialValues={{name: "", path: ""}}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
>
{(formik) => (
<Form>
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
<ModalBody>
<h4 className="text-l font-bold">Details</h4>
<div className="flex flex-col gap-2">
<Input
name="name"
label="Library Name"
placeholder="Enter library name"
value={formik.values.name}
required
/>
<Input
name="path"
label="path"
placeholder="Enter library path"
value={formik.values.path}
required
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</ModalFooter>
</Form>
)}
</Formik>
)}
</ModalContent>
</Modal>
);
}
@@ -27,6 +27,16 @@ export default function TestView() {
});
}
function removeLibraries() {
LibraryEndpoint.removeLibraries().then(() => {
addToast({
title: "Success",
description: "Libraries removed",
color: "success"
})
});
}
return (
<div className="grow justify-center mt-12">
<div className="flex flex-col items-center gap-6">
@@ -58,6 +68,7 @@ export default function TestView() {
<Input label="Game title" onValueChange={setGameTitle}/>
<Button onPress={getGame} size="lg">Match</Button>
<Button onPress={removeGames} size="lg">Clear DB</Button>
<Button onPress={removeLibraries} size="lg">Clear Libs</Button>
</div>
{game && <GameOverviewCard game={game}></GameOverviewCard>}
{game && <>{JSON.stringify(game, null, 2)}</>}
@@ -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<GameDto> {
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(
@@ -68,5 +68,7 @@ class Game(
var metadata: Map<String, FieldMetadata> = emptyMap(),
@ElementCollection
var originalIds: Map<PluginManagementEntry, String> = emptyMap()
var originalIds: Map<PluginManagementEntry, String> = emptyMap(),
var downloadCount: Int = 0
)
@@ -14,6 +14,6 @@ class Library(
@Column(unique = true)
val path: String,
@OneToMany
val games: Set<Game> = emptySet()
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
val games: MutableSet<Game> = mutableSetOf()
)
@@ -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?
)
@@ -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<LibraryDto> {
return libraryService.getAllLibraries()
}
fun getGamesInLibrary(libraryId: Long): Collection<GameDto> {
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)
@@ -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<Library, Long>
interface LibraryRepository : JpaRepository<Library, Long> {
@EntityGraph(attributePaths = ["games"])
@Query("SELECT l FROM Library l ORDER BY function('RAND') LIMIT 1")
fun findRandomLibrary(): Library?
}
@@ -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<LibraryDto> {
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<GameDto> {
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
)
@@ -0,0 +1,6 @@
package de.grimsi.gameyfin.libraries
data class LibraryStatsDto(
val gamesCount: Int,
val downloadedGamesCount: Int,
)