Implement library management

This commit is contained in:
grimsi
2025-05-20 20:52:43 +02:00
parent d5eb4b9a73
commit c770ed182e
16 changed files with 381 additions and 142 deletions
@@ -0,0 +1,92 @@
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {Check} from "@phosphor-icons/react";
import {addToast, Button} from "@heroui/react";
import React from "react";
import {Form, Formik} from "formik";
import {deepDiff} from "Frontend/util/utils";
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import Input from "Frontend/components/general/input/Input";
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
import Section from "Frontend/components/general/Section";
import {useNavigate} from "react-router";
import * as Yup from "yup";
interface LibraryManagementDetailsProps {
library: LibraryDto;
}
export default function LibraryManagementDetails({library}: LibraryManagementDetailsProps) {
const navigate = useNavigate();
const [librarySaved, setLibrarySaved] = React.useState(false);
async function handleSubmit(values: LibraryDto): Promise<void> {
const changed = deepDiff(library, values) as LibraryUpdateDto;
if (Object.keys(changed).length === 0) return;
changed.id = library.id;
await LibraryEndpoint.updateLibrary(changed);
setLibrarySaved(true);
setTimeout(() => setLibrarySaved(false), 2000);
}
async function handleDelete(): Promise<void> {
try {
await LibraryEndpoint.removeLibrary(library.id);
addToast({
title: "Library deleted",
description: `Library ${library.name} deleted!`,
color: "success"
});
navigate("/administration/libraries");
} catch (e) {
addToast({
title: "Error deleting library",
description: `Library ${library.name} could not be deleted!`,
color: "warning"
});
}
}
return <Formik
initialValues={library}
onSubmit={handleSubmit}
enableReinitialize={true}
validationSchema={Yup.object({
name: Yup.string()
.required("Library name is required")
.max(255, "Library name must be 255 characters or less"),
directories: Yup.array()
.of(Yup.object())
.min(1, "At least one directory is required")
})}
>
{(formik) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-4">
<h1 className="text-2xl font-bold">Edit library details</h1>
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || librarySaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : librarySaved ? <Check/> : "Save"}
</Button>
</div>
<Input label="Library name" name="name"/>
<DirectoryMappingInput name="directories"/>
<Section title="Danger zone"/>
<Button color="danger" onPress={handleDelete}>
Delete library
</Button>
</Form>
)}
</Formik>;
}
@@ -3,7 +3,7 @@ import {Divider} from "@heroui/react";
export default function Section({title}: { title: string }) { export default function Section({title}: { title: string }) {
return ( return (
<> <>
<h2 className={"text-xl font-bold mt-8 mb-1"}>{title}</h2> <h2 className="text-xl font-bold mt-8 mb-1">{title}</h2>
<Divider className="mb-4"/> <Divider className="mb-4"/>
</> </>
); );
@@ -1,4 +1,4 @@
import {Button, Card, Chip, Tooltip, useDisclosure} 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, {useEffect, useState} from "react";
@@ -20,10 +20,10 @@ import {
TreasureChest, TreasureChest,
Trophy Trophy
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import LibraryDetailsModal from "Frontend/components/general/modals/LibraryDetailsModal";
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto"; import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
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 {randomGamesFromLibrary} from "Frontend/util/utils";
import {useNavigate} from "react-router";
export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: { export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
library: LibraryDto, library: LibraryDto,
@@ -31,9 +31,8 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
removeLibrary: (library: LibraryDto) => Promise<void> removeLibrary: (library: LibraryDto) => Promise<void>
}) { }) {
const MAX_COVER_COUNT = 5; const MAX_COVER_COUNT = 5;
const navigate = useNavigate();
const [randomGames, setRandomGames] = useState<GameDto[]>([]); const [randomGames, setRandomGames] = useState<GameDto[]>([]);
const libraryDetailsModal = useDisclosure();
useEffect(() => { useEffect(() => {
randomGamesFromLibrary(library, MAX_COVER_COUNT).then((games) => { randomGamesFromLibrary(library, MAX_COVER_COUNT).then((games) => {
@@ -85,7 +84,7 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground"> <Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={libraryDetailsModal.onOpen}> <Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
<SlidersHorizontal/> <SlidersHorizontal/>
</Button> </Button>
</Tooltip> </Tooltip>
@@ -103,13 +102,6 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
</div> </div>
} }
</Card> </Card>
<LibraryDetailsModal
library={library}
isOpen={libraryDetailsModal.isOpen}
onOpenChange={libraryDetailsModal.onOpenChange}
updateLibrary={updateLibrary}
removeLibrary={removeLibrary}
/>
</> </>
); );
} }
@@ -15,7 +15,7 @@ export function GameCover({game, size = 300, radius = "sm", hover = false}: Game
<div className={`${hover ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}> <div className={`${hover ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
<Image <Image
alt={game.title} alt={game.title}
className="z-0 w-full h-full object-cover aspect-[12/17]" className="z-0 size-full object-cover aspect-[12/17]"
src={`images/cover/${game.coverId}`} src={`images/cover/${game.coverId}`}
radius={radius} radius={radius}
height={size} height={size}
@@ -0,0 +1,47 @@
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import React, {useEffect, useState} from "react";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {randomGamesFromLibrary} from "Frontend/util/utils";
interface LibraryHeaderProps {
library: LibraryDto;
maxCoverCount?: number;
className?: string;
}
export default function LibraryHeader({library, maxCoverCount = 5, className}: LibraryHeaderProps) {
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
useEffect(() => {
randomGamesFromLibrary(library, maxCoverCount).then((games) => {
setRandomGames(games);
});
}, [library, maxCoverCount]);
return (
<div className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
<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)}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-white text-3xl font-bold">{library.name}</h2>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import React from "react";
import {Button, Code, useDisclosure} from "@heroui/react";
import {ArrowRight, Minus, Plus, XCircle} from "@phosphor-icons/react";
import PathPickerModal from "Frontend/components/general/modals/PathPickerModal";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import DirectoryMappingDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/DirectoryMappingDto";
import {useField} from "formik";
interface DirectoryMappingInputProps {
name: string;
}
export default function DirectoryMappingInput({name}: DirectoryMappingInputProps) {
const pathPickerModal = useDisclosure();
const [field, meta, helpers] = useField<DirectoryMappingDto[]>({name});
function addDirectoryMapping(directory: DirectoryMappingDto) {
helpers.setValue([...(field.value || []), directory]);
}
function removeDirectoryMapping(directory: DirectoryMappingDto) {
helpers.setValue((field.value || []).filter((d) => d !== directory));
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row justify-between items-center">
<p className="font-bold">Directories</p>
<Button isIconOnly variant="light" size="sm" color="default"
onPress={pathPickerModal.onOpen}>
<Plus/>
</Button>
</div>
{(field.value || []).map((directory) => (
<Code
className="w-full flex items-center gap-2 overflow-hidden px-2 py-1"
key={directory.internalPath}>
<input
type="text"
value={directory.internalPath}
readOnly
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
/>
{directory.externalPath && (
<>
<div className="flex-shrink-0 flex items-center justify-center mx-2">
<ArrowRight size={20}/>
</div>
<input
type="text"
value={directory.externalPath}
readOnly
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
/>
</>
)}
<Button
isIconOnly
variant="light"
size="sm"
color="default"
onPress={() => removeDirectoryMapping(directory)}
className="ml-2"
>
<Minus/>
</Button>
</Code>
))}
<div className="min-h-6 text-danger">
{meta.touched && meta.error && (
<SmallInfoField icon={XCircle} message={meta.error}/>
)}
</div>
<PathPickerModal returnSelectedPath={addDirectoryMapping}
isOpen={pathPickerModal.isOpen}
onOpenChange={pathPickerModal.onOpenChange}/>
</div>
);
}
@@ -1,24 +1,11 @@
import React from "react"; import React from "react";
import { import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
addToast,
Button,
Code,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
useDisclosure
} from "@heroui/react";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
import Input from "Frontend/components/general/input/Input"; import Input from "Frontend/components/general/input/Input";
import PathPickerModal from "Frontend/components/general/modals/PathPickerModal";
import {ArrowRight, Minus, Plus, XCircle} from "@phosphor-icons/react";
import * as Yup from "yup"; import * as Yup from "yup";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
import DirectoryMappingDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/DirectoryMappingDto";
interface LibraryCreationModalProps { interface LibraryCreationModalProps {
libraries: LibraryDto[]; libraries: LibraryDto[];
@@ -33,12 +20,9 @@ export default function LibraryCreationModal({
isOpen, isOpen,
onOpenChange onOpenChange
}: LibraryCreationModalProps) { }: LibraryCreationModalProps) {
const pathPickerModal = useDisclosure();
async function createLibrary(library: LibraryDto) { async function createLibrary(library: LibraryDto) {
try { try {
const newLibrary = await LibraryEndpoint.createLibrary(library as LibraryDto); const newLibrary = await LibraryEndpoint.createLibrary(library as LibraryDto);
if (newLibrary === undefined) return;
setLibraries([...libraries, newLibrary]); setLibraries([...libraries, newLibrary]);
} catch (e) { } catch (e) {
addToast({ addToast({
@@ -76,98 +60,35 @@ export default function LibraryCreationModal({
onClose(); onClose();
}} }}
> >
{(formik) => { {(formik) =>
function addDirectoryMapping(directory: DirectoryMappingDto) { <Form>
formik.setFieldValue("directories", [...formik.values.directories, directory]); <ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
} <ModalBody>
<div className="flex flex-col gap-2">
function removeDirectoryMapping(directory: DirectoryMappingDto) { <Input
formik.setFieldValue("directories", formik.values.directories.filter((d: DirectoryMappingDto) => d.internalPath !== directory.internalPath)); name="name"
} label="Library Name"
placeholder="Enter library name"
return ( value={formik.values.name}
<Form> required
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader> />
<ModalBody> <DirectoryMappingInput name="directories"/>
<div className="flex flex-col gap-2"> </div>
<Input </ModalBody>
name="name" <ModalFooter>
label="Library Name" <Button variant="light" onPress={onClose}>
placeholder="Enter library name" Cancel
value={formik.values.name} </Button>
required <Button color="primary"
/> isLoading={formik.isSubmitting}
<div className="flex flex-row justify-between items-center"> isDisabled={formik.isSubmitting}
<p className="font-bold">Directories</p> type="submit"
<Button isIconOnly variant="light" size="sm" color="default" >
onPress={pathPickerModal.onOpen}> {formik.isSubmitting ? "" : "Add"}
<Plus/> </Button>
</Button> </ModalFooter>
</div> </Form>
{formik.values.directories.map((directory: DirectoryMappingDto) => ( }
<Code
className="w-full flex items-center gap-2 overflow-hidden px-2 py-1"
key={directory.internalPath}>
<input
type="text"
value={directory.internalPath}
readOnly
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
/>
{directory.externalPath && (
<>
<div
className="flex-shrink-0 flex items-center justify-center mx-2">
<ArrowRight size={20}/>
</div>
<input
type="text"
value={directory.externalPath}
readOnly
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
/>
</>
)}
<Button
isIconOnly
variant="light"
size="sm"
color="default"
onPress={() => removeDirectoryMapping(directory)}
className="ml-2"
>
<Minus/>
</Button>
</Code>
))}
<div className="min-h-6 text-danger">
{(() => {
const meta = formik.getFieldMeta("directories");
return meta.touched && meta.error && (
<SmallInfoField icon={XCircle} message={meta.error}/>
);
})()}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</ModalFooter>
<PathPickerModal returnSelectedPath={addDirectoryMapping}
isOpen={pathPickerModal.isOpen}
onOpenChange={pathPickerModal.onOpenChange}/>
</Form>
);
}}
</Formik> </Formik>
)} )}
</ModalContent> </ModalContent>
@@ -9,7 +9,7 @@ export type MenuItem = {
icon: ReactElement<Icon> icon: ReactElement<Icon>
} }
export default function withSideMenu(menuItems: MenuItem[]) { export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
return function PageWithSideMenu() { return function PageWithSideMenu() {
const [selectedItem, setSelectedItem] = useState<string>(initialSelected) const [selectedItem, setSelectedItem] = useState<string>(initialSelected)
@@ -24,24 +24,26 @@ export default function withSideMenu(menuItems: MenuItem[]) {
* If the key starts with "/" assume it's an absolute link, else assume it's relative * If the key starts with "/" assume it's an absolute link, else assume it's relative
*/ */
function link(l: string): string { function link(l: string): string {
if (l.startsWith("/")) return l; if (l.startsWith("/")) return baseUrl + l;
const p = window.location.pathname return baseUrl + "/" + l;
return p.substring(0, p.lastIndexOf("/") + 1) + l;
} }
/** /**
* Match the initially selected item by current URL path * Match the initially selected item by current URL path
*/ */
function initialSelected(): string { function initialSelected(): string {
const p = window.location.pathname const p = window.location.pathname;
return p.substring(p.lastIndexOf("/") + 1, p.length); const idx = p.indexOf(baseUrl);
if (idx === -1) return "";
const afterBase = p.substring(idx + baseUrl.length);
// Remove leading slash, then split and take the first segment
return afterBase.replace(/^\/+/, "").split("/")[0] || "";
} }
return ( return (
<div className="flex flex-row"> <div className="flex flex-row">
<div className="flex flex-col pr-8"> <div className="flex flex-col pr-8">
<Listbox className="min-w-60" <Listbox className="min-w-60" color="primary">
color="primary">
{menuItems.map((i) => ( {menuItems.map((i) => (
<ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)} <ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)}
onPress={() => setSelectedItem(i.url)} onPress={() => setSelectedItem(i.url)}
+5 -5
View File
@@ -52,11 +52,11 @@ export const routes = protectRoutes([
children: [ children: [
{ {
path: 'libraries', path: 'libraries',
element: <LibraryManagement/>, element: <LibraryManagement/>
children: [{ },
path: 'library/:libraryId', {
element: <LibraryManagementView/> path: 'libraries/library/:libraryId',
}] element: <LibraryManagementView/>
}, },
{path: 'users', element: <UserManagement/>}, {path: 'users', element: <UserManagement/>},
{path: 'sso', element: <SsoManagement/>}, {path: 'sso', element: <SsoManagement/>},
+50
View File
@@ -137,5 +137,55 @@ export async function randomGamesFromLibrary(library: LibraryDto, count?: number
return games return games
.sort((a: GameDto, b: GameDto) => a.id - b.id) .sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5) .sort(() => rand.next() - 0.5)
.filter(g => g.imageIds && g.imageIds.length > 0)
.slice(0, count ?? games.length); .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.
* @param initial
* @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) {
return obj2;
}
return undefined;
}
if (Array.isArray(obj1) && Array.isArray(obj2)) {
if (obj1.length !== obj2.length) {
return obj2;
} else {
const arrayDiff = obj1.map((item: any, index: number) => compareObjects(item, obj2[index]));
if (arrayDiff.some(item => item !== undefined)) {
return arrayDiff;
}
return undefined;
}
}
const keys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
const objDiff: any = {};
keys.forEach(key => {
const valueDiff = compareObjects(obj1[key], obj2[key]);
if (valueDiff !== undefined) {
objDiff[key] = valueDiff;
}
});
if (Object.keys(objDiff).length > 0) {
return objDiff;
}
return undefined;
}
const result = compareObjects(initial, current);
return result || {};
} }
@@ -39,5 +39,5 @@ const menuItems: MenuItem[] = [
} }
] ]
export const AdministrationView = withSideMenu(menuItems); export const AdministrationView = withSideMenu("/administration", menuItems);
export default AdministrationView; export default AdministrationView;
@@ -22,7 +22,7 @@ export default function GameView() {
label: provider.name, label: provider.name,
description: provider.shortDescription ?? provider.description, description: provider.shortDescription ?? provider.description,
action: () => { action: () => {
if (gameId) DownloadEndpoint.downloadGame(parseInt(gameId!), provider.key); if (gameId) DownloadEndpoint.downloadGame(parseInt(gameId), provider.key);
}, },
}; };
return acc; return acc;
@@ -1,7 +1,48 @@
import {useParams} from "react-router"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {useNavigate, useParams} from "react-router";
import React, {useEffect, useState} from "react";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import LibraryHeader from "Frontend/components/general/covers/LibraryHeader";
import {Button, Tab, Tabs} from "@heroui/react";
import {ArrowLeft} from "@phosphor-icons/react";
import LibraryManagementDetails from "Frontend/components/general/LibraryManagementDetails";
export default function LibraryManagementView() { export default function LibraryManagementView() {
const {libraryId} = useParams(); const {libraryId} = useParams();
const navigate = useNavigate();
const [library, setLibrary] = useState<LibraryDto>();
const [games, setGames] = useState<GameDto[]>([]);
return (<></>); useEffect(() => {
if (!libraryId) return;
LibraryEndpoint.getById(parseInt(libraryId)).then((library: LibraryDto) => {
setLibrary(library);
});
LibraryEndpoint.getGamesInLibrary(parseInt(libraryId)).then((games) => {
setGames(games);
});
}, []);
return library && <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/>
</Button>
<h1 className="text-2xl font-bold">Manage library</h1>
</div>
<LibraryHeader library={library} maxCoverCount={Math.min(games.length, 10)} className="h-32"/>
<Tabs color="primary" fullWidth>
<Tab title="Details">
<LibraryManagementDetails library={library}/>
</Tab>
<Tab title="Games">
<p>Games</p>
</Tab>
<Tab title="Unmatched paths">
<p>Unmatched paths</p>
</Tab>
</Tabs>
</div>;
} }
@@ -19,5 +19,5 @@ const menuItems = [
} }
] ]
export const ProfileView = withSideMenu(menuItems); export const ProfileView = withSideMenu("/settings", menuItems);
export default ProfileView; export default ProfileView;
@@ -18,6 +18,8 @@ class LibraryEndpoint(
return libraryService.getAllLibraries() return libraryService.getAllLibraries()
} }
fun getById(libraryId: Long): LibraryDto = libraryService.getById(libraryId)
fun getGamesInLibrary(libraryId: Long): Collection<GameDto> { fun getGamesInLibrary(libraryId: Long): Collection<GameDto> {
return libraryService.getGamesInLibrary(libraryId) return libraryService.getGamesInLibrary(libraryId)
} }
@@ -75,6 +75,19 @@ class LibraryService(
return entities.map { toDto(it) } 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.
* *