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 client from "Frontend/generated/connect-client.default";
import {ErrorHandlingMiddleware} from "Frontend/util/middleware"; import {ErrorHandlingMiddleware} from "Frontend/util/middleware";
import {initializeLibraryState} from "Frontend/state/LibraryState"; import {initializeLibraryState} from "Frontend/state/LibraryState";
import {initializeGameState} from "Frontend/state/GameState";
export default function App() { export default function App() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,6 +18,7 @@ export default function App() {
client.middlewares = [ErrorHandlingMiddleware]; client.middlewares = [ErrorHandlingMiddleware];
initializeLibraryState(); initializeLibraryState();
initializeGameState();
return ( return (
<HeroUIProvider className="size-full" navigate={navigate} useHref={useHref}> <HeroUIProvider className="size-full" navigate={navigate} useHref={useHref}>
@@ -31,7 +31,7 @@ function LibraryManagementLayout({getConfig, formik}: any) {
} }
async function removeLibrary(library: LibraryDto) { async function removeLibrary(library: LibraryDto) {
await LibraryEndpoint.removeLibrary(library.id); await LibraryEndpoint.deleteLibrary(library.id);
addToast({ addToast({
title: "Library removed", title: "Library removed",
description: `Library ${library.name} has been removed.`, description: `Library ${library.name} has been removed.`,
@@ -47,6 +47,12 @@ function LibraryManagementLayout({getConfig, formik}: any) {
<Section title="Scanning"/> <Section title="Scanning"/>
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/> <ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/> <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")}/> <ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
<Section title="Metadata"/> <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 {Button, Card, Chip, Tooltip} from "@heroui/react";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; 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 {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/covers/GameCover"; import {GameCover} from "Frontend/components/general/covers/GameCover";
import { import {MagnifyingGlass, SlidersHorizontal} from "@phosphor-icons/react";
Alien,
CastleTurret,
GameController,
Ghost,
Joystick,
Lego,
MagnifyingGlass,
Skull,
SlidersHorizontal,
SoccerBall,
Strategy,
Sword,
TreasureChest,
Trophy
} from "@phosphor-icons/react";
import ScanType from "Frontend/generated/de/grimsi/gameyfin/libraries/enums/ScanType"; import ScanType from "Frontend/generated/de/grimsi/gameyfin/libraries/enums/ScanType";
import {randomGamesFromLibrary} from "Frontend/util/utils";
import {useNavigate} from "react-router"; 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 { interface LibraryOverviewCardProps {
library: LibraryDto; library: LibraryDto;
@@ -31,75 +18,59 @@ interface LibraryOverviewCardProps {
export function LibraryOverviewCard({library}: LibraryOverviewCardProps) { export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
const MAX_COVER_COUNT = 5; const MAX_COVER_COUNT = 5;
const navigate = useNavigate(); const navigate = useNavigate();
const [randomGames, setRandomGames] = useState<GameDto[]>([]); const state = useSnapshot(gameState);
const randomGames = getRandomGames();
useEffect(() => { function getRandomGames() {
randomGamesFromLibrary(library, MAX_COVER_COUNT).then((games) => { const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
setRandomGames(games); if (!games) return [];
}) return games.slice(0, MAX_COVER_COUNT);
}, []); }
async function triggerScan() { async function triggerScan() {
await LibraryEndpoint.triggerScan(ScanType.QUICK, [library]); await LibraryEndpoint.triggerScan(ScanType.QUICK, [library]);
} }
return ( return (
<> <Card className="flex flex-col justify-between w-[353px]">
<Card className="flex flex-col justify-between w-[353px]"> <div className="flex flex-1 justify-center items-center">
<div className="flex flex-1 justify-center items-center"> <div className="flex flex-1 opacity-10 min-h-[100px]">
<div className="flex flex-1 opacity-10 min-h-[100px]"> <IconBackgroundPattern/>
<div className="absolute w-full h-full opacity-50"> {randomGames.length > 0 &&
<GameController size={32} <div className="absolute flex flex-row">
className="absolute fill-primary top-[10%] left-[10%] rotate-[350deg]"/> {randomGames.map((game) => (
<SoccerBall size={34} <GameCover game={game} size={100} radius="none" key={game.coverId}/>
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> </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> </div>
{library.stats && <p className="absolute text-2xl font-bold">{library.name}</p>
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
<p>Games</p> <div className="absolute right-0 top-0 flex flex-row">
<p>Downloads</p> <Tooltip content="Scan library" placement="bottom" color="foreground">
<p>Platforms</p> <Button isIconOnly variant="light" onPress={triggerScan}>
<p className="font-bold">{library.stats.gamesCount}</p> <MagnifyingGlass/>
<p className="font-bold">{library.stats.downloadedGamesCount}</p> </Button>
<Chip size="sm">PC</Chip> </Tooltip>
</div> <Tooltip content="Configuration" placement="bottom" color="foreground">
} <Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
</Card> <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 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 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 { interface LibraryHeaderProps {
library: LibraryDto; library: LibraryDto;
@@ -9,39 +12,39 @@ interface LibraryHeaderProps {
} }
export default function LibraryHeader({library, className}: LibraryHeaderProps) { export default function LibraryHeader({library, className}: LibraryHeaderProps) {
const [randomGames, setRandomGames] = useState<GameDto[]>([]); const MAX_COVER_COUNT = 5;
const maxCoverCount = 5; const state = useSnapshot(gameState);
const randomGames = getRandomGames();
useEffect(() => { function getRandomGames() {
randomGamesFromLibrary(library, maxCoverCount).then((games) => { const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
setRandomGames(games); if (!games) return [];
}); return games.slice(0, MAX_COVER_COUNT);
}, [library, maxCoverCount]); }
return ( 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"> <div className="flex flex-row items-center w-full h-full brightness-50">
{randomGames {randomGames.map((game, idx) => (
.map((game, idx) => ( <div
<div key={idx}
key={idx} className="flex-none overflow-hidden -ml-[10%]"
className="flex-none overflow-hidden -ml-[10%]" style={{
style={{ width: `calc(100% / ${MAX_COVER_COUNT - 2})`,
width: `calc(100% / ${maxCoverCount - 2})`, clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)', }}
}} >
> <img
<img src={`/images/screenshot/${game.imageIds![0]}`}
src={`/images/screenshot/${game.imageIds![0]}`} alt={`Image ${idx}`}
alt={`Image ${idx}`} />
/> </div>
</div> ))}
))
.slice(0, maxCoverCount)}
</div> </div>
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-white text-3xl font-bold">{library.name}</h2> <h2 className="text-white text-3xl font-bold">{library.name}</h2>
</div> </div>
</div> </Card>
); );
} }
@@ -33,7 +33,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
async function handleDelete(): Promise<void> { async function handleDelete(): Promise<void> {
try { try {
await LibraryEndpoint.removeLibrary(library.id); await LibraryEndpoint.deleteLibrary(library.id);
addToast({ addToast({
title: "Library deleted", title: "Library deleted",
@@ -1,22 +1,17 @@
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; 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 GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {Button, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@heroui/react"; import {Button, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@heroui/react";
import {CheckCircle, Pencil, Trash} from "@phosphor-icons/react"; import {CheckCircle, Pencil, Trash} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
interface LibraryManagementGamesProps { interface LibraryManagementGamesProps {
library: LibraryDto; library: LibraryDto;
} }
export default function LibraryManagementGames({library}: LibraryManagementGamesProps) { export default function LibraryManagementGames({library}: LibraryManagementGamesProps) {
const [games, setGames] = useState<GameDto[]>([]); const state = useSnapshot(gameState);
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : undefined;
useEffect(() => {
LibraryEndpoint.getGamesInLibrary(library.id).then((games) => {
setGames(games);
})
}, []);
return <div className="flex flex-col gap-4"> return <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage games in library</h1> <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 = { type LibraryState = {
subscription?: Subscription<LibraryEvent>; subscription?: Subscription<LibraryEvent>;
isLoaded: boolean; isLoaded: boolean;
state: Record<string, LibraryDto>; state: Record<number, LibraryDto>;
libraries: LibraryDto[]; libraries: LibraryDto[];
sorted: LibraryDto[]; sorted: LibraryDto[];
}; };
-37
View File
@@ -1,23 +1,5 @@
import {getCsrfToken} from "Frontend/util/auth"; import {getCsrfToken} from "Frontend/util/auth";
import moment from 'moment-timezone'; 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) { export function roleToRoleName(role: string) {
role = role.replace("ROLE_", "").toLowerCase(); 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]; 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. * 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. * 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 * @param current
*/ */
export function deepDiff<T extends object>(initial: T, current: T): Partial<T> { export function deepDiff<T extends object>(initial: T, current: T): Partial<T> {
const diff: Partial<T> = {};
function compareObjects(obj1: any, obj2: any): any { function compareObjects(obj1: any, obj2: any): any {
if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) { if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) {
if (obj1 !== obj2) { if (obj1 !== obj2) {
+24 -18
View File
@@ -1,18 +1,22 @@
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; import {DownloadProviderEndpoint} from "Frontend/generated/endpoints";
import {DownloadProviderEndpoint, GameEndpoint} from "Frontend/generated/endpoints"; import {useNavigate, useParams} from "react-router";
import {useParams} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover"; import {GameCover} from "Frontend/components/general/covers/GameCover";
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton"; import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel"; import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
import {Chip} from "@heroui/react"; import {Chip} from "@heroui/react";
import {humanFileSize, toTitleCase} from "Frontend/util/utils"; import {humanFileSize, toTitleCase} from "Frontend/util/utils";
import {DownloadEndpoint} from "Frontend/endpoints/endpoints"; 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() { export default function GameView() {
const {gameId} = useParams(); 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>>({}); const [downloadOptions, setDownloadOptions] = useState<Record<string, ComboButtonOption>>({});
useEffect(() => { useEffect(() => {
@@ -32,15 +36,17 @@ export default function GameView() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (gameId) { initializeGameState().then((state) => {
GameEndpoint.getGame(parseInt(gameId)).then((game) => setGame(game)); if (!gameId || !state.state[parseInt(gameId)]) {
} navigate("/");
}
});
}, [gameId]); }, [gameId]);
return (game && ( return game && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="overflow-hidden relative rounded-t-lg"> <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" <img className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
alt="Game screenshot" alt="Game screenshot"
src={`/images/screenshot/${game.imageIds[0]}`} src={`/images/screenshot/${game.imageIds[0]}`}
@@ -79,14 +85,14 @@ export default function GameView() {
<table className="text-left w-full table-auto"> <table className="text-left w-full table-auto">
<tbody> <tbody>
{Object.entries({ {Object.entries({
"Developed by": game.developers?.sort().join(" / ") || "unknown", "Developed by": game.developers ? [...game.developers].sort().join(" / ") : "unknown",
"Published by": game.publishers?.sort().join(" / ") || "unknown", "Published by": game.publishers ? [...game.publishers].sort().join(" / ") : "unknown",
"Genres": game.genres?.sort().map(p => <Chip "Genres": game.genres ? [...game.genres].sort().map(p =>
radius="sm">{toTitleCase(p)}</Chip>), <Chip radius="sm" key={p}>{toTitleCase(p)}</Chip>) : undefined,
"Themes": game.themes?.sort().map(p => <Chip "Themes": game.themes ? [...game.themes].sort().map(p =>
radius="sm">{toTitleCase(p)}</Chip>), <Chip radius="sm" key={p}>{toTitleCase(p)}</Chip>) : undefined,
"Features": game.features?.sort().map(p => <Chip "Features": game.features ? [...game.features].sort().map(p =>
radius="sm">{toTitleCase(p)}</Chip>), <Chip radius="sm" key={p}>{toTitleCase(p)}</Chip>) : undefined,
}).map(([key, value]) => ( }).map(([key, value]) => (
<tr key={key}> <tr key={key}>
<td className="text-foreground/60 w-0 min-w-32">{key}</td> <td className="text-foreground/60 w-0 min-w-32">{key}</td>
@@ -108,5 +114,5 @@ export default function GameView() {
</div> </div>
</div> </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 GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {randomGamesFromLibrary} from "Frontend/util/utils";
import {CoverRow} from "Frontend/components/general/CoverRow"; import {CoverRow} from "Frontend/components/general/CoverRow";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
import {gameState} from "Frontend/state/GameState";
export default function HomeView() { export default function HomeView() {
const [recentlyAddedGames, setRecentlyAddedGames] = useState<GameDto[]>([]); const librariesState = useSnapshot(libraryState);
const [libraryIdToGames, setLibraryIdToGames] = useState<Map<number, GameDto[]>>(new Map()); const gamesState = useSnapshot(gameState);
const state = useSnapshot(libraryState); const recentlyAddedGames = gamesState.sortedByMostRecentlyAdded as GameDto[];
const gamesByLibrary = gamesState.gamesByLibraryId as Record<number, GameDto[]>;
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);
});
}, []);
return ( return (
<div className="w-full"> <div className="w-full">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<CoverRow title="Recently added" games={recentlyAddedGames} <CoverRow title="Recently added" games={recentlyAddedGames}
onPressShowMore={() => alert("show more of 'Recently added'")}/> onPressShowMore={() => alert("show more of 'Recently added'")}/>
{state.libraries.map((library) => ( {librariesState.libraries.map((library) => (
<CoverRow key={library.id} title={library.name} <CoverRow key={library.id} title={library.name}
games={libraryIdToGames.get(library.id) || []} games={gamesByLibrary[library.id] || []}
onPressShowMore={() => alert(`show more of library '${library.name}'`)} onPressShowMore={() => alert(`show more of library '${library.name}'`)}
/> />
))} ))}
@@ -16,13 +16,13 @@ export default function LibraryManagementView() {
useEffect(() => { useEffect(() => {
initializeLibraryState().then((state) => { initializeLibraryState().then((state) => {
if (!libraryId || !state.state[libraryId]) { if (!libraryId || !state.state[parseInt(libraryId)]) {
navigate("/administration/libraries"); 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"> <div className="flex flex-row gap-4 items-center">
<Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}> <Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}>
<ArrowLeft/> <ArrowLeft/>
@@ -36,6 +36,13 @@ sealed class ConfigProperties<T : Serializable>(
false 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>>( data object GameFileExtensions : ConfigProperties<Array<String>>(
Array<String>::class, Array<String>::class,
"library.scan.game-file-extensions", "library.scan.game-file-extensions",
@@ -20,7 +20,7 @@ class DownloadEndpoint(
@PathVariable gameId: Long, @PathVariable gameId: Long,
@RequestParam provider: String @RequestParam provider: String
): ResponseEntity<StreamingResponseBody> { ): ResponseEntity<StreamingResponseBody> {
val game = gameService.getGame(gameId) val game = gameService.getById(gameId)
val download = downloadService.getDownload(game.path, provider) val download = downloadService.getDownload(game.path, provider)
return when (download) { return when (download) {
@@ -3,29 +3,27 @@ package de.grimsi.gameyfin.games
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.games.dto.GameDto 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.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import reactor.core.publisher.Flux
@Endpoint @Endpoint
@PermitAll @PermitAll
class GameEndpoint( class GameEndpoint(
private val gameService: GameService private val gameService: GameService
) { ) {
fun subscribe(): Flux<GameEvent> {
fun getGame(id: Long): GameDto { return gameService.subscribe()
return gameService.getGame(id)
} }
fun getMostRecentlyAddedGames(n: Int?): List<GameDto> { fun getAll(): List<GameDto> = gameService.getAll()
return gameService.getMostRecentlyAdded(n ?: 10)
}
fun getMostRecentlyUpdatedGames(n: Int?): List<GameDto> {
return gameService.getMostRecentlyUpdated(n ?: 10)
}
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun removeGames() { fun updateGame(game: GameUpdateDto) = gameService.update(game)
return gameService.deleteAll()
} @RolesAllowed(Role.Names.ADMIN)
fun deleteGame(gameId: Long) = gameService.delete(gameId)
} }
@@ -1,49 +1,66 @@
package de.grimsi.gameyfin.games 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.alphaNumeric
import de.grimsi.gameyfin.core.filterValuesNotNull import de.grimsi.gameyfin.core.filterValuesNotNull
import de.grimsi.gameyfin.core.plugins.PluginService import de.grimsi.gameyfin.core.plugins.PluginService
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.core.replaceRomanNumerals import de.grimsi.gameyfin.core.replaceRomanNumerals
import de.grimsi.gameyfin.games.dto.GameDto 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.GameMetadataDto
import de.grimsi.gameyfin.games.dto.GameUpdateDto
import de.grimsi.gameyfin.games.entities.* import de.grimsi.gameyfin.games.entities.*
import de.grimsi.gameyfin.games.repositories.GameRepository import de.grimsi.gameyfin.games.repositories.GameRepository
import de.grimsi.gameyfin.libraries.Library import de.grimsi.gameyfin.libraries.Library
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.persistence.EntityManager
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import me.xdrop.fuzzywuzzy.FuzzySearch import me.xdrop.fuzzywuzzy.FuzzySearch
import org.apache.commons.io.FilenameUtils import org.apache.commons.io.FilenameUtils
import org.pf4j.PluginManager import org.pf4j.PluginManager
import org.springframework.data.domain.Limit
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import java.nio.file.Path import java.nio.file.Path
@Service @Service
class GameService( class GameService(
private val pluginManager: PluginManager, private val pluginManager: PluginManager,
private val pluginService: PluginService, private val pluginService: PluginService,
private val config: ConfigService,
private val companyService: CompanyService,
private val gameRepository: GameRepository, private val gameRepository: GameRepository,
private val companyService: CompanyService private val entityManager: EntityManager
) { ) {
companion object { companion object {
const val TITLE_MATCH_MIN_RATIO = 90
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
} }
private val metadataPlugins: List<GameMetadataProvider> private val metadataPlugins: List<GameMetadataProvider>
get() = pluginManager.getExtensions(GameMetadataProvider::class.java) get() = pluginManager.getExtensions(GameMetadataProvider::class.java)
fun createOrUpdate(game: Game): Game { private val gameEvents = Sinks.many().multicast().onBackpressureBuffer<GameEvent>(1024, false)
gameRepository.findByPath(game.path)?.let { game.id = it.id }
return gameRepository.save(game) 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 @Transactional
@@ -56,12 +73,39 @@ class GameService(
game 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 { fun update(gameUpdateDto: GameUpdateDto) {
return gameRepository.findByIdOrNull(id)?.toDto() val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
?: throw IllegalArgumentException("Game with id $id not found") ?: 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? { fun matchFromFile(path: Path, library: Library): Game? {
@@ -91,30 +135,7 @@ class GameService(
return gameRepository.findAllByPathIn(paths) return gameRepository.findAllByPathIn(paths)
} }
fun getAllGames(): List<GameDto> { fun getById(id: Long): Game {
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 {
return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found") return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found")
} }
@@ -295,7 +316,8 @@ class GameService(
return mergedGame 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 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 id = this.id ?: throw IllegalArgumentException("ID is null")
val createdAt = this.createdAt ?: throw IllegalArgumentException("this creation timestamp is null") val createdAt = this.createdAt ?: throw IllegalArgumentException("creation timestamp is null")
val updatedAt = this.updatedAt ?: throw IllegalArgumentException("this update timestamp is null") val updatedAt = this.updatedAt ?: throw IllegalArgumentException("update timestamp is null")
val thisLibraryId = this.library.id ?: throw IllegalArgumentException("this library ID is null") val libraryId = this.library.id ?: throw IllegalArgumentException("library ID is null")
val thisTitle = this.title ?: throw IllegalArgumentException("this title is null") val title = this.title ?: throw IllegalArgumentException("title is null")
return GameDto( return GameDto(
id = thisId, id = id,
createdAt = createdAt, createdAt = createdAt,
updatedAt = updatedAt, updatedAt = updatedAt,
libraryId = thisLibraryId, libraryId = libraryId,
title = thisTitle, title = title,
coverId = this.coverImage?.id, coverId = this.coverImage?.id,
comment = this.comment, comment = this.comment,
summary = this.summary, 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 @Lob
@Column(columnDefinition = "CLOB") @Column(columnDefinition = "CLOB")
val comment: String? = null, var comment: String? = null,
@Lob @Lob
@Column(columnDefinition = "CLOB") @Column(columnDefinition = "CLOB")
@@ -21,8 +21,6 @@ class LibraryEndpoint(
fun getAll() = libraryService.getAll() fun getAll() = libraryService.getAll()
fun getGamesInLibrary(libraryId: Long) = libraryService.getGamesInLibrary(libraryId)
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection<LibraryDto>?) = fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection<LibraryDto>?) =
libraryService.triggerScan(scanType, libraries) libraryService.triggerScan(scanType, libraries)
@@ -34,5 +32,5 @@ class LibraryEndpoint(
fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library) fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library)
@RolesAllowed(Role.Names.ADMIN) @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.core.filesystem.FilesystemService
import de.grimsi.gameyfin.games.GameService 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.entities.Game
import de.grimsi.gameyfin.games.toDto
import de.grimsi.gameyfin.libraries.dto.* import de.grimsi.gameyfin.libraries.dto.*
import de.grimsi.gameyfin.libraries.enums.ScanType import de.grimsi.gameyfin.libraries.enums.ScanType
import de.grimsi.gameyfin.media.ImageService import de.grimsi.gameyfin.media.ImageService
@@ -35,14 +33,22 @@ class LibraryService(
private val libraryEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryEvent>(1024, false) private val libraryEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryEvent>(1024, false)
fun subscribe(): Flux<LibraryEvent> { fun subscribe(): Flux<LibraryEvent> {
log.debug { "New subscription for libraryUpdates" } log.debug { "New subscription for libraryEvents" }
return libraryEvents.asFlux() return libraryEvents.asFlux()
.doOnSubscribe { log.debug { "Subscriber added to libraryUpdates [${libraryEvents.currentSubscriberCount()}]" } } .doOnSubscribe { log.debug { "Subscriber added to libraryEvents [${libraryEvents.currentSubscriberCount()}]" } }
.doFinally { .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. * 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. * 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. * @return The updated LibraryDto.
* @throws IllegalArgumentException if the library ID is null or the library is not found. * @throws IllegalArgumentException if the library ID is null or the library is not found.
*/ */
fun update(libraryDto: LibraryUpdateDto) { fun update(libraryUpdateDto: LibraryUpdateDto) {
val existingLibrary = libraryRepository.findByIdOrNull(libraryDto.id) val existingLibrary = libraryRepository.findByIdOrNull(libraryUpdateDto.id)
?: throw IllegalArgumentException("Library with ID $libraryDto.id not found") ?: throw IllegalArgumentException("Library with ID $libraryUpdateDto.id not found")
// Update only non-null fields // Update only non-null fields
libraryDto.name?.let { existingLibrary.name = it } libraryUpdateDto.name?.let { existingLibrary.name = it }
libraryDto.directories?.let { libraryUpdateDto.directories?.let {
existingLibrary.directories = it existingLibrary.directories = it
.map { d -> DirectoryMapping(internalPath = d.internalPath, externalPath = d.externalPath) } .map { d -> DirectoryMapping(internalPath = d.internalPath, externalPath = d.externalPath) }
.toMutableList() .toMutableList()
@@ -79,77 +85,19 @@ class LibraryService(
libraryEvents.tryEmitNext(LibraryEvent.Updated(updatedLibraryDto)) 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. * Deletes a library from the repository.
* *
* @param libraryId: ID of the library to delete. * @param libraryId: ID of the library to delete.
*/ */
fun deleteLibrary(libraryId: Long) { fun delete(libraryId: Long) {
libraryRepository.deleteById(libraryId) val gameIds = libraryRepository.findByIdOrNull(libraryId)?.games?.mapNotNull { it.id }
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)
?: throw IllegalArgumentException("Library with ID $libraryId not found") ?: throw IllegalArgumentException("Library with ID $libraryId not found")
val games = library.games.map { it.toDto() } libraryRepository.deleteById(libraryId)
return games libraryEvents.tryEmitNext(LibraryEvent.Deleted(libraryId))
} gameIds.forEach { gameService.emitDeletionEvent(it) }
/**
* 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
} }
/** /**
@@ -325,10 +273,24 @@ class LibraryService(
id = libraryId, id = libraryId,
name = library.name, name = library.name,
directories = library.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) }, directories = library.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
games = library.games.mapNotNull { it.id },
stats = statsDto 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. * Converts a LibraryDto to a Library entity.
* *
@@ -4,5 +4,6 @@ data class LibraryDto(
val id: Long, val id: Long,
val name: String, val name: String,
val directories: List<DirectoryMappingDto>, val directories: List<DirectoryMappingDto>,
val games: List<Long>?,
val stats: LibraryStatsDto? val stats: LibraryStatsDto?
) )