Implement different DTOs for users and admins (#644)

* Implement different DTOs for users and admins
* Fix performance by not creating unnecessary websocket connections
This commit is contained in:
Simon
2025-07-22 14:52:59 +02:00
committed by GitHub
parent 2e596bf7a3
commit 791ddf8ce2
39 changed files with 516 additions and 353 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080"> <configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080">
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>
+4
View File
@@ -40,6 +40,9 @@ export default function App() {
function ViewWithAuth() { function ViewWithAuth() {
const auth = useAuth(); const auth = useAuth();
useEffect(() => {
if (auth.state.initializing || auth.state.loading) return;
initializeLibraryState(); initializeLibraryState();
initializeGameState(); initializeGameState();
@@ -47,6 +50,7 @@ function ViewWithAuth() {
initializeScanState(); initializeScanState();
initializePluginState(); initializePluginState();
} }
}, [auth]);
return <> return <>
<IconContext.Provider value={{size: 20}}> <IconContext.Provider value={{size: 20}}>
@@ -32,8 +32,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
} }
function getConfig(key: string): ConfigEntryDto | undefined { function getConfig(key: string): ConfigEntryDto | undefined {
// @ts-ignore return state.state[key] as ConfigEntryDto | undefined;
return state.state[key];
} }
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> { function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
@@ -10,7 +10,7 @@ export default function SearchBar() {
const navigate = useNavigate(); const navigate = useNavigate();
const state = useSnapshot(gameState); const state = useSnapshot(gameState);
const games = state.recentlyUpdated as GameDto[]; const games = state.games as GameDto[];
return <Autocomplete return <Autocomplete
aria-label="Search for games" aria-label="Search for games"
@@ -1,5 +1,4 @@
import {Button, Card, Chip, Tooltip} from "@heroui/react"; import {Button, Card, Chip, Tooltip} from "@heroui/react";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React from "react"; import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
@@ -10,9 +9,10 @@ import {useNavigate} from "react-router";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern"; import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
interface LibraryOverviewCardProps { interface LibraryOverviewCardProps {
library: LibraryDto; library: LibraryAdminDto;
} }
export function LibraryOverviewCard({library}: LibraryOverviewCardProps) { export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
@@ -28,7 +28,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
} }
async function triggerScan(scanType: ScanType) { async function triggerScan(scanType: ScanType) {
await LibraryEndpoint.triggerScan(scanType, [library]); await LibraryEndpoint.triggerScan(scanType, [library.id]);
} }
return ( return (
@@ -25,6 +25,7 @@ import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpd
import {useMemo, useState} from "react"; import {useMemo, useState} from "react";
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal"; import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal"; import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import {GameAdminDto} from "Frontend/dtos/GameDtos";
interface LibraryManagementGamesProps { interface LibraryManagementGamesProps {
library: LibraryDto; library: LibraryDto;
@@ -34,12 +35,12 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
const rowsPerPage = 25; const rowsPerPage = 25;
const state = useSnapshot(gameState); const state = useSnapshot(gameState);
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : []; const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameAdminDto[] : [];
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all"); const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "title", direction: "ascending"}); const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "title", direction: "ascending"});
const [selectedGame, setSelectedGame] = useState<GameDto>(games[0]); const [selectedGame, setSelectedGame] = useState<GameAdminDto>(games[0]);
const editGameModal = useDisclosure(); const editGameModal = useDisclosure();
const matchGameModal = useDisclosure(); const matchGameModal = useDisclosure();
@@ -53,7 +54,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
}, [games, filter, searchTerm]); }, [games, filter, searchTerm]);
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
return filteredItems.slice().sort((a, b) => { return (filteredItems as GameAdminDto[]).slice().sort((a, b) => {
let cmp: number; let cmp: number;
switch (sortDescriptor.column) { switch (sortDescriptor.column) {
@@ -86,7 +87,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
function getFilteredGames() { function getFilteredGames() {
let filteredGames = games.filter((game) => let filteredGames = (games as GameAdminDto[]).filter((game) =>
game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) || game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.title.toLowerCase().includes(searchTerm.toLowerCase()) || game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) || game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) ||
@@ -102,7 +103,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
return filteredGames; return filteredGames;
} }
async function toggleMatchConfirmed(game: GameDto) { async function toggleMatchConfirmed(game: GameAdminDto) {
await GameEndpoint.updateGame( await GameEndpoint.updateGame(
{ {
id: game.id, id: game.id,
@@ -163,13 +164,13 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<TableColumn width={1}>Actions</TableColumn> <TableColumn width={1}>Actions</TableColumn>
</TableHeader> </TableHeader>
<TableBody emptyContent="Your filter did not match any games." items={pagedItems}> <TableBody emptyContent="Your filter did not match any games." items={pagedItems}>
{(item) => ( {(item: GameAdminDto) => (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell> <TableCell>
<Link href={`/game/${item.id}`} <Link href={`/game/${item.id}`}
color="foreground" color="foreground"
className="text-sm" className="text-sm"
underline="hover">{item.title} ({item.release !== undefined ? new Date(item.release).getFullYear() : "unknown"}) underline="hover">{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</Link> </Link>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -1,4 +1,3 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import { import {
Button, Button,
Input, Input,
@@ -19,9 +18,10 @@ import {useMemo, useState} from "react";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto"; import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import {fileNameFromPath, hashCode} from "Frontend/util/utils"; import {fileNameFromPath, hashCode} from "Frontend/util/utils";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal"; import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
interface LibraryManagementUnmatchedPathsProps { interface LibraryManagementUnmatchedPathsProps {
library: LibraryDto; library: LibraryAdminDto;
} }
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) { export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) {
@@ -6,6 +6,7 @@ import {LibraryEndpoint} from "Frontend/generated/endpoints";
import Input from "Frontend/components/general/input/Input"; import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup"; import * as Yup from "yup";
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput"; import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
interface LibraryCreationModalProps { interface LibraryCreationModalProps {
libraries: LibraryDto[]; libraries: LibraryDto[];
@@ -23,7 +24,7 @@ export default function LibraryCreationModal({
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true); const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
async function createLibrary(library: LibraryDto) { async function createLibrary(library: LibraryDto) {
await LibraryEndpoint.createLibrary(library as LibraryDto, scanAfterCreation); await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation);
addToast({ addToast({
title: "New library created", title: "New library created",
@@ -15,7 +15,6 @@ export default function PluginIcon({
blurred = false, blurred = false,
showTooltip = true showTooltip = true
}: PluginIconProps) { }: PluginIconProps) {
const icon = plugin.hasLogo const icon = plugin.hasLogo
? ?
<Image isBlurred={blurred} src={`/images/plugins/${plugin.id}/logo`} width={size} height={size} radius="none"/> <Image isBlurred={blurred} src={`/images/plugins/${plugin.id}/logo`} width={size} height={size} radius="none"/>
+26
View File
@@ -0,0 +1,26 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
export interface GameAdminDto extends GameDto {
metadata: GameMetadataAdminDto;
}
export interface GameMetadataAdminDto {
path?: string | null;
fileSize: number;
fields?: { [key: string]: GameFieldMetadataDto } | null;
originalIds?: { [key: string]: string } | null;
downloadCount: number;
matchConfirmed: boolean;
}
export interface GameFieldMetadataDto {
type: GameFieldMetadataType;
source: string;
updatedAt: string;
}
export enum GameFieldMetadataType {
PLUGIN = 'PLUGIN',
USER = 'USER',
UNKNOWN = 'UNKNOWN'
}
+2 -4
View File
@@ -1,9 +1,9 @@
import {Subscription} from "@vaadin/hilla-frontend"; import {Subscription} from "@vaadin/hilla-frontend";
import {proxy} from "valtio/index"; import {proxy} from "valtio/index";
import {GameEndpoint} from "Frontend/generated/endpoints"; import {GameEndpoint} from "Frontend/generated/endpoints";
import GameEvent from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryEvent";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import Rand from "rand-seed"; import Rand from "rand-seed";
import GameEvent from "Frontend/generated/org/gameyfin/app/games/dto/GameEvent";
type GameState = { type GameState = {
subscription?: Subscription<GameEvent[]>; subscription?: Subscription<GameEvent[]>;
@@ -116,7 +116,7 @@ export const gameState = proxy<GameState>({
/** Subscribe to and process state updates from backend **/ /** Subscribe to and process state updates from backend **/
export async function initializeGameState() { export async function initializeGameState() {
if (gameState.isLoaded) return gameState; if (gameState.isLoaded) return;
// Fetch initial library list // Fetch initial library list
const initialEntries = await GameEndpoint.getAll(); const initialEntries = await GameEndpoint.getAll();
@@ -140,6 +140,4 @@ export async function initializeGameState() {
} }
}) })
}); });
return gameState;
} }
+1 -3
View File
@@ -31,7 +31,7 @@ export const libraryState = proxy<LibraryState>({
/** Subscribe to and process state updates from backend **/ /** Subscribe to and process state updates from backend **/
export async function initializeLibraryState() { export async function initializeLibraryState() {
if (libraryState.isLoaded) return libraryState; if (libraryState.isLoaded) return;
// Fetch initial library list // Fetch initial library list
const initialEntries = await LibraryEndpoint.getAll(); const initialEntries = await LibraryEndpoint.getAll();
@@ -57,6 +57,4 @@ export async function initializeLibraryState() {
} }
}) })
}); });
return libraryState;
} }
+5 -7
View File
@@ -7,9 +7,8 @@ import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
import {Accordion, AccordionItem, addToast, Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react"; import {Accordion, AccordionItem, addToast, Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react";
import {humanFileSize, isAdmin, toTitleCase} from "Frontend/util/utils"; import {humanFileSize, isAdmin, 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 {gameState} from "Frontend/state/GameState";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {CheckCircle, Info, MagnifyingGlass, Pencil, Trash, TriangleDashed} from "@phosphor-icons/react"; import {CheckCircle, Info, MagnifyingGlass, Pencil, Trash, TriangleDashed} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal"; import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
@@ -17,6 +16,7 @@ import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMe
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto"; import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import {GameAdminDto} from "Frontend/dtos/GameDtos";
export default function GameView() { export default function GameView() {
const {gameId} = useParams(); const {gameId} = useParams();
@@ -28,7 +28,7 @@ export default function GameView() {
const matchGameModal = useDisclosure(); const matchGameModal = useDisclosure();
const state = useSnapshot(gameState); const state = useSnapshot(gameState);
const game = gameId ? state.state[parseInt(gameId)] as GameDto : undefined; const game = gameId ? state.state[parseInt(gameId)] as GameAdminDto : undefined;
const [downloadOptions, setDownloadOptions] = useState<Record<string, ComboButtonOption>>(); const [downloadOptions, setDownloadOptions] = useState<Record<string, ComboButtonOption>>();
@@ -49,13 +49,11 @@ export default function GameView() {
}, []); }, []);
useEffect(() => { useEffect(() => {
initializeGameState().then((state) => { if (state.isLoaded && (!gameId || !state.state[parseInt(gameId)])) {
if (!gameId || !state.state[parseInt(gameId)]) {
navigate("/", {replace: true}); navigate("/", {replace: true});
} }
document.title = game ? game.title : "Gameyfin"; document.title = game ? game.title : "Gameyfin";
}); }, [gameId, state]);
}, [gameId]);
async function toggleMatchConfirmed() { async function toggleMatchConfirmed() {
if (!game) return; if (!game) return;
@@ -6,8 +6,9 @@ import {ArrowLeft} from "@phosphor-icons/react";
import LibraryManagementDetails from "Frontend/components/general/library/LibraryManagementDetails"; import LibraryManagementDetails from "Frontend/components/general/library/LibraryManagementDetails";
import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames"; import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {initializeLibraryState, libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
import LibraryManagementUnmatchedPaths from "Frontend/components/general/library/LibraryManagementUnmatchedPaths"; import LibraryManagementUnmatchedPaths from "Frontend/components/general/library/LibraryManagementUnmatchedPaths";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
export default function LibraryManagementView() { export default function LibraryManagementView() {
@@ -17,12 +18,10 @@ export default function LibraryManagementView() {
const state = useSnapshot(libraryState); const state = useSnapshot(libraryState);
useEffect(() => { useEffect(() => {
initializeLibraryState().then((state) => { if (state.isLoaded && (!libraryId || !state.state[parseInt(libraryId)])) {
if (!libraryId || !state.state[parseInt(libraryId)]) {
navigate("/administration/libraries"); navigate("/administration/libraries");
} }
}); }, [state, libraryId]);
}, [libraryId]);
return libraryId && state.state[parseInt(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">
@@ -31,23 +30,18 @@ export default function LibraryManagementView() {
</Button> </Button>
<h1 className="text-2xl font-bold">Manage library</h1> <h1 className="text-2xl font-bold">Manage library</h1>
</div> </div>
{/* @ts-ignore */} <LibraryHeader library={state.state[parseInt(libraryId)] as LibraryAdminDto} className="h-32"/>
<LibraryHeader library={state.state[libraryId]} className="h-32"/>
{/* @ts-ignore */}
<Tabs color="primary" fullWidth <Tabs color="primary" fullWidth
selectedKey={hash.length > 0 ? hash : "#details"} selectedKey={hash.length > 0 ? hash : "#details"}
onSelectionChange={(newKey) => navigate(newKey.toString(), {replace: true})}> onSelectionChange={(newKey) => navigate(newKey.toString(), {replace: true})}>
<Tab key="#details" title="Details"> <Tab key="#details" title="Details">
{/* @ts-ignore */} <LibraryManagementDetails library={state.state[parseInt(libraryId)] as LibraryAdminDto}/>
<LibraryManagementDetails library={state.state[libraryId]}/>
</Tab> </Tab>
<Tab key="#games" title="Games"> <Tab key="#games" title="Games">
{/* @ts-ignore */} <LibraryManagementGames library={state.state[parseInt(libraryId)] as LibraryAdminDto}/>
<LibraryManagementGames library={state.state[libraryId]}/>
</Tab> </Tab>
<Tab key="#unmatched-paths" title="Unmatched paths"> <Tab key="#unmatched-paths" title="Unmatched paths">
{/* @ts-ignore */} <LibraryManagementUnmatchedPaths library={state.state[parseInt(libraryId)] as LibraryAdminDto}/>
<LibraryManagementUnmatchedPaths library={state.state[libraryId]}/>
</Tab> </Tab>
</Tabs> </Tabs>
</div>; </div>;
+4 -6
View File
@@ -1,5 +1,5 @@
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {initializeLibraryState, libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import React, {useEffect} from "react"; import React, {useEffect} from "react";
import {useNavigate, useParams} from "react-router"; import {useNavigate, useParams} from "react-router";
@@ -13,13 +13,11 @@ export default function LibraryView() {
const games = (libraryId ? useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!!)] || [] : []) as GameDto[]; const games = (libraryId ? useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!!)] || [] : []) as GameDto[];
useEffect(() => { useEffect(() => {
initializeLibraryState().then((state) => { if (libraries.isLoaded && (!libraryId || !libraries.state[parseInt(libraryId)])) {
if (!libraryId || !state.state[parseInt(libraryId)]) {
navigate("/", {replace: true}); navigate("/", {replace: true});
} }
document.title = state.state[parseInt(libraryId!!)]?.name || "Gameyfin"; document.title = libraries.state[parseInt(libraryId!!)]?.name || "Gameyfin";
}); }, [libraryId, libraries]);
}, [libraryId]);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -9,8 +9,7 @@ import org.gameyfin.app.config.dto.ConfigEntryDto
import org.gameyfin.app.config.dto.ConfigUpdateDto import org.gameyfin.app.config.dto.ConfigUpdateDto
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.core.annotations.DynamicPublicAccess import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.users.UserService import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.users.util.isAdmin
import org.springframework.scheduling.support.CronExpression import org.springframework.scheduling.support.CronExpression
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
@@ -18,7 +17,6 @@ import reactor.core.publisher.Flux
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
class ConfigEndpoint( class ConfigEndpoint(
private val configService: ConfigService, private val configService: ConfigService,
private val userService: UserService,
) { ) {
companion object { companion object {
val log = KotlinLogging.logger { } val log = KotlinLogging.logger { }
@@ -28,8 +26,7 @@ class ConfigEndpoint(
@PermitAll @PermitAll
fun subscribe(): Flux<List<ConfigUpdateDto>> { fun subscribe(): Flux<List<ConfigUpdateDto>> {
val user = userService.getCurrentUser() return if (isCurrentUserAdmin()) ConfigService.subscribe()
return if (user.isAdmin()) ConfigService.subscribe()
else Flux.empty() else Flux.empty()
} }
@@ -46,7 +46,7 @@ class ConfigService(
*/ */
fun <T : Serializable> get(configProperty: ConfigProperties<T>): T? { fun <T : Serializable> get(configProperty: ConfigProperties<T>): T? {
log.debug { "Getting config value '${configProperty.key}'" } log.trace { "Getting config value '${configProperty.key}'" }
val appConfig = appConfigRepository.findByIdOrNull(configProperty.key) val appConfig = appConfigRepository.findByIdOrNull(configProperty.key)
return if (appConfig != null) { return if (appConfig != null) {
@@ -65,7 +65,7 @@ class ConfigService(
*/ */
fun get(key: String): Serializable? { fun get(key: String): Serializable? {
log.debug { "Getting config value '$key'" } log.trace { "Getting config value '$key'" }
val configProperty = findConfigProperty(key) val configProperty = findConfigProperty(key)
@@ -4,15 +4,13 @@ import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.users.UserService import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.users.util.isAdmin
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
@Endpoint @Endpoint
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
class LogEndpoint( class LogEndpoint(
private val logService: LogService, private val logService: LogService,
private val userService: UserService,
) { ) {
fun reloadLogConfig() { fun reloadLogConfig() {
@@ -21,8 +19,7 @@ class LogEndpoint(
@PermitAll @PermitAll
fun getApplicationLogs(): Flux<String> { fun getApplicationLogs(): Flux<String> {
val user = userService.getCurrentUser() return if (isCurrentUserAdmin()) logService.streamLogs()
return if (user.isAdmin()) logService.streamLogs()
else Flux.empty() else Flux.empty()
} }
} }
@@ -5,8 +5,7 @@ import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.core.plugins.dto.PluginUpdateDto import org.gameyfin.app.core.plugins.dto.PluginUpdateDto
import org.gameyfin.app.users.UserService import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.users.util.isAdmin
import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
@@ -14,13 +13,11 @@ import reactor.core.publisher.Flux
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
class PluginEndpoint( class PluginEndpoint(
private val pluginService: PluginService, private val pluginService: PluginService,
private val userService: UserService,
) { ) {
@PermitAll @PermitAll
fun subscribe(): Flux<List<PluginUpdateDto>> { fun subscribe(): Flux<List<PluginUpdateDto>> {
val user = userService.getCurrentUser() return if (isCurrentUserAdmin()) PluginService.subscribe()
return if (user.isAdmin()) PluginService.subscribe()
else Flux.empty() else Flux.empty()
} }
@@ -0,0 +1,9 @@
package org.gameyfin.app.core.security
import org.gameyfin.app.core.Role
import org.springframework.security.core.context.SecurityContextHolder
fun isCurrentUserAdmin(): Boolean {
return SecurityContextHolder.getContext().authentication?.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN }
?: false
}
@@ -5,6 +5,7 @@ import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.core.annotations.DynamicPublicAccess import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.games.dto.* import org.gameyfin.app.games.dto.*
import org.gameyfin.app.libraries.LibraryCoreService import org.gameyfin.app.libraries.LibraryCoreService
import org.gameyfin.app.libraries.LibraryService import org.gameyfin.app.libraries.LibraryService
@@ -19,8 +20,12 @@ class GameEndpoint(
private val libraryService: LibraryService, private val libraryService: LibraryService,
private val libraryCoreService: LibraryCoreService private val libraryCoreService: LibraryCoreService
) { ) {
fun subscribe(): Flux<List<GameEvent>> { fun subscribe(): Flux<out List<GameEvent>> {
return GameService.subscribe() return if (isCurrentUserAdmin()) {
GameService.subscribeAdmin()
} else {
GameService.subscribeUser()
}
} }
fun getAll(): List<GameDto> = gameService.getAll() fun getAll(): List<GameDto> = gameService.getAll()
@@ -18,7 +18,7 @@ import org.gameyfin.app.core.plugins.management.PluginManagementEntry
import org.gameyfin.app.core.replaceRomanNumerals import org.gameyfin.app.core.replaceRomanNumerals
import org.gameyfin.app.games.dto.* import org.gameyfin.app.games.dto.*
import org.gameyfin.app.games.entities.* import org.gameyfin.app.games.entities.*
import org.gameyfin.app.games.entities.GameMetadata import org.gameyfin.app.games.extensions.toDtos
import org.gameyfin.app.games.repositories.GameRepository import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.libraries.Library import org.gameyfin.app.libraries.Library
import org.gameyfin.app.media.ImageService import org.gameyfin.app.media.ImageService
@@ -57,22 +57,39 @@ class GameService(
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
/* Websockets */ /* Websockets */
private val gameEvents = Sinks.many().multicast().onBackpressureBuffer<GameEvent>(1024, false) private val gameUserEvents = Sinks.many().multicast().onBackpressureBuffer<GameUserEvent>(1024, false)
private val gameAdminEvents = Sinks.many().multicast().onBackpressureBuffer<GameAdminEvent>(1024, false)
fun subscribe(): Flux<List<GameEvent>> { fun subscribeUser(): Flux<List<GameUserEvent>> {
log.debug { "New subscription for gameUpdates" } log.debug { "New user subscription for gameUpdates" }
return gameEvents.asFlux() return gameUserEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration()) .buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe { .doOnSubscribe {
log.debug { "Subscriber added to gameEvents [${gameEvents.currentSubscriberCount()}]" } log.debug { "Subscriber added to gameUserEvents [${gameUserEvents.currentSubscriberCount()}]" }
} }
.doFinally { .doFinally {
log.debug { "Subscriber removed from gameEvents with signal type $it [${gameEvents.currentSubscriberCount()}]" } log.debug { "Subscriber removed from gameUserEvents with signal type $it [${gameUserEvents.currentSubscriberCount()}]" }
} }
} }
fun emit(event: GameEvent) { fun subscribeAdmin(): Flux<List<GameAdminEvent>> {
gameEvents.tryEmitNext(event) log.debug { "New admin subscription for gameUpdates" }
return gameAdminEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe {
log.debug { "Subscriber added to gameAdminEvents [${gameAdminEvents.currentSubscriberCount()}]" }
}
.doFinally {
log.debug { "Subscriber removed from gameAdminEvents with signal type $it [${gameAdminEvents.currentSubscriberCount()}]" }
}
}
fun emitUser(event: GameUserEvent) {
gameUserEvents.tryEmitNext(event)
}
fun emitAdmin(event: GameAdminEvent) {
gameAdminEvents.tryEmitNext(event)
} }
private val executor = Executors.newVirtualThreadPerTaskExecutor() private val executor = Executors.newVirtualThreadPerTaskExecutor()
@@ -84,7 +101,7 @@ class GameService(
fun getAll(): List<GameDto> { fun getAll(): List<GameDto> {
val entities = gameRepository.findAll() val entities = gameRepository.findAll()
return entities.map { it.toDto() } return entities.toDtos()
} }
@Transactional @Transactional
@@ -854,73 +871,3 @@ class GameService(
fun String.normalizeGameTitle(): String = this.alphaNumeric().replaceRomanNumerals() fun String.normalizeGameTitle(): String = this.alphaNumeric().replaceRomanNumerals()
} }
fun Game.toDto(): GameDto {
// Helper functions
fun toDto(fieldMetadata: GameFieldMetadata): GameFieldMetadataDto {
val source = fieldMetadata.source
return when (source) {
is GameFieldPluginSource -> {
GameFieldMetadataDto(
type = GameFieldMetadataType.PLUGIN,
source = source.plugin.pluginId,
updatedAt = fieldMetadata.updatedAt!!
)
}
is GameFieldUserSource -> {
GameFieldMetadataDto(
type = GameFieldMetadataType.USER,
source = source.user.username,
updatedAt = fieldMetadata.updatedAt!!
)
}
else -> {
GameFieldMetadataDto(
type = GameFieldMetadataType.UNKNOWN,
source = "unknown source",
updatedAt = fieldMetadata.updatedAt!!
)
}
}
}
fun toDto(metadata: GameMetadata): GameMetadataDto {
return GameMetadataDto(
fileSize = metadata.fileSize ?: 0L,
downloadCount = metadata.downloadCount,
path = metadata.path,
fields = metadata.fields.mapValues { toDto(it.value) },
originalIds = metadata.originalIds.mapKeys { it.key.pluginId },
matchConfirmed = metadata.matchConfirmed
)
}
return GameDto(
id = id!!,
createdAt = createdAt!!,
updatedAt = updatedAt!!,
libraryId = this.library.id!!,
title = title!!,
coverId = this.coverImage?.id,
headerId = this.headerImage?.id,
comment = this.comment,
summary = this.summary,
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
userRating = this.userRating,
criticRating = this.criticRating,
publishers = this.publishers.map { it.name },
developers = this.developers.map { it.name },
genres = this.genres.map { it.name },
themes = this.themes.map { it.name },
keywords = this.keywords.toList(),
features = this.features.map { it.name },
perspectives = this.perspectives?.map { it.name },
imageIds = this.images.mapNotNull { it.id },
videoUrls = this.videoUrls.map { it.toString() },
metadata = toDto(this.metadata)
)
}
@@ -4,28 +4,79 @@ import com.fasterxml.jackson.annotation.JsonInclude
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
@JsonInclude(JsonInclude.Include.NON_NULL) sealed interface GameDto {
class GameDto( val id: Long
val id: Long, val createdAt: Instant
val createdAt: Instant, val updatedAt: Instant
val updatedAt: Instant, val libraryId: Long
val libraryId: Long, val title: String
val title: String, val coverId: Long?
val coverId: Long?, val headerId: Long?
val headerId: Long?, val comment: String?
val comment: String?, val summary: String?
val summary: String?, val release: LocalDate?
val release: LocalDate?, val userRating: Int?
val userRating: Int?, val criticRating: Int?
val criticRating: Int?, val publishers: List<String>?
val publishers: List<String>?, val developers: List<String>?
val developers: List<String>?, val genres: List<String>?
val genres: List<String>?, val themes: List<String>?
val themes: List<String>?, val keywords: List<String>?
val keywords: List<String>?, val features: List<String>?
val features: List<String>?, val perspectives: List<String>?
val perspectives: List<String>?, val imageIds: List<Long>?
val imageIds: List<Long>?, val videoUrls: List<String>?
val videoUrls: List<String>?,
val metadata: GameMetadataDto val metadata: GameMetadataDto
) }
@JsonInclude(JsonInclude.Include.NON_NULL)
data class GameUserDto(
override val id: Long,
override val createdAt: Instant,
override val updatedAt: Instant,
override val libraryId: Long,
override val title: String,
override val coverId: Long?,
override val headerId: Long?,
override val comment: String?,
override val summary: String?,
override val release: LocalDate?,
override val userRating: Int?,
override val criticRating: Int?,
override val publishers: List<String>?,
override val developers: List<String>?,
override val genres: List<String>?,
override val themes: List<String>?,
override val keywords: List<String>?,
override val features: List<String>?,
override val perspectives: List<String>?,
override val imageIds: List<Long>?,
override val videoUrls: List<String>?,
override val metadata: GameMetadataUserDto
) : GameDto
@JsonInclude(JsonInclude.Include.NON_NULL)
data class GameAdminDto(
override val id: Long,
override val createdAt: Instant,
override val updatedAt: Instant,
override val libraryId: Long,
override val title: String,
override val coverId: Long?,
override val headerId: Long?,
override val comment: String?,
override val summary: String?,
override val release: LocalDate?,
override val userRating: Int?,
override val criticRating: Int?,
override val publishers: List<String>?,
override val developers: List<String>?,
override val genres: List<String>?,
override val themes: List<String>?,
override val keywords: List<String>?,
override val features: List<String>?,
override val perspectives: List<String>?,
override val imageIds: List<Long>?,
override val videoUrls: List<String>?,
override val metadata: GameMetadataAdminDto
) : GameDto
@@ -1,9 +0,0 @@
package org.gameyfin.app.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,18 @@
package org.gameyfin.app.games.dto
sealed interface GameEvent {
val type: String
}
sealed class GameUserEvent : GameEvent {
data class Created(val game: GameUserDto, override val type: String = "created") : GameUserEvent()
data class Updated(val game: GameUserDto, override val type: String = "updated") : GameUserEvent()
data class Deleted(val gameId: Long, override val type: String = "deleted") : GameUserEvent()
}
sealed class GameAdminEvent : GameEvent {
data class Created(val game: GameAdminDto, override val type: String = "created") : GameAdminEvent()
data class Updated(val game: GameAdminDto, override val type: String = "updated") : GameAdminEvent()
data class Deleted(val gameId: Long, override val type: String = "deleted") : GameAdminEvent()
}
@@ -2,12 +2,21 @@ package org.gameyfin.app.games.dto
import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonInclude
interface GameMetadataDto {
val fileSize: Long
}
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
class GameMetadataDto( data class GameMetadataUserDto(
override val fileSize: Long
) : GameMetadataDto
@JsonInclude(JsonInclude.Include.NON_NULL)
data class GameMetadataAdminDto(
val path: String?, val path: String?,
val fileSize: Long, override val fileSize: Long,
val fields: Map<String, GameFieldMetadataDto>?, val fields: Map<String, GameFieldMetadataDto>?,
val originalIds: Map<String, String>?, val originalIds: Map<String, String>?,
val downloadCount: Int, val downloadCount: Int,
val matchConfirmed: Boolean val matchConfirmed: Boolean
) ) : GameMetadataDto
@@ -4,25 +4,27 @@ import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate import jakarta.persistence.PostUpdate
import org.gameyfin.app.games.GameService import org.gameyfin.app.games.GameService
import org.gameyfin.app.games.dto.GameEvent import org.gameyfin.app.games.dto.GameAdminEvent
import org.gameyfin.app.games.toDto import org.gameyfin.app.games.dto.GameUserEvent
import org.gameyfin.app.games.extensions.toAdminDto
import org.gameyfin.app.games.extensions.toUserDto
class GameEntityListener { class GameEntityListener {
@PostPersist @PostPersist
fun created(game: Game) { fun created(game: Game) {
val event = GameEvent.Created(game.toDto()) GameService.Companion.emitUser(GameUserEvent.Created(game.toUserDto()))
GameService.Companion.emit(event) GameService.Companion.emitAdmin(GameAdminEvent.Created(game.toAdminDto()))
} }
@PostUpdate @PostUpdate
fun updated(game: Game) { fun updated(game: Game) {
val event = GameEvent.Updated(game.toDto()) GameService.Companion.emitUser(GameUserEvent.Updated(game.toUserDto()))
GameService.Companion.emit(event) GameService.Companion.emitAdmin(GameAdminEvent.Updated(game.toAdminDto()))
} }
@PostRemove @PostRemove
fun deleted(game: Game) { fun deleted(game: Game) {
val event = GameEvent.Deleted(game.id!!) GameService.Companion.emitUser(GameUserEvent.Deleted(game.id!!))
GameService.Companion.emit(event) GameService.Companion.emitAdmin(GameAdminEvent.Deleted(game.id!!))
} }
} }
@@ -1,29 +1,31 @@
package org.gameyfin.app.games.entities package org.gameyfin.app.games.entities
import org.gameyfin.app.libraries.dto.LibraryEvent
import jakarta.persistence.PostPersist import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate import jakarta.persistence.PostUpdate
import org.gameyfin.app.libraries.Library import org.gameyfin.app.libraries.Library
import org.gameyfin.app.libraries.LibraryService import org.gameyfin.app.libraries.LibraryService
import org.gameyfin.app.libraries.toDto import org.gameyfin.app.libraries.dto.LibraryAdminEvent
import org.gameyfin.app.libraries.dto.LibraryUserEvent
import org.gameyfin.app.libraries.extensions.toAdminDto
import org.gameyfin.app.libraries.extensions.toUserDto
class LibraryEntityListener { class LibraryEntityListener {
@PostPersist @PostPersist
fun created(library: Library) { fun created(library: Library) {
val event = LibraryEvent.Created(library.toDto()) LibraryService.emitUser(LibraryUserEvent.Created(library.toUserDto()))
LibraryService.Companion.emit(event) LibraryService.emitAdmin(LibraryAdminEvent.Created(library.toAdminDto()))
} }
@PostUpdate @PostUpdate
fun updated(library: Library) { fun updated(library: Library) {
val event = LibraryEvent.Updated(library.toDto()) LibraryService.emitUser(LibraryUserEvent.Updated(library.toUserDto()))
LibraryService.Companion.emit(event) LibraryService.emitAdmin(LibraryAdminEvent.Updated(library.toAdminDto()))
} }
@PostRemove @PostRemove
fun deleted(library: Library) { fun deleted(library: Library) {
val event = LibraryEvent.Deleted(library.id!!) LibraryService.emitUser(LibraryUserEvent.Deleted(library.id!!))
LibraryService.Companion.emit(event) LibraryService.emitAdmin(LibraryAdminEvent.Deleted(library.id!!))
} }
} }
@@ -0,0 +1,124 @@
package org.gameyfin.app.games.extensions
import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.games.dto.*
import org.gameyfin.app.games.entities.*
import java.time.ZoneOffset
fun Game.toDto(): GameDto {
return if (isCurrentUserAdmin()) {
this.toAdminDto()
} else {
this.toUserDto()
}
}
fun Collection<Game>.toDtos(): List<GameDto> {
return if (isCurrentUserAdmin()) {
this.map { it.toAdminDto() }
} else {
this.map { it.toUserDto() }
}
}
fun Game.toAdminDto(): GameAdminDto {
return GameAdminDto(
id = id!!,
createdAt = createdAt!!,
updatedAt = updatedAt!!,
libraryId = this.library.id!!,
title = title!!,
coverId = this.coverImage?.id,
headerId = this.headerImage?.id,
comment = this.comment,
summary = this.summary,
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
userRating = this.userRating,
criticRating = this.criticRating,
publishers = this.publishers.map { it.name },
developers = this.developers.map { it.name },
genres = this.genres.map { it.name },
themes = this.themes.map { it.name },
keywords = this.keywords.toList(),
features = this.features.map { it.name },
perspectives = this.perspectives.map { it.name },
imageIds = this.images.mapNotNull { it.id },
videoUrls = this.videoUrls.map { it.toString() },
metadata = this.metadata.toAdminDto()
)
}
fun Game.toUserDto(): GameUserDto {
return GameUserDto(
id = id!!,
createdAt = createdAt!!,
updatedAt = updatedAt!!,
libraryId = this.library.id!!,
title = title!!,
coverId = this.coverImage?.id,
headerId = this.headerImage?.id,
comment = this.comment,
summary = this.summary,
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
userRating = this.userRating,
criticRating = this.criticRating,
publishers = this.publishers.map { it.name },
developers = this.developers.map { it.name },
genres = this.genres.map { it.name },
themes = this.themes.map { it.name },
keywords = this.keywords.toList(),
features = this.features.map { it.name },
perspectives = this.perspectives.map { it.name },
imageIds = this.images.mapNotNull { it.id },
videoUrls = this.videoUrls.map { it.toString() },
metadata = this.metadata.toUserDto()
)
}
private fun GameMetadata.toAdminDto(): GameMetadataAdminDto {
return GameMetadataAdminDto(
fileSize = this.fileSize ?: 0L,
downloadCount = this.downloadCount,
path = this.path,
fields = this.fields.mapValues { it.value.toDto() },
originalIds = this.originalIds.mapKeys { it.key.pluginId },
matchConfirmed = this.matchConfirmed
)
}
private fun GameMetadata.toUserDto(): GameMetadataUserDto {
return GameMetadataUserDto(
fileSize = this.fileSize ?: 0L
)
}
private fun GameFieldMetadata.toDto(): GameFieldMetadataDto {
val source = this.source
return when (source) {
is GameFieldPluginSource -> {
GameFieldMetadataDto(
type = GameFieldMetadataType.PLUGIN,
source = source.plugin.pluginId,
updatedAt = this.updatedAt!!
)
}
is GameFieldUserSource -> {
GameFieldMetadataDto(
type = GameFieldMetadataType.USER,
source = source.user.username,
updatedAt = this.updatedAt!!
)
}
else -> {
GameFieldMetadataDto(
type = GameFieldMetadataType.UNKNOWN,
source = "unknown source",
updatedAt = this.updatedAt!!
)
}
}
}
@@ -2,10 +2,6 @@ package org.gameyfin.app.libraries
import org.gameyfin.app.games.GameService import org.gameyfin.app.games.GameService
import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Game
import org.gameyfin.app.libraries.dto.DirectoryMappingDto
import org.gameyfin.app.libraries.dto.LibraryDto
import org.gameyfin.app.libraries.dto.LibraryStatsDto
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Instant import java.time.Instant
@@ -54,36 +50,4 @@ class LibraryCoreService(
library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
libraryRepository.save(library) libraryRepository.save(library)
} }
/**
* Converts a LibraryDto to a Library entity.
*
* @param library: The LibraryDto to convert.
* @return The converted Library entity.
*/
fun toEntity(library: LibraryDto): Library {
return libraryRepository.findByIdOrNull(library.id) ?: Library(
name = library.name,
directories = library.directories.distinctBy { it.internalPath }.map {
DirectoryMapping(internalPath = it.internalPath, externalPath = it.externalPath)
}.toMutableList(),
)
}
}
fun Library.toDto(): LibraryDto {
val statsDto = LibraryStatsDto(
gamesCount = this.games.size,
downloadedGamesCount = this.games.sumOf { it.metadata.downloadCount }
)
return LibraryDto(
id = this.id!!,
name = this.name,
directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
games = this.games.mapNotNull { it.id },
stats = statsDto,
unmatchedPaths = this.unmatchedPaths
)
} }
@@ -5,13 +5,12 @@ import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.core.annotations.DynamicPublicAccess import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.libraries.dto.LibraryDto import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.libraries.dto.LibraryAdminDto
import org.gameyfin.app.libraries.dto.LibraryEvent import org.gameyfin.app.libraries.dto.LibraryEvent
import org.gameyfin.app.libraries.dto.LibraryScanProgress import org.gameyfin.app.libraries.dto.LibraryScanProgress
import org.gameyfin.app.libraries.dto.LibraryUpdateDto import org.gameyfin.app.libraries.dto.LibraryUpdateDto
import org.gameyfin.app.libraries.enums.ScanType import org.gameyfin.app.libraries.enums.ScanType
import org.gameyfin.app.users.UserService
import org.gameyfin.app.users.util.isAdmin
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
@Endpoint @Endpoint
@@ -19,27 +18,29 @@ import reactor.core.publisher.Flux
@AnonymousAllowed @AnonymousAllowed
class LibraryEndpoint( class LibraryEndpoint(
private val libraryService: LibraryService, private val libraryService: LibraryService,
private val userService: UserService,
private val libraryScanService: LibraryScanService, private val libraryScanService: LibraryScanService,
) { ) {
fun subscribeToLibraryEvents(): Flux<List<LibraryEvent>> { fun subscribeToLibraryEvents(): Flux<out List<LibraryEvent>> {
return LibraryService.subscribeToLibraryEvents() return if (isCurrentUserAdmin()) {
LibraryService.subscribeAdmin()
} else {
LibraryService.subscribeUser()
}
} }
fun getAll() = libraryService.getAll() fun getAll() = libraryService.getAll()
fun subscribeToScanProgressEvents(): Flux<List<LibraryScanProgress>> { fun subscribeToScanProgressEvents(): Flux<List<LibraryScanProgress>> {
val user = userService.getCurrentUser() return if (isCurrentUserAdmin()) LibraryScanService.subscribeToScanProgressEvents()
return if (user.isAdmin()) LibraryScanService.subscribeToScanProgressEvents()
else Flux.empty() else Flux.empty()
} }
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection<LibraryDto>?) = fun triggerScan(scanType: ScanType = ScanType.QUICK, libraryIds: Collection<Long>?) =
libraryScanService.triggerScan(scanType, libraries) libraryScanService.triggerScan(scanType, libraryIds)
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun createLibrary(library: LibraryDto, scanAfterCreation: Boolean = true) = fun createLibrary(library: LibraryAdminDto, scanAfterCreation: Boolean = true) =
libraryService.create(library, scanAfterCreation) libraryService.create(library, scanAfterCreation)
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
@@ -4,7 +4,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.core.filesystem.FilesystemService import org.gameyfin.app.core.filesystem.FilesystemService
import org.gameyfin.app.games.GameService import org.gameyfin.app.games.GameService
import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Game
import org.gameyfin.app.libraries.dto.LibraryDto
import org.gameyfin.app.libraries.dto.LibraryScanProgress import org.gameyfin.app.libraries.dto.LibraryScanProgress
import org.gameyfin.app.libraries.dto.LibraryScanStatus import org.gameyfin.app.libraries.dto.LibraryScanStatus
import org.gameyfin.app.libraries.dto.LibraryScanStep import org.gameyfin.app.libraries.dto.LibraryScanStep
@@ -62,8 +61,8 @@ class LibraryScanService(
/** /**
* Wrapper function to trigger a scan for a list of libraries. * Wrapper function to trigger a scan for a list of libraries.
*/ */
fun triggerScan(scanType: ScanType, libraryDtos: Collection<LibraryDto>?) { fun triggerScan(scanType: ScanType, libraryIds: Collection<Long>?) {
val libraries = libraryDtos?.map { libraryCoreService.toEntity(it) } ?: libraryRepository.findAll() val libraries = libraryIds?.let { libraryRepository.findAllById(libraryIds) } ?: libraryRepository.findAll()
libraries.forEach { library -> libraries.forEach { library ->
val libraryId = library.id!! val libraryId = library.id!!
if (scansInProgress.putIfAbsent(libraryId, true) == null) { if (scansInProgress.putIfAbsent(libraryId, true) == null) {
@@ -84,30 +83,6 @@ class LibraryScanService(
} }
} }
/**
* Triggers a quick scan for a list of libraries.
* A quick scan will only scan for new games and deleted games, but will not touch existing games.
* If no list is provided, all libraries will be scanned.
*
* @param libraryDtos: List of LibraryDto objects to scan.
*/
fun quickScan(libraryDtos: Collection<LibraryDto>?) {
val libraries = libraryDtos?.map { libraryCoreService.toEntity(it) } ?: libraryRepository.findAll()
libraries.forEach { executor.submit { quickScan(it) } }
}
/**
* Triggers a full scan for a list of libraries.
* A full scan will rescan all games in the library, including metadata and images.
* If no list is provided, all libraries will be scanned.
*
* @param libraryDtos: List of LibraryDto objects to scan.
*/
fun fullScan(libraryDtos: Collection<LibraryDto>?) {
val libraries = libraryDtos?.map { libraryCoreService.toEntity(it) } ?: libraryRepository.findAll()
libraries.forEach { executor.submit { fullScan(it, false) } }
}
private fun quickScan(library: Library) { private fun quickScan(library: Library) {
val progress = LibraryScanProgress( val progress = LibraryScanProgress(
libraryId = library.id!!, libraryId = library.id!!,
@@ -2,11 +2,9 @@ package org.gameyfin.app.libraries
import com.vaadin.hilla.exception.EndpointException import com.vaadin.hilla.exception.EndpointException
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.games.GameService import org.gameyfin.app.libraries.dto.*
import org.gameyfin.app.libraries.dto.LibraryDto
import org.gameyfin.app.libraries.dto.LibraryEvent
import org.gameyfin.app.libraries.dto.LibraryUpdateDto
import org.gameyfin.app.libraries.enums.ScanType import org.gameyfin.app.libraries.enums.ScanType
import org.gameyfin.app.libraries.extensions.toDtos
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
@@ -18,31 +16,46 @@ import kotlin.time.toJavaDuration
@Service @Service
class LibraryService( class LibraryService(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val libraryCoreService: LibraryCoreService,
private val libraryScanService: LibraryScanService, private val libraryScanService: LibraryScanService,
private val gameService: GameService
) { ) {
companion object { companion object {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
/* Websockets */ /* Websockets */
private val libraryEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryEvent>(1024, false) private val libraryUserEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryUserEvent>(1024, false)
private val libraryAdminEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryAdminEvent>(1024, false)
fun subscribeToLibraryEvents(): Flux<List<LibraryEvent>> { fun subscribeUser(): Flux<List<LibraryUserEvent>> {
log.debug { "New subscription for libraryEvents" } log.debug { "New user subscription for libraryEvents" }
return libraryEvents.asFlux() return libraryUserEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration()) .buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe { .doOnSubscribe {
log.debug { "Subscriber added to libraryEvents [${libraryEvents.currentSubscriberCount()}]" } log.debug { "Subscriber added to user libraryUserEvents [${libraryUserEvents.currentSubscriberCount()}]" }
} }
.doFinally { .doFinally {
log.debug { "Subscriber removed from libraryEvents with signal type $it [${libraryEvents.currentSubscriberCount()}]" } log.debug { "Subscriber removed from user libraryUserEvents with signal type $it [${libraryUserEvents.currentSubscriberCount()}]" }
} }
} }
fun emit(event: LibraryEvent) { fun subscribeAdmin(): Flux<List<LibraryAdminEvent>> {
libraryEvents.tryEmitNext(event) log.debug { "New admin subscription for libraryEvents" }
return libraryAdminEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe {
log.debug { "Subscriber added to admin libraryAdminEvents [${libraryAdminEvents.currentSubscriberCount()}]" }
}
.doFinally {
log.debug { "Subscriber removed from admin libraryAdminEvents with signal type $it [${libraryAdminEvents.currentSubscriberCount()}]" }
}
}
fun emitUser(event: LibraryUserEvent) {
libraryUserEvents.tryEmitNext(event)
}
fun emitAdmin(event: LibraryAdminEvent) {
libraryAdminEvents.tryEmitNext(event)
} }
} }
@@ -52,7 +65,7 @@ class LibraryService(
*/ */
fun getAll(): List<LibraryDto> { fun getAll(): List<LibraryDto> {
val entities = libraryRepository.findAll() val entities = libraryRepository.findAll()
return entities.map { it.toDto() } return entities.toDtos()
} }
/** /**
@@ -70,14 +83,21 @@ class LibraryService(
* @param library: The library to create or update. * @param library: The library to create or update.
* @return The created or updated LibraryDto object. * @return The created or updated LibraryDto object.
*/ */
fun create(library: LibraryDto, scanAfterCreation: Boolean) { fun create(library: LibraryAdminDto, scanAfterCreation: Boolean) {
// Check for duplicate directories before creating a new library // Check for duplicate directories before creating a new library
checkForDuplicateDirectories(library.directories.map { it.internalPath }) checkForDuplicateDirectories(library.directories.map { it.internalPath })
val newLibrary = libraryRepository.save(libraryCoreService.toEntity(library)) var newLibrary = Library(
name = library.name,
directories = library.directories.distinctBy { it.internalPath }.map {
DirectoryMapping(internalPath = it.internalPath, externalPath = it.externalPath)
}.toMutableList(),
)
newLibrary = libraryRepository.save(newLibrary)
if (scanAfterCreation) { if (scanAfterCreation) {
libraryScanService.triggerScan(ScanType.QUICK, listOf(newLibrary.toDto())) libraryScanService.triggerScan(ScanType.QUICK, listOf(newLibrary.id!!))
} }
} }
@@ -1,10 +1,26 @@
package org.gameyfin.app.libraries.dto package org.gameyfin.app.libraries.dto
data class LibraryDto( import com.fasterxml.jackson.annotation.JsonInclude
val id: Long,
val name: String, interface LibraryDto {
val id: Long
val name: String
val games: List<Long>?
}
@JsonInclude(JsonInclude.Include.NON_NULL)
data class LibraryUserDto(
override val id: Long,
override val name: String,
override val games: List<Long>?
) : LibraryDto
@JsonInclude(JsonInclude.Include.NON_NULL)
data class LibraryAdminDto(
override val id: Long,
override val name: String,
val directories: List<DirectoryMappingDto>, val directories: List<DirectoryMappingDto>,
val games: List<Long>?, override val games: List<Long>?,
val stats: LibraryStatsDto?, val stats: LibraryStatsDto?,
val unmatchedPaths: List<String>? = emptyList() val unmatchedPaths: List<String> = emptyList()
) ) : LibraryDto
@@ -1,9 +0,0 @@
package org.gameyfin.app.libraries.dto
sealed class LibraryEvent {
abstract val type: String
data class Created(val library: LibraryDto, override val type: String = "created") : LibraryEvent()
data class Updated(val library: LibraryDto, override val type: String = "updated") : LibraryEvent()
data class Deleted(val libraryId: Long, override val type: String = "deleted") : LibraryEvent()
}
@@ -0,0 +1,17 @@
package org.gameyfin.app.libraries.dto
sealed interface LibraryEvent {
val type: String
}
sealed class LibraryUserEvent : LibraryEvent {
data class Created(val library: LibraryUserDto, override val type: String = "created") : LibraryUserEvent()
data class Updated(val library: LibraryUserDto, override val type: String = "updated") : LibraryUserEvent()
data class Deleted(val libraryId: Long, override val type: String = "deleted") : LibraryUserEvent()
}
sealed class LibraryAdminEvent : LibraryEvent {
data class Created(val library: LibraryAdminDto, override val type: String = "created") : LibraryAdminEvent()
data class Updated(val library: LibraryAdminDto, override val type: String = "updated") : LibraryAdminEvent()
data class Deleted(val libraryId: Long, override val type: String = "deleted") : LibraryAdminEvent()
}
@@ -0,0 +1,44 @@
package org.gameyfin.app.libraries.extensions
import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.libraries.Library
import org.gameyfin.app.libraries.dto.*
fun Library.toDto(): LibraryDto {
return if (isCurrentUserAdmin()) {
this.toAdminDto()
} else {
this.toUserDto()
}
}
fun Collection<Library>.toDtos(): List<LibraryDto> {
return if (isCurrentUserAdmin()) {
this.map { it.toAdminDto() }
} else {
this.map { it.toUserDto() }
}
}
fun Library.toUserDto(): LibraryUserDto {
return LibraryUserDto(
id = this.id!!,
name = this.name,
games = this.games.mapNotNull { it.id }
)
}
fun Library.toAdminDto(): LibraryAdminDto {
return LibraryAdminDto(
id = this.id!!,
name = this.name,
directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
games = this.games.mapNotNull { it.id },
stats = LibraryStatsDto(
gamesCount = this.games.size,
downloadedGamesCount = this.games.sumOf { it.metadata.downloadCount }
),
unmatchedPaths = this.unmatchedPaths
)
}
@@ -109,15 +109,6 @@ class UserService(
return toUserInfo(user) return toUserInfo(user)
} }
fun getCurrentUser(): org.gameyfin.app.users.entities.User {
val auth = SecurityContextHolder.getContext().authentication
if (auth.principal is OidcUser) {
return userRepository.findByOidcProviderId((auth.principal as OidcUser).subject)
?: throw UsernameNotFoundException("OIDC user not found")
}
return getByUsernameNonNull(auth.name)
}
fun getAvatar(username: String): Image? { fun getAvatar(username: String): Image? {
val user = getByUsernameNonNull(username) val user = getByUsernameNonNull(username)
return user.avatar return user.avatar
@@ -1,21 +0,0 @@
package org.gameyfin.app.users.util
import org.gameyfin.app.core.Role
import org.gameyfin.app.users.entities.User
import org.springframework.security.core.userdetails.UserDetails
fun User.hasRole(role: Role): Boolean {
return role.roleName in this.roles.map { r -> r.roleName }
}
fun UserDetails.hasRole(role: Role): Boolean {
return role.roleName in this.authorities.map { a -> a.authority }
}
fun UserDetails.isAdmin(): Boolean {
return hasRole(Role.SUPERADMIN) || hasRole(Role.ADMIN)
}
fun User.isAdmin(): Boolean {
return hasRole(Role.SUPERADMIN) || hasRole(Role.ADMIN)
}