Implement realtime UI for games

Minor refactorings and cleanups
Minor UI fixes and improvement
This commit is contained in:
grimsi
2025-05-22 12:07:53 +02:00
parent ad2fee0c3f
commit c68ecedd03
23 changed files with 382 additions and 340 deletions
+2
View File
@@ -10,6 +10,7 @@ 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";
import {initializeGameState} from "Frontend/state/GameState";
export default function App() {
const navigate = useNavigate();
@@ -17,6 +18,7 @@ export default function App() {
client.middlewares = [ErrorHandlingMiddleware];
initializeLibraryState();
initializeGameState();
return (
<HeroUIProvider className="size-full" navigate={navigate} useHref={useHref}>
@@ -31,7 +31,7 @@ function LibraryManagementLayout({getConfig, formik}: any) {
}
async function removeLibrary(library: LibraryDto) {
await LibraryEndpoint.removeLibrary(library.id);
await LibraryEndpoint.deleteLibrary(library.id);
addToast({
title: "Library removed",
description: `Library ${library.name} has been removed.`,
@@ -47,6 +47,12 @@ function LibraryManagementLayout({getConfig, formik}: any) {
<Section title="Scanning"/>
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
<div className="flex flex-row gap-4">
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
<p className="text-foreground/80">
Minimum required Levenshtein ratio to consider two titles the same.
</p>
</div>
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
<Section title="Metadata"/>
@@ -0,0 +1,32 @@
import {
Alien,
CastleTurret,
GameController,
Ghost,
Joystick,
Lego,
Skull,
SoccerBall,
Strategy,
Sword,
TreasureChest,
Trophy
} from "@phosphor-icons/react";
import React from "react";
export default function IconBackgroundPattern() {
return <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>
}
@@ -1,28 +1,15 @@
import {Button, Card, Chip, Tooltip} from "@heroui/react";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import React, {useEffect, useState} from "react";
import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import {
Alien,
CastleTurret,
GameController,
Ghost,
Joystick,
Lego,
MagnifyingGlass,
Skull,
SlidersHorizontal,
SoccerBall,
Strategy,
Sword,
TreasureChest,
Trophy
} from "@phosphor-icons/react";
import {MagnifyingGlass, SlidersHorizontal} from "@phosphor-icons/react";
import ScanType from "Frontend/generated/de/grimsi/gameyfin/libraries/enums/ScanType";
import {randomGamesFromLibrary} from "Frontend/util/utils";
import {useNavigate} from "react-router";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
interface LibraryOverviewCardProps {
library: LibraryDto;
@@ -31,75 +18,59 @@ interface LibraryOverviewCardProps {
export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
const MAX_COVER_COUNT = 5;
const navigate = useNavigate();
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
const state = useSnapshot(gameState);
const randomGames = getRandomGames();
useEffect(() => {
randomGamesFromLibrary(library, MAX_COVER_COUNT).then((games) => {
setRandomGames(games);
})
}, []);
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
async function triggerScan() {
await LibraryEndpoint.triggerScan(ScanType.QUICK, [library]);
}
return (
<>
<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]"/>
<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]">
<IconBackgroundPattern/>
{randomGames.length > 0 &&
<div className="absolute flex flex-row">
{randomGames.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
))}
</div>
{randomGames.length > 0 &&
<div className="absolute flex flex-row">
{randomGames.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 className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Scan library" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={triggerScan}>
<MagnifyingGlass/>
</Button>
</Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
<SlidersHorizontal/>
</Button>
</Tooltip>
</div>
}
</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>
</>
<p className="absolute text-2xl font-bold">{library.name}</p>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Scan library" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={triggerScan}>
<MagnifyingGlass/>
</Button>
</Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
<SlidersHorizontal/>
</Button>
</Tooltip>
</div>
</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>
);
}
@@ -1,7 +1,10 @@
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import React, {useEffect, useState} from "react";
import React from "react";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {randomGamesFromLibrary} from "Frontend/util/utils";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import {Card} from "@heroui/react";
interface LibraryHeaderProps {
library: LibraryDto;
@@ -9,39 +12,39 @@ interface LibraryHeaderProps {
}
export default function LibraryHeader({library, className}: LibraryHeaderProps) {
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
const maxCoverCount = 5;
const MAX_COVER_COUNT = 5;
const state = useSnapshot(gameState);
const randomGames = getRandomGames();
useEffect(() => {
randomGamesFromLibrary(library, maxCoverCount).then((games) => {
setRandomGames(games);
});
}, [library, maxCoverCount]);
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
return (
<div className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
<Card className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
<IconBackgroundPattern/>
<div className="flex flex-row items-center w-full h-full brightness-50">
{randomGames
.map((game, idx) => (
<div
key={idx}
className="flex-none overflow-hidden -ml-[10%]"
style={{
width: `calc(100% / ${maxCoverCount - 2})`,
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
}}
>
<img
src={`/images/screenshot/${game.imageIds![0]}`}
alt={`Image ${idx}`}
/>
</div>
))
.slice(0, maxCoverCount)}
{randomGames.map((game, idx) => (
<div
key={idx}
className="flex-none overflow-hidden -ml-[10%]"
style={{
width: `calc(100% / ${MAX_COVER_COUNT - 2})`,
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
}}
>
<img
src={`/images/screenshot/${game.imageIds![0]}`}
alt={`Image ${idx}`}
/>
</div>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-white text-3xl font-bold">{library.name}</h2>
</div>
</div>
</Card>
);
}
@@ -33,7 +33,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
async function handleDelete(): Promise<void> {
try {
await LibraryEndpoint.removeLibrary(library.id);
await LibraryEndpoint.deleteLibrary(library.id);
addToast({
title: "Library deleted",
@@ -1,22 +1,17 @@
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {useEffect, useState} from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {Button, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@heroui/react";
import {CheckCircle, Pencil, Trash} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
interface LibraryManagementGamesProps {
library: LibraryDto;
}
export default function LibraryManagementGames({library}: LibraryManagementGamesProps) {
const [games, setGames] = useState<GameDto[]>([]);
useEffect(() => {
LibraryEndpoint.getGamesInLibrary(library.id).then((games) => {
setGames(games);
})
}, []);
const state = useSnapshot(gameState);
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : undefined;
return <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage games in library</h1>
@@ -0,0 +1,80 @@
import {Subscription} from "@vaadin/hilla-frontend";
import {proxy} from "valtio/index";
import {GameEndpoint} from "Frontend/generated/endpoints";
import GameEvent from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryEvent";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import Rand from "rand-seed";
type GameState = {
subscription?: Subscription<GameEvent>;
isLoaded: boolean;
state: Record<number, GameDto>;
games: GameDto[];
gamesByLibraryId: Record<number, GameDto[]>;
sortedByMostRecentlyAdded: GameDto[];
sortedByMostRecentlyUpdated: GameDto[];
randomlyOrderedGamesByLibraryId: Record<number, GameDto[]>;
};
export const gameState = proxy<GameState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get games() {
return Object.values<GameDto>(this.state);
},
get gamesByLibraryId() {
return this.games.reduce((acc: Record<number, GameDto[]>, game: GameDto) => {
(acc[game.libraryId] ||= []).push(game);
return acc;
}, {});
},
get sortedByMostRecentlyAdded() {
return this.games
.sort((a: GameDto, b: GameDto) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
},
get sortedByMostRecentlyUpdated() {
return this.games
.sort((a: GameDto, b: GameDto) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
},
get randomlyOrderedGamesByLibraryId() {
const result: Record<number, GameDto[]> = {};
for (const libraryId in this.gamesByLibraryId) {
const rand = new Rand(libraryId.toString());
result[libraryId] = this.gamesByLibraryId[libraryId]
.filter((g: GameDto) => g.coverId && g.imageIds && g.imageIds.length > 0)
.sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5);
}
return result;
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeGameState() {
if (gameState.isLoaded) return gameState;
// Fetch initial library list
const initialEntries = await GameEndpoint.getAll();
initialEntries.forEach((game: GameDto) => {
gameState.state[game.id] = game;
});
// Subscribe to real-time updates
gameState.subscription = GameEndpoint.subscribe().onNext((gameEvent) => {
switch (gameEvent.type) {
case "created":
case "updated":
//@ts-ignore
gameState.state[gameEvent.game.id] = gameEvent.game;
break;
case "deleted":
//@ts-ignore
delete gameState.state[gameEvent.gameId];
break;
}
});
return gameState;
}
@@ -7,7 +7,7 @@ import LibraryEvent from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/Li
type LibraryState = {
subscription?: Subscription<LibraryEvent>;
isLoaded: boolean;
state: Record<string, LibraryDto>;
state: Record<number, LibraryDto>;
libraries: LibraryDto[];
sorted: LibraryDto[];
};
-37
View File
@@ -1,23 +1,5 @@
import {getCsrfToken} from "Frontend/util/auth";
import moment from 'moment-timezone';
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import Rand from "rand-seed";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
export function cssVar(variable: string) {
return getComputedStyle(document.documentElement).getPropertyValue(`--${variable}`);
}
export function hsl(hsl: string) {
return `hsl(${hsl}`;
}
export function rand(min: number, max: number) {
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}
export function roleToRoleName(role: string) {
role = role.replace("ROLE_", "").toLowerCase();
@@ -125,23 +107,6 @@ export function humanFileSize(bytes: number, si: boolean = false, dp: number = 1
return bytes.toFixed(dp) + ' ' + units[u];
}
/**
* Select a random number of games from the library based on the library ID.
* @param library
* @param count
* @returns {GameDto[]}
*/
export async function randomGamesFromLibrary(library: LibraryDto, count?: number): Promise<GameDto[]> {
const rand = new Rand(library.id.toString());
const games = await LibraryEndpoint.getGamesInLibrary(library.id);
return games
.sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5)
.filter(g => g.imageIds && g.imageIds.length > 0)
.slice(0, count ?? games.length);
}
/**
* Return an object with the changed fields between two objects.
* The returned object will only contain the changed fields with values from the current object.
@@ -149,8 +114,6 @@ export async function randomGamesFromLibrary(library: LibraryDto, count?: number
* @param current
*/
export function deepDiff<T extends object>(initial: T, current: T): Partial<T> {
const diff: Partial<T> = {};
function compareObjects(obj1: any, obj2: any): any {
if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) {
if (obj1 !== obj2) {
+24 -18
View File
@@ -1,18 +1,22 @@
import {useEffect, useState} from "react";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {DownloadProviderEndpoint, GameEndpoint} from "Frontend/generated/endpoints";
import {useParams} from "react-router";
import {DownloadProviderEndpoint} from "Frontend/generated/endpoints";
import {useNavigate, useParams} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
import {Chip} from "@heroui/react";
import {humanFileSize, toTitleCase} from "Frontend/util/utils";
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
import {gameState, initializeGameState} from "Frontend/state/GameState";
import {useSnapshot} from "valtio/react";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
export default function GameView() {
const {gameId} = useParams();
const navigate = useNavigate();
const state = useSnapshot(gameState);
const game = gameId ? state.state[parseInt(gameId)] as GameDto : undefined;
const [game, setGame] = useState<GameDto>();
const [downloadOptions, setDownloadOptions] = useState<Record<string, ComboButtonOption>>({});
useEffect(() => {
@@ -32,15 +36,17 @@ export default function GameView() {
}, []);
useEffect(() => {
if (gameId) {
GameEndpoint.getGame(parseInt(gameId)).then((game) => setGame(game));
}
initializeGameState().then((state) => {
if (!gameId || !state.state[parseInt(gameId)]) {
navigate("/");
}
});
}, [gameId]);
return (game && (
return game && (
<div className="flex flex-col gap-4">
<div className="overflow-hidden relative rounded-t-lg">
{(game.imageIds !== undefined && game.imageIds.length > 0) ?
{(game.imageIds && game.imageIds.length > 0) ?
<img className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
alt="Game screenshot"
src={`/images/screenshot/${game.imageIds[0]}`}
@@ -79,14 +85,14 @@ export default function GameView() {
<table className="text-left w-full table-auto">
<tbody>
{Object.entries({
"Developed by": game.developers?.sort().join(" / ") || "unknown",
"Published by": game.publishers?.sort().join(" / ") || "unknown",
"Genres": game.genres?.sort().map(p => <Chip
radius="sm">{toTitleCase(p)}</Chip>),
"Themes": game.themes?.sort().map(p => <Chip
radius="sm">{toTitleCase(p)}</Chip>),
"Features": game.features?.sort().map(p => <Chip
radius="sm">{toTitleCase(p)}</Chip>),
"Developed by": game.developers ? [...game.developers].sort().join(" / ") : "unknown",
"Published by": game.publishers ? [...game.publishers].sort().join(" / ") : "unknown",
"Genres": game.genres ? [...game.genres].sort().map(p =>
<Chip radius="sm" key={p}>{toTitleCase(p)}</Chip>) : undefined,
"Themes": game.themes ? [...game.themes].sort().map(p =>
<Chip radius="sm" key={p}>{toTitleCase(p)}</Chip>) : undefined,
"Features": game.features ? [...game.features].sort().map(p =>
<Chip radius="sm" key={p}>{toTitleCase(p)}</Chip>) : undefined,
}).map(([key, value]) => (
<tr key={key}>
<td className="text-foreground/60 w-0 min-w-32">{key}</td>
@@ -108,5 +114,5 @@ export default function GameView() {
</div>
</div>
</div>
));
);
}
+7 -28
View File
@@ -1,44 +1,23 @@
import {useEffect, useState} from "react";
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";
import {gameState} from "Frontend/state/GameState";
export default function HomeView() {
const [recentlyAddedGames, setRecentlyAddedGames] = useState<GameDto[]>([]);
const [libraryIdToGames, setLibraryIdToGames] = useState<Map<number, GameDto[]>>(new Map());
const state = useSnapshot(libraryState);
useEffect(() => {
const gamePromises = state.libraries.map((library) =>
//@ts-ignore
randomGamesFromLibrary(library).then((games) => [library.id, games] as [number, GameDto[]])
);
Promise.all(gamePromises).then((results) => {
const libraryGamesMap = new Map<number, GameDto[]>();
results.forEach(([libraryId, games]) => {
libraryGamesMap.set(libraryId, games);
});
setLibraryIdToGames(libraryGamesMap);
});
// TODO: see https://github.com/vaadin/hilla/issues/3470
GameEndpoint.getMostRecentlyAddedGames(undefined).then(games => {
setRecentlyAddedGames(games);
});
}, []);
const librariesState = useSnapshot(libraryState);
const gamesState = useSnapshot(gameState);
const recentlyAddedGames = gamesState.sortedByMostRecentlyAdded as GameDto[];
const gamesByLibrary = gamesState.gamesByLibraryId as Record<number, GameDto[]>;
return (
<div className="w-full">
<div className="flex flex-col gap-2">
<CoverRow title="Recently added" games={recentlyAddedGames}
onPressShowMore={() => alert("show more of 'Recently added'")}/>
{state.libraries.map((library) => (
{librariesState.libraries.map((library) => (
<CoverRow key={library.id} title={library.name}
games={libraryIdToGames.get(library.id) || []}
games={gamesByLibrary[library.id] || []}
onPressShowMore={() => alert(`show more of library '${library.name}'`)}
/>
))}
@@ -16,13 +16,13 @@ export default function LibraryManagementView() {
useEffect(() => {
initializeLibraryState().then((state) => {
if (!libraryId || !state.state[libraryId]) {
if (!libraryId || !state.state[parseInt(libraryId)]) {
navigate("/administration/libraries");
}
});
}, []);
}, [libraryId]);
return libraryId && state.state[libraryId] && <div className="flex flex-col gap-4">
return libraryId && state.state[parseInt(libraryId)] && <div className="flex flex-col gap-4">
<div className="flex flex-row gap-4 items-center">
<Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}>
<ArrowLeft/>
@@ -36,6 +36,13 @@ sealed class ConfigProperties<T : Serializable>(
false
)
data object TitleMatchMinRatio : ConfigProperties<Int>(
Int::class,
"library.scan.title-match-min-ratio",
"Minimum ratio for title matching (0-100)",
90
)
data object GameFileExtensions : ConfigProperties<Array<String>>(
Array<String>::class,
"library.scan.game-file-extensions",
@@ -20,7 +20,7 @@ class DownloadEndpoint(
@PathVariable gameId: Long,
@RequestParam provider: String
): ResponseEntity<StreamingResponseBody> {
val game = gameService.getGame(gameId)
val game = gameService.getById(gameId)
val download = downloadService.getDownload(game.path, provider)
return when (download) {
@@ -3,29 +3,27 @@ package de.grimsi.gameyfin.games
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.games.dto.GameEvent
import de.grimsi.gameyfin.games.dto.GameUpdateDto
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
import reactor.core.publisher.Flux
@Endpoint
@PermitAll
class GameEndpoint(
private val gameService: GameService
) {
fun getGame(id: Long): GameDto {
return gameService.getGame(id)
fun subscribe(): Flux<GameEvent> {
return gameService.subscribe()
}
fun getMostRecentlyAddedGames(n: Int?): List<GameDto> {
return gameService.getMostRecentlyAdded(n ?: 10)
}
fun getMostRecentlyUpdatedGames(n: Int?): List<GameDto> {
return gameService.getMostRecentlyUpdated(n ?: 10)
}
fun getAll(): List<GameDto> = gameService.getAll()
@RolesAllowed(Role.Names.ADMIN)
fun removeGames() {
return gameService.deleteAll()
}
fun updateGame(game: GameUpdateDto) = gameService.update(game)
@RolesAllowed(Role.Names.ADMIN)
fun deleteGame(gameId: Long) = gameService.delete(gameId)
}
@@ -1,49 +1,66 @@
package de.grimsi.gameyfin.games
import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.core.alphaNumeric
import de.grimsi.gameyfin.core.filterValuesNotNull
import de.grimsi.gameyfin.core.plugins.PluginService
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.core.replaceRomanNumerals
import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.games.dto.GameEvent
import de.grimsi.gameyfin.games.dto.GameMetadataDto
import de.grimsi.gameyfin.games.dto.GameUpdateDto
import de.grimsi.gameyfin.games.entities.*
import de.grimsi.gameyfin.games.repositories.GameRepository
import de.grimsi.gameyfin.libraries.Library
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.persistence.EntityManager
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import me.xdrop.fuzzywuzzy.FuzzySearch
import org.apache.commons.io.FilenameUtils
import org.pf4j.PluginManager
import org.springframework.data.domain.Limit
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import java.nio.file.Path
@Service
class GameService(
private val pluginManager: PluginManager,
private val pluginService: PluginService,
private val config: ConfigService,
private val companyService: CompanyService,
private val gameRepository: GameRepository,
private val companyService: CompanyService
private val entityManager: EntityManager
) {
companion object {
const val TITLE_MATCH_MIN_RATIO = 90
private val log = KotlinLogging.logger {}
}
private val metadataPlugins: List<GameMetadataProvider>
get() = pluginManager.getExtensions(GameMetadataProvider::class.java)
fun createOrUpdate(game: Game): Game {
gameRepository.findByPath(game.path)?.let { game.id = it.id }
return gameRepository.save(game)
private val gameEvents = Sinks.many().multicast().onBackpressureBuffer<GameEvent>(1024, false)
fun subscribe(): Flux<GameEvent> {
log.debug { "New subscription for gameUpdates" }
return gameEvents.asFlux()
.doOnSubscribe { log.debug { "Subscriber added to gameEvents [${gameEvents.currentSubscriberCount()}]" } }
.doFinally {
log.debug { "Subscriber removed from gameEvents with signal type $it [${gameEvents.currentSubscriberCount()}]" }
}
}
fun getAll(): List<GameDto> {
val entities = gameRepository.findAll()
return entities.map { it.toDto() }
}
@Transactional
@@ -56,12 +73,39 @@ class GameService(
game
}
return gameRepository.saveAll(gamesToBePersisted)
val games = gameRepository.saveAll(gamesToBePersisted)
// force flush to populate creation and update timestamp
entityManager.flush()
games.forEach { game ->
val gameDto = game.toDto()
gameEvents.tryEmitNext(GameEvent.Created(gameDto))
}
return games
}
fun getGame(id: Long): GameDto {
return gameRepository.findByIdOrNull(id)?.toDto()
?: throw IllegalArgumentException("Game with id $id not found")
fun update(gameUpdateDto: GameUpdateDto) {
val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found")
// Update only non-null fields
gameUpdateDto.title?.let { existingGame.title = it }
gameUpdateDto.comment?.let { existingGame.comment = it }
gameUpdateDto.summary?.let { existingGame.summary = it }
val updatedGame = gameRepository.save(existingGame)
val updatedGameDto = updatedGame.toDto()
gameEvents.tryEmitNext(GameEvent.Updated(updatedGameDto))
}
fun delete(gameId: Long) {
gameRepository.deleteById(gameId)
gameEvents.tryEmitNext(GameEvent.Deleted(gameId))
}
fun emitDeletionEvent(gameId: Long) {
gameEvents.tryEmitNext(GameEvent.Deleted(gameId))
}
fun matchFromFile(path: Path, library: Library): Game? {
@@ -91,30 +135,7 @@ class GameService(
return gameRepository.findAllByPathIn(paths)
}
fun getAllGames(): List<GameDto> {
val entities = gameRepository.findAll()
return entities.map { it.toDto() }
}
fun delete(game: Game) {
gameRepository.delete(game)
}
fun deleteAll() {
gameRepository.deleteAll()
}
fun getMostRecentlyAdded(count: Int): List<GameDto> {
return gameRepository.findByOrderByCreatedAtDesc(Limit.of(count))
.map { it.toDto() }
}
fun getMostRecentlyUpdated(count: Int): List<GameDto> {
return gameRepository.findByOrderByCreatedAtDesc(Limit.of(count))
.map { it.toDto() }
}
private fun getById(id: Long): Game {
fun getById(id: Long): Game {
return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found")
}
@@ -295,7 +316,8 @@ class GameService(
return mergedGame
}
private fun String.fuzzyMatchTitle(other: String, minRatio: Int = TITLE_MATCH_MIN_RATIO): Boolean {
private fun String.fuzzyMatchTitle(other: String): Boolean {
val minRatio = config.get(ConfigProperties.Libraries.Scan.TitleMatchMinRatio)!!
return FuzzySearch.ratio(this.normalizeGameTitle(), other.normalizeGameTitle()) > minRatio
}
@@ -317,18 +339,18 @@ fun Game.toDto(): GameDto {
}
val thisId = this.id ?: throw IllegalArgumentException("this ID is null")
val createdAt = this.createdAt ?: throw IllegalArgumentException("this creation timestamp is null")
val updatedAt = this.updatedAt ?: throw IllegalArgumentException("this update timestamp is null")
val thisLibraryId = this.library.id ?: throw IllegalArgumentException("this library ID is null")
val thisTitle = this.title ?: throw IllegalArgumentException("this title is null")
val id = this.id ?: throw IllegalArgumentException("ID is null")
val createdAt = this.createdAt ?: throw IllegalArgumentException("creation timestamp is null")
val updatedAt = this.updatedAt ?: throw IllegalArgumentException("update timestamp is null")
val libraryId = this.library.id ?: throw IllegalArgumentException("library ID is null")
val title = this.title ?: throw IllegalArgumentException("title is null")
return GameDto(
id = thisId,
id = id,
createdAt = createdAt,
updatedAt = updatedAt,
libraryId = thisLibraryId,
title = thisTitle,
libraryId = libraryId,
title = title,
coverId = this.coverImage?.id,
comment = this.comment,
summary = this.summary,
@@ -0,0 +1,9 @@
package de.grimsi.gameyfin.games.dto
sealed class GameEvent {
abstract val type: String
data class Created(val game: GameDto, override val type: String = "created") : GameEvent()
data class Updated(val game: GameDto, override val type: String = "updated") : GameEvent()
data class Deleted(val gameId: Long, override val type: String = "deleted") : GameEvent()
}
@@ -0,0 +1,8 @@
package de.grimsi.gameyfin.games.dto
data class GameUpdateDto(
val id: Long,
val title: String?,
val comment: String?,
val summary: String?,
)
@@ -36,7 +36,7 @@ class Game(
@Lob
@Column(columnDefinition = "CLOB")
val comment: String? = null,
var comment: String? = null,
@Lob
@Column(columnDefinition = "CLOB")
@@ -21,8 +21,6 @@ class LibraryEndpoint(
fun getAll() = libraryService.getAll()
fun getGamesInLibrary(libraryId: Long) = libraryService.getGamesInLibrary(libraryId)
@RolesAllowed(Role.Names.ADMIN)
fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection<LibraryDto>?) =
libraryService.triggerScan(scanType, libraries)
@@ -34,5 +32,5 @@ class LibraryEndpoint(
fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library)
@RolesAllowed(Role.Names.ADMIN)
fun removeLibrary(libraryId: Long) = libraryService.deleteLibrary(libraryId)
fun deleteLibrary(libraryId: Long) = libraryService.delete(libraryId)
}
@@ -2,9 +2,7 @@ package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.core.filesystem.FilesystemService
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.*
import de.grimsi.gameyfin.libraries.enums.ScanType
import de.grimsi.gameyfin.media.ImageService
@@ -35,14 +33,22 @@ class LibraryService(
private val libraryEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryEvent>(1024, false)
fun subscribe(): Flux<LibraryEvent> {
log.debug { "New subscription for libraryUpdates" }
log.debug { "New subscription for libraryEvents" }
return libraryEvents.asFlux()
.doOnSubscribe { log.debug { "Subscriber added to libraryUpdates [${libraryEvents.currentSubscriberCount()}]" } }
.doOnSubscribe { log.debug { "Subscriber added to libraryEvents [${libraryEvents.currentSubscriberCount()}]" } }
.doFinally {
log.debug { "Subscriber removed from libraryUpdates with signal type $it [${libraryEvents.currentSubscriberCount()}]" }
log.debug { "Subscriber removed from libraryEvents with signal type $it [${libraryEvents.currentSubscriberCount()}]" }
}
}
/**
* Retrieves all libraries from the repository.
*/
fun getAll(): List<LibraryDto> {
val entities = libraryRepository.findAll()
return entities.map { toDto(it) }
}
/**
* Creates or updates a library in the repository.
*
@@ -58,17 +64,17 @@ class LibraryService(
/**
* Updates a library entity with the non-null fields from a LibraryUpdateDto.
*
* @param libraryDto: The LibraryUpdateDto containing the fields to update.
* @param libraryUpdateDto: The LibraryUpdateDto containing the fields to update.
* @return The updated LibraryDto.
* @throws IllegalArgumentException if the library ID is null or the library is not found.
*/
fun update(libraryDto: LibraryUpdateDto) {
val existingLibrary = libraryRepository.findByIdOrNull(libraryDto.id)
?: throw IllegalArgumentException("Library with ID $libraryDto.id not found")
fun update(libraryUpdateDto: LibraryUpdateDto) {
val existingLibrary = libraryRepository.findByIdOrNull(libraryUpdateDto.id)
?: throw IllegalArgumentException("Library with ID $libraryUpdateDto.id not found")
// Update only non-null fields
libraryDto.name?.let { existingLibrary.name = it }
libraryDto.directories?.let {
libraryUpdateDto.name?.let { existingLibrary.name = it }
libraryUpdateDto.directories?.let {
existingLibrary.directories = it
.map { d -> DirectoryMapping(internalPath = d.internalPath, externalPath = d.externalPath) }
.toMutableList()
@@ -79,77 +85,19 @@ class LibraryService(
libraryEvents.tryEmitNext(LibraryEvent.Updated(updatedLibraryDto))
}
/**
* Retrieves all libraries from the repository.
*/
fun getAll(): List<LibraryDto> {
val entities = libraryRepository.findAll()
return entities.map { toDto(it) }
}
/**
* Retrieves a library by its ID.
*
* @param libraryId: ID of the library to retrieve.
* @return The LibraryDto object representing the library.
*/
fun getById(libraryId: Long): LibraryDto {
val library = libraryRepository.findByIdOrNull(libraryId)
?: throw IllegalArgumentException("Library with ID $libraryId not found")
return toDto(library)
}
/**
* Deletes a library from the repository.
*
* @param libraryId: ID of the library to delete.
*/
fun deleteLibrary(libraryId: Long) {
libraryRepository.deleteById(libraryId)
libraryEvents.tryEmitNext(LibraryEvent.Deleted(libraryId))
}
/**
* Retrieves all games in a library.
*
* @param libraryId: The ID of the library to retrieve games from.
* @return A collection of GameDto objects representing the games in the library.
*/
fun getGamesInLibrary(libraryId: Long): Collection<GameDto> {
val library = libraryRepository.findByIdOrNull(libraryId)
fun delete(libraryId: Long) {
val gameIds = libraryRepository.findByIdOrNull(libraryId)?.games?.mapNotNull { it.id }
?: throw IllegalArgumentException("Library with ID $libraryId not found")
val games = library.games.map { it.toDto() }
libraryRepository.deleteById(libraryId)
return games
}
/**
* Adds a game to the library.
*
* @param game: The game to add.
* @param library: The library to add the game to.
* @return The updated library.
*/
fun addGameToLibrary(game: Game, library: Library): Library {
if (library.games.any { it.id == game.id }) return library
library.games.add(game)
return libraryRepository.save(library)
}
/**
* Adds a collection of games to the library.
*
* @param games: The collection of games to add.
* @param library: The library to add the games to.
* @return The updated library.
*/
fun addGamesToLibrary(games: Collection<Game>, library: Library): Library {
val newGames = games.filter { game -> library.games.none { it.id == game.id } }
library.games.addAll(newGames)
return library
libraryEvents.tryEmitNext(LibraryEvent.Deleted(libraryId))
gameIds.forEach { gameService.emitDeletionEvent(it) }
}
/**
@@ -325,10 +273,24 @@ class LibraryService(
id = libraryId,
name = library.name,
directories = library.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
games = library.games.mapNotNull { it.id },
stats = statsDto
)
}
/**
* Adds a collection of games to the library.
*
* @param games: The collection of games to add.
* @param library: The library to add the games to.
* @return The updated library.
*/
private fun addGamesToLibrary(games: Collection<Game>, library: Library): Library {
val newGames = games.filter { game -> library.games.none { it.id == game.id } }
library.games.addAll(newGames)
return library
}
/**
* Converts a LibraryDto to a Library entity.
*
@@ -4,5 +4,6 @@ data class LibraryDto(
val id: Long,
val name: String,
val directories: List<DirectoryMappingDto>,
val games: List<Long>?,
val stats: LibraryStatsDto?
)