diff --git a/.run/UI debug.run.xml b/.run/UI debug.run.xml index ccfbe55..1a2afd9 100644 --- a/.run/UI debug.run.xml +++ b/.run/UI debug.run.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/frontend/App.tsx b/app/src/main/frontend/App.tsx index a620430..01f759e 100644 --- a/app/src/main/frontend/App.tsx +++ b/app/src/main/frontend/App.tsx @@ -40,13 +40,17 @@ export default function App() { function ViewWithAuth() { const auth = useAuth(); - initializeLibraryState(); - initializeGameState(); + useEffect(() => { + if (auth.state.initializing || auth.state.loading) return; - if (isAdmin(auth)) { - initializeScanState(); - initializePluginState(); - } + initializeLibraryState(); + initializeGameState(); + + if (isAdmin(auth)) { + initializeScanState(); + initializePluginState(); + } + }, [auth]); return <> diff --git a/app/src/main/frontend/components/administration/withConfigPage.tsx b/app/src/main/frontend/components/administration/withConfigPage.tsx index b49b766..70a5ad8 100644 --- a/app/src/main/frontend/components/administration/withConfigPage.tsx +++ b/app/src/main/frontend/components/administration/withConfigPage.tsx @@ -32,8 +32,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType { diff --git a/app/src/main/frontend/components/general/SearchBar.tsx b/app/src/main/frontend/components/general/SearchBar.tsx index 877e7dd..401d0e4 100644 --- a/app/src/main/frontend/components/general/SearchBar.tsx +++ b/app/src/main/frontend/components/general/SearchBar.tsx @@ -10,7 +10,7 @@ export default function SearchBar() { const navigate = useNavigate(); const state = useSnapshot(gameState); - const games = state.recentlyUpdated as GameDto[]; + const games = state.games as GameDto[]; return ("all"); const [sortDescriptor, setSortDescriptor] = useState({column: "title", direction: "ascending"}); - const [selectedGame, setSelectedGame] = useState(games[0]); + const [selectedGame, setSelectedGame] = useState(games[0]); const editGameModal = useDisclosure(); const matchGameModal = useDisclosure(); @@ -53,7 +54,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames }, [games, filter, searchTerm]); const sortedItems = useMemo(() => { - return filteredItems.slice().sort((a, b) => { + return (filteredItems as GameAdminDto[]).slice().sort((a, b) => { let cmp: number; switch (sortDescriptor.column) { @@ -86,7 +87,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames function getFilteredGames() { - let filteredGames = games.filter((game) => + let filteredGames = (games as GameAdminDto[]).filter((game) => game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) || game.title.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; } - async function toggleMatchConfirmed(game: GameDto) { + async function toggleMatchConfirmed(game: GameAdminDto) { await GameEndpoint.updateGame( { id: game.id, @@ -163,13 +164,13 @@ export default function LibraryManagementGames({library}: LibraryManagementGames Actions - {(item) => ( + {(item: GameAdminDto) => ( {item.title} ({item.release !== undefined ? new Date(item.release).getFullYear() : "unknown"}) + underline="hover">{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"}) diff --git a/app/src/main/frontend/components/general/library/LibraryManagementUnmatchedPaths.tsx b/app/src/main/frontend/components/general/library/LibraryManagementUnmatchedPaths.tsx index d1925f6..948e3fa 100644 --- a/app/src/main/frontend/components/general/library/LibraryManagementUnmatchedPaths.tsx +++ b/app/src/main/frontend/components/general/library/LibraryManagementUnmatchedPaths.tsx @@ -1,4 +1,3 @@ -import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto"; import { Button, Input, @@ -19,9 +18,10 @@ import {useMemo, useState} from "react"; import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto"; import {fileNameFromPath, hashCode} from "Frontend/util/utils"; import MatchGameModal from "Frontend/components/general/modals/MatchGameModal"; +import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; interface LibraryManagementUnmatchedPathsProps { - library: LibraryDto; + library: LibraryAdminDto; } export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) { diff --git a/app/src/main/frontend/components/general/modals/LibraryCreationModal.tsx b/app/src/main/frontend/components/general/modals/LibraryCreationModal.tsx index 3f2fb0d..637442c 100644 --- a/app/src/main/frontend/components/general/modals/LibraryCreationModal.tsx +++ b/app/src/main/frontend/components/general/modals/LibraryCreationModal.tsx @@ -6,6 +6,7 @@ import {LibraryEndpoint} from "Frontend/generated/endpoints"; import Input from "Frontend/components/general/input/Input"; import * as Yup from "yup"; import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput"; +import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; interface LibraryCreationModalProps { libraries: LibraryDto[]; @@ -23,7 +24,7 @@ export default function LibraryCreationModal({ const [scanAfterCreation, setScanAfterCreation] = useState(true); async function createLibrary(library: LibraryDto) { - await LibraryEndpoint.createLibrary(library as LibraryDto, scanAfterCreation); + await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation); addToast({ title: "New library created", diff --git a/app/src/main/frontend/components/general/plugin/PluginIcon.tsx b/app/src/main/frontend/components/general/plugin/PluginIcon.tsx index 83999bc..b0f254a 100644 --- a/app/src/main/frontend/components/general/plugin/PluginIcon.tsx +++ b/app/src/main/frontend/components/general/plugin/PluginIcon.tsx @@ -15,7 +15,6 @@ export default function PluginIcon({ blurred = false, showTooltip = true }: PluginIconProps) { - const icon = plugin.hasLogo ? diff --git a/app/src/main/frontend/dtos/GameDtos.ts b/app/src/main/frontend/dtos/GameDtos.ts new file mode 100644 index 0000000..78d544e --- /dev/null +++ b/app/src/main/frontend/dtos/GameDtos.ts @@ -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' +} \ No newline at end of file diff --git a/app/src/main/frontend/state/GameState.ts b/app/src/main/frontend/state/GameState.ts index 23e097d..a7d42b8 100644 --- a/app/src/main/frontend/state/GameState.ts +++ b/app/src/main/frontend/state/GameState.ts @@ -1,9 +1,9 @@ import {Subscription} from "@vaadin/hilla-frontend"; import {proxy} from "valtio/index"; 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 Rand from "rand-seed"; +import GameEvent from "Frontend/generated/org/gameyfin/app/games/dto/GameEvent"; type GameState = { subscription?: Subscription; @@ -116,7 +116,7 @@ export const gameState = proxy({ /** Subscribe to and process state updates from backend **/ export async function initializeGameState() { - if (gameState.isLoaded) return gameState; + if (gameState.isLoaded) return; // Fetch initial library list const initialEntries = await GameEndpoint.getAll(); @@ -140,6 +140,4 @@ export async function initializeGameState() { } }) }); - - return gameState; } \ No newline at end of file diff --git a/app/src/main/frontend/state/LibraryState.ts b/app/src/main/frontend/state/LibraryState.ts index a4a651d..778a1ad 100644 --- a/app/src/main/frontend/state/LibraryState.ts +++ b/app/src/main/frontend/state/LibraryState.ts @@ -31,7 +31,7 @@ export const libraryState = proxy({ /** Subscribe to and process state updates from backend **/ export async function initializeLibraryState() { - if (libraryState.isLoaded) return libraryState; + if (libraryState.isLoaded) return; // Fetch initial library list const initialEntries = await LibraryEndpoint.getAll(); @@ -57,6 +57,4 @@ export async function initializeLibraryState() { } }) }); - - return libraryState; } \ No newline at end of file diff --git a/app/src/main/frontend/views/GameView.tsx b/app/src/main/frontend/views/GameView.tsx index 31fcba1..4ef6d24 100644 --- a/app/src/main/frontend/views/GameView.tsx +++ b/app/src/main/frontend/views/GameView.tsx @@ -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 {humanFileSize, isAdmin, toTitleCase} from "Frontend/util/utils"; 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 GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import {CheckCircle, Info, MagnifyingGlass, Pencil, Trash, TriangleDashed} from "@phosphor-icons/react"; import {useAuth} from "Frontend/util/auth"; 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 Markdown from "react-markdown"; import remarkBreaks from "remark-breaks"; +import {GameAdminDto} from "Frontend/dtos/GameDtos"; export default function GameView() { const {gameId} = useParams(); @@ -28,7 +28,7 @@ export default function GameView() { const matchGameModal = useDisclosure(); 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>(); @@ -49,13 +49,11 @@ export default function GameView() { }, []); useEffect(() => { - initializeGameState().then((state) => { - if (!gameId || !state.state[parseInt(gameId)]) { - navigate("/", {replace: true}); - } - document.title = game ? game.title : "Gameyfin"; - }); - }, [gameId]); + if (state.isLoaded && (!gameId || !state.state[parseInt(gameId)])) { + navigate("/", {replace: true}); + } + document.title = game ? game.title : "Gameyfin"; + }, [gameId, state]); async function toggleMatchConfirmed() { if (!game) return; diff --git a/app/src/main/frontend/views/LibraryManagementView.tsx b/app/src/main/frontend/views/LibraryManagementView.tsx index 288ffc9..78acac2 100644 --- a/app/src/main/frontend/views/LibraryManagementView.tsx +++ b/app/src/main/frontend/views/LibraryManagementView.tsx @@ -6,8 +6,9 @@ import {ArrowLeft} from "@phosphor-icons/react"; import LibraryManagementDetails from "Frontend/components/general/library/LibraryManagementDetails"; import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames"; 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 LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; export default function LibraryManagementView() { @@ -17,12 +18,10 @@ export default function LibraryManagementView() { const state = useSnapshot(libraryState); useEffect(() => { - initializeLibraryState().then((state) => { - if (!libraryId || !state.state[parseInt(libraryId)]) { - navigate("/administration/libraries"); - } - }); - }, [libraryId]); + if (state.isLoaded && (!libraryId || !state.state[parseInt(libraryId)])) { + navigate("/administration/libraries"); + } + }, [state, libraryId]); return libraryId && state.state[parseInt(libraryId)] &&
@@ -31,23 +30,18 @@ export default function LibraryManagementView() {

Manage library

- {/* @ts-ignore */} - - {/* @ts-ignore */} + 0 ? hash : "#details"} onSelectionChange={(newKey) => navigate(newKey.toString(), {replace: true})}> - {/* @ts-ignore */} - + - {/* @ts-ignore */} - + - {/* @ts-ignore */} - +
; diff --git a/app/src/main/frontend/views/LibraryView.tsx b/app/src/main/frontend/views/LibraryView.tsx index 9943fa8..597d7e1 100644 --- a/app/src/main/frontend/views/LibraryView.tsx +++ b/app/src/main/frontend/views/LibraryView.tsx @@ -1,5 +1,5 @@ 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 React, {useEffect} from "react"; import {useNavigate, useParams} from "react-router"; @@ -13,13 +13,11 @@ export default function LibraryView() { const games = (libraryId ? useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!!)] || [] : []) as GameDto[]; useEffect(() => { - initializeLibraryState().then((state) => { - if (!libraryId || !state.state[parseInt(libraryId)]) { - navigate("/", {replace: true}); - } - document.title = state.state[parseInt(libraryId!!)]?.name || "Gameyfin"; - }); - }, [libraryId]); + if (libraries.isLoaded && (!libraryId || !libraries.state[parseInt(libraryId)])) { + navigate("/", {replace: true}); + } + document.title = libraries.state[parseInt(libraryId!!)]?.name || "Gameyfin"; + }, [libraryId, libraries]); return (
diff --git a/app/src/main/kotlin/org/gameyfin/app/config/ConfigEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/config/ConfigEndpoint.kt index 7666814..50ddede 100644 --- a/app/src/main/kotlin/org/gameyfin/app/config/ConfigEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/config/ConfigEndpoint.kt @@ -9,8 +9,7 @@ import org.gameyfin.app.config.dto.ConfigEntryDto import org.gameyfin.app.config.dto.ConfigUpdateDto import org.gameyfin.app.core.Role import org.gameyfin.app.core.annotations.DynamicPublicAccess -import org.gameyfin.app.users.UserService -import org.gameyfin.app.users.util.isAdmin +import org.gameyfin.app.core.security.isCurrentUserAdmin import org.springframework.scheduling.support.CronExpression import reactor.core.publisher.Flux @@ -18,7 +17,6 @@ import reactor.core.publisher.Flux @RolesAllowed(Role.Names.ADMIN) class ConfigEndpoint( private val configService: ConfigService, - private val userService: UserService, ) { companion object { val log = KotlinLogging.logger { } @@ -28,8 +26,7 @@ class ConfigEndpoint( @PermitAll fun subscribe(): Flux> { - val user = userService.getCurrentUser() - return if (user.isAdmin()) ConfigService.subscribe() + return if (isCurrentUserAdmin()) ConfigService.subscribe() else Flux.empty() } diff --git a/app/src/main/kotlin/org/gameyfin/app/config/ConfigService.kt b/app/src/main/kotlin/org/gameyfin/app/config/ConfigService.kt index 2caa883..fc74e31 100644 --- a/app/src/main/kotlin/org/gameyfin/app/config/ConfigService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/config/ConfigService.kt @@ -46,7 +46,7 @@ class ConfigService( */ fun get(configProperty: ConfigProperties): T? { - log.debug { "Getting config value '${configProperty.key}'" } + log.trace { "Getting config value '${configProperty.key}'" } val appConfig = appConfigRepository.findByIdOrNull(configProperty.key) return if (appConfig != null) { @@ -65,7 +65,7 @@ class ConfigService( */ fun get(key: String): Serializable? { - log.debug { "Getting config value '$key'" } + log.trace { "Getting config value '$key'" } val configProperty = findConfigProperty(key) diff --git a/app/src/main/kotlin/org/gameyfin/app/core/logging/LogEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/core/logging/LogEndpoint.kt index c161243..98b0ca1 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/logging/LogEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/logging/LogEndpoint.kt @@ -4,15 +4,13 @@ import com.vaadin.hilla.Endpoint import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed import org.gameyfin.app.core.Role -import org.gameyfin.app.users.UserService -import org.gameyfin.app.users.util.isAdmin +import org.gameyfin.app.core.security.isCurrentUserAdmin import reactor.core.publisher.Flux @Endpoint @RolesAllowed(Role.Names.ADMIN) class LogEndpoint( private val logService: LogService, - private val userService: UserService, ) { fun reloadLogConfig() { @@ -21,8 +19,7 @@ class LogEndpoint( @PermitAll fun getApplicationLogs(): Flux { - val user = userService.getCurrentUser() - return if (user.isAdmin()) logService.streamLogs() + return if (isCurrentUserAdmin()) logService.streamLogs() else Flux.empty() } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/core/plugins/PluginEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/core/plugins/PluginEndpoint.kt index 6f25573..94be2f4 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/plugins/PluginEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/plugins/PluginEndpoint.kt @@ -5,8 +5,7 @@ import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed import org.gameyfin.app.core.Role import org.gameyfin.app.core.plugins.dto.PluginUpdateDto -import org.gameyfin.app.users.UserService -import org.gameyfin.app.users.util.isAdmin +import org.gameyfin.app.core.security.isCurrentUserAdmin import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult import reactor.core.publisher.Flux @@ -14,13 +13,11 @@ import reactor.core.publisher.Flux @RolesAllowed(Role.Names.ADMIN) class PluginEndpoint( private val pluginService: PluginService, - private val userService: UserService, ) { @PermitAll fun subscribe(): Flux> { - val user = userService.getCurrentUser() - return if (user.isAdmin()) PluginService.subscribe() + return if (isCurrentUserAdmin()) PluginService.subscribe() else Flux.empty() } diff --git a/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityUtils.kt b/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityUtils.kt new file mode 100644 index 0000000..1b0adf1 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityUtils.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt index 61a043c..5ecd8dd 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt @@ -5,6 +5,7 @@ import com.vaadin.hilla.Endpoint import jakarta.annotation.security.RolesAllowed import org.gameyfin.app.core.Role import org.gameyfin.app.core.annotations.DynamicPublicAccess +import org.gameyfin.app.core.security.isCurrentUserAdmin import org.gameyfin.app.games.dto.* import org.gameyfin.app.libraries.LibraryCoreService import org.gameyfin.app.libraries.LibraryService @@ -19,8 +20,12 @@ class GameEndpoint( private val libraryService: LibraryService, private val libraryCoreService: LibraryCoreService ) { - fun subscribe(): Flux> { - return GameService.subscribe() + fun subscribe(): Flux> { + return if (isCurrentUserAdmin()) { + GameService.subscribeAdmin() + } else { + GameService.subscribeUser() + } } fun getAll(): List = gameService.getAll() diff --git a/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt b/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt index 3ac7fe6..751082e 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt @@ -18,7 +18,7 @@ import org.gameyfin.app.core.plugins.management.PluginManagementEntry import org.gameyfin.app.core.replaceRomanNumerals import org.gameyfin.app.games.dto.* 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.libraries.Library import org.gameyfin.app.media.ImageService @@ -57,22 +57,39 @@ class GameService( private val log = KotlinLogging.logger {} /* Websockets */ - private val gameEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) + private val gameUserEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) + private val gameAdminEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) - fun subscribe(): Flux> { - log.debug { "New subscription for gameUpdates" } - return gameEvents.asFlux() + fun subscribeUser(): Flux> { + log.debug { "New user subscription for gameUpdates" } + return gameUserEvents.asFlux() .buffer(100.milliseconds.toJavaDuration()) .doOnSubscribe { - log.debug { "Subscriber added to gameEvents [${gameEvents.currentSubscriberCount()}]" } + log.debug { "Subscriber added to gameUserEvents [${gameUserEvents.currentSubscriberCount()}]" } } .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) { - gameEvents.tryEmitNext(event) + fun subscribeAdmin(): Flux> { + 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() @@ -84,7 +101,7 @@ class GameService( fun getAll(): List { val entities = gameRepository.findAll() - return entities.map { it.toDto() } + return entities.toDtos() } @Transactional @@ -853,74 +870,4 @@ class GameService( } 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) - ) } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameDto.kt b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameDto.kt index 70fab71..8e0fa66 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameDto.kt @@ -4,28 +4,79 @@ import com.fasterxml.jackson.annotation.JsonInclude import java.time.Instant import java.time.LocalDate -@JsonInclude(JsonInclude.Include.NON_NULL) -class GameDto( - val id: Long, - val createdAt: Instant, - val updatedAt: Instant, - val libraryId: Long, - val title: String, - val coverId: Long?, - val headerId: Long?, - val comment: String?, - val summary: String?, - val release: LocalDate?, - val userRating: Int?, - val criticRating: Int?, - val publishers: List?, - val developers: List?, - val genres: List?, - val themes: List?, - val keywords: List?, - val features: List?, - val perspectives: List?, - val imageIds: List?, - val videoUrls: List?, +sealed interface GameDto { + val id: Long + val createdAt: Instant + val updatedAt: Instant + val libraryId: Long + val title: String + val coverId: Long? + val headerId: Long? + val comment: String? + val summary: String? + val release: LocalDate? + val userRating: Int? + val criticRating: Int? + val publishers: List? + val developers: List? + val genres: List? + val themes: List? + val keywords: List? + val features: List? + val perspectives: List? + val imageIds: List? + val videoUrls: List? val metadata: GameMetadataDto -) \ No newline at end of file +} + +@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?, + override val developers: List?, + override val genres: List?, + override val themes: List?, + override val keywords: List?, + override val features: List?, + override val perspectives: List?, + override val imageIds: List?, + override val videoUrls: List?, + 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?, + override val developers: List?, + override val genres: List?, + override val themes: List?, + override val keywords: List?, + override val features: List?, + override val perspectives: List?, + override val imageIds: List?, + override val videoUrls: List?, + override val metadata: GameMetadataAdminDto +) : GameDto diff --git a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameEvent.kt b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameEvent.kt deleted file mode 100644 index 8186ff3..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameEvent.kt +++ /dev/null @@ -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() -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameEvents.kt b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameEvents.kt new file mode 100644 index 0000000..de47aeb --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameEvents.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameMetadataDto.kt b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameMetadataDto.kt index 2caf243..efd5ecf 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameMetadataDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameMetadataDto.kt @@ -2,12 +2,21 @@ package org.gameyfin.app.games.dto import com.fasterxml.jackson.annotation.JsonInclude +interface GameMetadataDto { + val fileSize: Long +} + @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 fileSize: Long, + override val fileSize: Long, val fields: Map?, val originalIds: Map?, val downloadCount: Int, val matchConfirmed: Boolean -) \ No newline at end of file +) : GameMetadataDto diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt index 33c9902..efe500b 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt @@ -4,25 +4,27 @@ import jakarta.persistence.PostPersist import jakarta.persistence.PostRemove import jakarta.persistence.PostUpdate import org.gameyfin.app.games.GameService -import org.gameyfin.app.games.dto.GameEvent -import org.gameyfin.app.games.toDto +import org.gameyfin.app.games.dto.GameAdminEvent +import org.gameyfin.app.games.dto.GameUserEvent +import org.gameyfin.app.games.extensions.toAdminDto +import org.gameyfin.app.games.extensions.toUserDto class GameEntityListener { @PostPersist fun created(game: Game) { - val event = GameEvent.Created(game.toDto()) - GameService.Companion.emit(event) + GameService.Companion.emitUser(GameUserEvent.Created(game.toUserDto())) + GameService.Companion.emitAdmin(GameAdminEvent.Created(game.toAdminDto())) } @PostUpdate fun updated(game: Game) { - val event = GameEvent.Updated(game.toDto()) - GameService.Companion.emit(event) + GameService.Companion.emitUser(GameUserEvent.Updated(game.toUserDto())) + GameService.Companion.emitAdmin(GameAdminEvent.Updated(game.toAdminDto())) } @PostRemove fun deleted(game: Game) { - val event = GameEvent.Deleted(game.id!!) - GameService.Companion.emit(event) + GameService.Companion.emitUser(GameUserEvent.Deleted(game.id!!)) + GameService.Companion.emitAdmin(GameAdminEvent.Deleted(game.id!!)) } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/LibraryEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/LibraryEntityListener.kt index 2e49038..c035ea2 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/LibraryEntityListener.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/LibraryEntityListener.kt @@ -1,29 +1,31 @@ package org.gameyfin.app.games.entities -import org.gameyfin.app.libraries.dto.LibraryEvent import jakarta.persistence.PostPersist import jakarta.persistence.PostRemove import jakarta.persistence.PostUpdate import org.gameyfin.app.libraries.Library 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 { @PostPersist fun created(library: Library) { - val event = LibraryEvent.Created(library.toDto()) - LibraryService.Companion.emit(event) + LibraryService.emitUser(LibraryUserEvent.Created(library.toUserDto())) + LibraryService.emitAdmin(LibraryAdminEvent.Created(library.toAdminDto())) } @PostUpdate fun updated(library: Library) { - val event = LibraryEvent.Updated(library.toDto()) - LibraryService.Companion.emit(event) + LibraryService.emitUser(LibraryUserEvent.Updated(library.toUserDto())) + LibraryService.emitAdmin(LibraryAdminEvent.Updated(library.toAdminDto())) } @PostRemove fun deleted(library: Library) { - val event = LibraryEvent.Deleted(library.id!!) - LibraryService.Companion.emit(event) + LibraryService.emitUser(LibraryUserEvent.Deleted(library.id!!)) + LibraryService.emitAdmin(LibraryAdminEvent.Deleted(library.id!!)) } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/extensions/GameExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/games/extensions/GameExtensions.kt new file mode 100644 index 0000000..5ee6558 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/games/extensions/GameExtensions.kt @@ -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.toDtos(): List { + 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!! + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryCoreService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryCoreService.kt index f80e3b6..6fc27eb 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryCoreService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryCoreService.kt @@ -2,10 +2,6 @@ package org.gameyfin.app.libraries import org.gameyfin.app.games.GameService 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 java.time.Instant @@ -54,36 +50,4 @@ class LibraryCoreService( library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp 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 - ) } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryEndpoint.kt index 7a91293..ae211d5 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryEndpoint.kt @@ -5,13 +5,12 @@ import com.vaadin.hilla.Endpoint import jakarta.annotation.security.RolesAllowed import org.gameyfin.app.core.Role 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.LibraryScanProgress import org.gameyfin.app.libraries.dto.LibraryUpdateDto 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 @Endpoint @@ -19,27 +18,29 @@ import reactor.core.publisher.Flux @AnonymousAllowed class LibraryEndpoint( private val libraryService: LibraryService, - private val userService: UserService, private val libraryScanService: LibraryScanService, ) { - fun subscribeToLibraryEvents(): Flux> { - return LibraryService.subscribeToLibraryEvents() + fun subscribeToLibraryEvents(): Flux> { + return if (isCurrentUserAdmin()) { + LibraryService.subscribeAdmin() + } else { + LibraryService.subscribeUser() + } } fun getAll() = libraryService.getAll() fun subscribeToScanProgressEvents(): Flux> { - val user = userService.getCurrentUser() - return if (user.isAdmin()) LibraryScanService.subscribeToScanProgressEvents() + return if (isCurrentUserAdmin()) LibraryScanService.subscribeToScanProgressEvents() else Flux.empty() } @RolesAllowed(Role.Names.ADMIN) - fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection?) = - libraryScanService.triggerScan(scanType, libraries) + fun triggerScan(scanType: ScanType = ScanType.QUICK, libraryIds: Collection?) = + libraryScanService.triggerScan(scanType, libraryIds) @RolesAllowed(Role.Names.ADMIN) - fun createLibrary(library: LibraryDto, scanAfterCreation: Boolean = true) = + fun createLibrary(library: LibraryAdminDto, scanAfterCreation: Boolean = true) = libraryService.create(library, scanAfterCreation) @RolesAllowed(Role.Names.ADMIN) diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt index 3b64568..e66d23e 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt @@ -4,7 +4,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.gameyfin.app.core.filesystem.FilesystemService import org.gameyfin.app.games.GameService 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.LibraryScanStatus import org.gameyfin.app.libraries.dto.LibraryScanStep @@ -62,8 +61,8 @@ class LibraryScanService( /** * Wrapper function to trigger a scan for a list of libraries. */ - fun triggerScan(scanType: ScanType, libraryDtos: Collection?) { - val libraries = libraryDtos?.map { libraryCoreService.toEntity(it) } ?: libraryRepository.findAll() + fun triggerScan(scanType: ScanType, libraryIds: Collection?) { + val libraries = libraryIds?.let { libraryRepository.findAllById(libraryIds) } ?: libraryRepository.findAll() libraries.forEach { library -> val libraryId = library.id!! 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?) { - 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?) { - val libraries = libraryDtos?.map { libraryCoreService.toEntity(it) } ?: libraryRepository.findAll() - libraries.forEach { executor.submit { fullScan(it, false) } } - } - private fun quickScan(library: Library) { val progress = LibraryScanProgress( libraryId = library.id!!, diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt index 6b5f343..f9c61f5 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt @@ -2,11 +2,9 @@ package org.gameyfin.app.libraries import com.vaadin.hilla.exception.EndpointException import io.github.oshai.kotlinlogging.KotlinLogging -import org.gameyfin.app.games.GameService -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.dto.* import org.gameyfin.app.libraries.enums.ScanType +import org.gameyfin.app.libraries.extensions.toDtos import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import reactor.core.publisher.Flux @@ -18,31 +16,46 @@ import kotlin.time.toJavaDuration @Service class LibraryService( private val libraryRepository: LibraryRepository, - private val libraryCoreService: LibraryCoreService, private val libraryScanService: LibraryScanService, - private val gameService: GameService ) { companion object { private val log = KotlinLogging.logger {} /* Websockets */ - private val libraryEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) + private val libraryUserEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) + private val libraryAdminEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) - fun subscribeToLibraryEvents(): Flux> { - log.debug { "New subscription for libraryEvents" } - return libraryEvents.asFlux() + fun subscribeUser(): Flux> { + log.debug { "New user subscription for libraryEvents" } + return libraryUserEvents.asFlux() .buffer(100.milliseconds.toJavaDuration()) .doOnSubscribe { - log.debug { "Subscriber added to libraryEvents [${libraryEvents.currentSubscriberCount()}]" } + log.debug { "Subscriber added to user libraryUserEvents [${libraryUserEvents.currentSubscriberCount()}]" } } .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) { - libraryEvents.tryEmitNext(event) + fun subscribeAdmin(): Flux> { + 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 { 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. * @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 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) { - libraryScanService.triggerScan(ScanType.QUICK, listOf(newLibrary.toDto())) + libraryScanService.triggerScan(ScanType.QUICK, listOf(newLibrary.id!!)) } } diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryDto.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryDto.kt index 4f77a09..77d565b 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryDto.kt @@ -1,10 +1,26 @@ package org.gameyfin.app.libraries.dto -data class LibraryDto( - val id: Long, - val name: String, +import com.fasterxml.jackson.annotation.JsonInclude + +interface LibraryDto { + val id: Long + val name: String + val games: List? +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class LibraryUserDto( + override val id: Long, + override val name: String, + override val games: List? +) : LibraryDto + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class LibraryAdminDto( + override val id: Long, + override val name: String, val directories: List, - val games: List?, + override val games: List?, val stats: LibraryStatsDto?, - val unmatchedPaths: List? = emptyList() -) \ No newline at end of file + val unmatchedPaths: List = emptyList() +) : LibraryDto diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryEvent.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryEvent.kt deleted file mode 100644 index 930c908..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryEvent.kt +++ /dev/null @@ -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() -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryEvents.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryEvents.kt new file mode 100644 index 0000000..512f2d2 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryEvents.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensions.kt new file mode 100644 index 0000000..a73f0d7 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensions.kt @@ -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.toDtos(): List { + 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 + ) +} diff --git a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt index 7a84315..82ec4ab 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt @@ -109,15 +109,6 @@ class UserService( 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? { val user = getByUsernameNonNull(username) return user.avatar diff --git a/app/src/main/kotlin/org/gameyfin/app/users/util/UserDetailsExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/users/util/UserDetailsExtensions.kt deleted file mode 100644 index 6f17d81..0000000 --- a/app/src/main/kotlin/org/gameyfin/app/users/util/UserDetailsExtensions.kt +++ /dev/null @@ -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) -} \ No newline at end of file