mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Release 2.1
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<component name="ProjectRunConfigurationManager">
|
||||||
<configuration default="false" name="GameyfinApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
|
<configuration default="false" name="Gameyfin" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
|
||||||
<option name="ACTIVE_PROFILES" value="dev" />
|
<option name="ACTIVE_PROFILES" value="dev" />
|
||||||
<option name="ALTERNATIVE_JRE_PATH" value="BUNDLED" />
|
<option name="ALTERNATIVE_JRE_PATH" value="BUNDLED" />
|
||||||
<envs>
|
<envs>
|
||||||
@@ -54,6 +54,7 @@ dependencies {
|
|||||||
// Persistence & I/O
|
// Persistence & I/O
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||||
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17")
|
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17")
|
||||||
|
implementation("org.flywaydb:flyway-core")
|
||||||
implementation("commons-io:commons-io:2.18.0")
|
implementation("commons-io:commons-io:2.18.0")
|
||||||
|
|
||||||
// SSO
|
// SSO
|
||||||
|
|||||||
Generated
+802
-1128
File diff suppressed because it is too large
Load Diff
+113
-113
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gameyfin",
|
"name": "gameyfin",
|
||||||
"version": "2.0.1",
|
"version": "2.1.0-preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroui/react": "2.7.9",
|
"@heroui/react": "2.7.9",
|
||||||
@@ -9,22 +9,22 @@
|
|||||||
"@polymer/polymer": "3.5.2",
|
"@polymer/polymer": "3.5.2",
|
||||||
"@react-stately/data": "^3.12.2",
|
"@react-stately/data": "^3.12.2",
|
||||||
"@react-types/shared": "^3.28.0",
|
"@react-types/shared": "^3.28.0",
|
||||||
"@vaadin/bundles": "24.8.3",
|
"@vaadin/bundles": "24.9.0",
|
||||||
"@vaadin/common-frontend": "0.0.19",
|
"@vaadin/common-frontend": "0.0.19",
|
||||||
"@vaadin/hilla-file-router": "24.8.2",
|
"@vaadin/hilla-file-router": "24.9.0",
|
||||||
"@vaadin/hilla-frontend": "24.8.2",
|
"@vaadin/hilla-frontend": "24.9.0",
|
||||||
"@vaadin/hilla-lit-form": "24.8.2",
|
"@vaadin/hilla-lit-form": "24.9.0",
|
||||||
"@vaadin/hilla-react-auth": "24.8.2",
|
"@vaadin/hilla-react-auth": "24.9.0",
|
||||||
"@vaadin/hilla-react-crud": "24.8.2",
|
"@vaadin/hilla-react-crud": "24.9.0",
|
||||||
"@vaadin/hilla-react-form": "24.8.2",
|
"@vaadin/hilla-react-form": "24.9.0",
|
||||||
"@vaadin/hilla-react-i18n": "24.8.2",
|
"@vaadin/hilla-react-i18n": "24.9.0",
|
||||||
"@vaadin/hilla-react-signals": "24.8.2",
|
"@vaadin/hilla-react-signals": "24.9.0",
|
||||||
"@vaadin/polymer-legacy-adapter": "24.8.3",
|
"@vaadin/polymer-legacy-adapter": "24.9.0",
|
||||||
"@vaadin/react-components": "24.8.3",
|
"@vaadin/react-components": "24.9.0",
|
||||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||||
"@vaadin/vaadin-lumo-styles": "24.8.3",
|
"@vaadin/vaadin-lumo-styles": "24.9.0",
|
||||||
"@vaadin/vaadin-material-styles": "24.8.3",
|
"@vaadin/vaadin-material-styles": "24.9.0",
|
||||||
"@vaadin/vaadin-themable-mixin": "24.8.3",
|
"@vaadin/vaadin-themable-mixin": "24.9.0",
|
||||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"construct-style-sheets-polyfill": "3.1.0",
|
"construct-style-sheets-polyfill": "3.1.0",
|
||||||
@@ -61,17 +61,17 @@
|
|||||||
"@types/node": "^22.4.0",
|
"@types/node": "^22.4.0",
|
||||||
"@types/react": "18.3.23",
|
"@types/react": "18.3.23",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "18.3.7",
|
||||||
"@vaadin/hilla-generator-cli": "24.8.2",
|
"@vaadin/hilla-generator-cli": "24.9.0",
|
||||||
"@vaadin/hilla-generator-core": "24.8.2",
|
"@vaadin/hilla-generator-core": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-backbone": "24.8.2",
|
"@vaadin/hilla-generator-plugin-backbone": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-barrel": "24.8.2",
|
"@vaadin/hilla-generator-plugin-barrel": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-client": "24.8.2",
|
"@vaadin/hilla-generator-plugin-client": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-model": "24.8.2",
|
"@vaadin/hilla-generator-plugin-model": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-push": "24.8.2",
|
"@vaadin/hilla-generator-plugin-push": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-signals": "24.8.2",
|
"@vaadin/hilla-generator-plugin-signals": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-subtypes": "24.8.2",
|
"@vaadin/hilla-generator-plugin-subtypes": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.2",
|
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.0",
|
||||||
"@vaadin/hilla-generator-utils": "24.8.2",
|
"@vaadin/hilla-generator-utils": "24.9.0",
|
||||||
"@vitejs/plugin-react": "4.5.0",
|
"@vitejs/plugin-react": "4.5.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"async": "3.2.6",
|
"async": "3.2.6",
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"transform-ast": "2.4.4",
|
"transform-ast": "2.4.4",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "6.3.5",
|
"vite": "6.3.6",
|
||||||
"vite-plugin-checker": "0.9.3",
|
"vite-plugin-checker": "0.9.3",
|
||||||
"workbox-build": "7.3.0",
|
"workbox-build": "7.3.0",
|
||||||
"workbox-core": "7.3.0",
|
"workbox-core": "7.3.0",
|
||||||
@@ -142,85 +142,85 @@
|
|||||||
"valtio": "$valtio",
|
"valtio": "$valtio",
|
||||||
"valtio-reactive": "$valtio-reactive",
|
"valtio-reactive": "$valtio-reactive",
|
||||||
"fzf": "$fzf",
|
"fzf": "$fzf",
|
||||||
"@vaadin/a11y-base": "24.8.3",
|
"@vaadin/a11y-base": "24.9.0",
|
||||||
"@vaadin/accordion": "24.8.3",
|
"@vaadin/accordion": "24.9.0",
|
||||||
"@vaadin/app-layout": "24.8.3",
|
"@vaadin/app-layout": "24.9.0",
|
||||||
"@vaadin/avatar": "24.8.3",
|
"@vaadin/avatar": "24.9.0",
|
||||||
"@vaadin/avatar-group": "24.8.3",
|
"@vaadin/avatar-group": "24.9.0",
|
||||||
"@vaadin/button": "24.8.3",
|
"@vaadin/button": "24.9.0",
|
||||||
"@vaadin/card": "24.8.3",
|
"@vaadin/card": "24.9.0",
|
||||||
"@vaadin/checkbox": "24.8.3",
|
"@vaadin/checkbox": "24.9.0",
|
||||||
"@vaadin/checkbox-group": "24.8.3",
|
"@vaadin/checkbox-group": "24.9.0",
|
||||||
"@vaadin/combo-box": "24.8.3",
|
"@vaadin/combo-box": "24.9.0",
|
||||||
"@vaadin/component-base": "24.8.3",
|
"@vaadin/component-base": "24.9.0",
|
||||||
"@vaadin/confirm-dialog": "24.8.3",
|
"@vaadin/confirm-dialog": "24.9.0",
|
||||||
"@vaadin/context-menu": "24.8.3",
|
"@vaadin/context-menu": "24.9.0",
|
||||||
"@vaadin/custom-field": "24.8.3",
|
"@vaadin/custom-field": "24.9.0",
|
||||||
"@vaadin/date-picker": "24.8.3",
|
"@vaadin/date-picker": "24.9.0",
|
||||||
"@vaadin/date-time-picker": "24.8.3",
|
"@vaadin/date-time-picker": "24.9.0",
|
||||||
"@vaadin/details": "24.8.3",
|
"@vaadin/details": "24.9.0",
|
||||||
"@vaadin/dialog": "24.8.3",
|
"@vaadin/dialog": "24.9.0",
|
||||||
"@vaadin/email-field": "24.8.3",
|
"@vaadin/email-field": "24.9.0",
|
||||||
"@vaadin/field-base": "24.8.3",
|
"@vaadin/field-base": "24.9.0",
|
||||||
"@vaadin/field-highlighter": "24.8.3",
|
"@vaadin/field-highlighter": "24.9.0",
|
||||||
"@vaadin/form-layout": "24.8.3",
|
"@vaadin/form-layout": "24.9.0",
|
||||||
"@vaadin/grid": "24.8.3",
|
"@vaadin/grid": "24.9.0",
|
||||||
"@vaadin/horizontal-layout": "24.8.3",
|
"@vaadin/horizontal-layout": "24.9.0",
|
||||||
"@vaadin/icon": "24.8.3",
|
"@vaadin/icon": "24.9.0",
|
||||||
"@vaadin/icons": "24.8.3",
|
"@vaadin/icons": "24.9.0",
|
||||||
"@vaadin/input-container": "24.8.3",
|
"@vaadin/input-container": "24.9.0",
|
||||||
"@vaadin/integer-field": "24.8.3",
|
"@vaadin/integer-field": "24.9.0",
|
||||||
"@vaadin/item": "24.8.3",
|
"@vaadin/item": "24.9.0",
|
||||||
"@vaadin/list-box": "24.8.3",
|
"@vaadin/list-box": "24.9.0",
|
||||||
"@vaadin/lit-renderer": "24.8.3",
|
"@vaadin/lit-renderer": "24.9.0",
|
||||||
"@vaadin/login": "24.8.3",
|
"@vaadin/login": "24.9.0",
|
||||||
"@vaadin/markdown": "24.8.3",
|
"@vaadin/markdown": "24.9.0",
|
||||||
"@vaadin/master-detail-layout": "24.8.3",
|
"@vaadin/master-detail-layout": "24.9.0",
|
||||||
"@vaadin/menu-bar": "24.8.3",
|
"@vaadin/menu-bar": "24.9.0",
|
||||||
"@vaadin/message-input": "24.8.3",
|
"@vaadin/message-input": "24.9.0",
|
||||||
"@vaadin/message-list": "24.8.3",
|
"@vaadin/message-list": "24.9.0",
|
||||||
"@vaadin/multi-select-combo-box": "24.8.3",
|
"@vaadin/multi-select-combo-box": "24.9.0",
|
||||||
"@vaadin/notification": "24.8.3",
|
"@vaadin/notification": "24.9.0",
|
||||||
"@vaadin/number-field": "24.8.3",
|
"@vaadin/number-field": "24.9.0",
|
||||||
"@vaadin/overlay": "24.8.3",
|
"@vaadin/overlay": "24.9.0",
|
||||||
"@vaadin/password-field": "24.8.3",
|
"@vaadin/password-field": "24.9.0",
|
||||||
"@vaadin/popover": "24.8.3",
|
"@vaadin/popover": "24.9.0",
|
||||||
"@vaadin/progress-bar": "24.8.3",
|
"@vaadin/progress-bar": "24.9.0",
|
||||||
"@vaadin/radio-group": "24.8.3",
|
"@vaadin/radio-group": "24.9.0",
|
||||||
"@vaadin/scroller": "24.8.3",
|
"@vaadin/scroller": "24.9.0",
|
||||||
"@vaadin/select": "24.8.3",
|
"@vaadin/select": "24.9.0",
|
||||||
"@vaadin/side-nav": "24.8.3",
|
"@vaadin/side-nav": "24.9.0",
|
||||||
"@vaadin/split-layout": "24.8.3",
|
"@vaadin/split-layout": "24.9.0",
|
||||||
"@vaadin/tabs": "24.8.3",
|
"@vaadin/tabs": "24.9.0",
|
||||||
"@vaadin/tabsheet": "24.8.3",
|
"@vaadin/tabsheet": "24.9.0",
|
||||||
"@vaadin/text-area": "24.8.3",
|
"@vaadin/text-area": "24.9.0",
|
||||||
"@vaadin/text-field": "24.8.3",
|
"@vaadin/text-field": "24.9.0",
|
||||||
"@vaadin/time-picker": "24.8.3",
|
"@vaadin/time-picker": "24.9.0",
|
||||||
"@vaadin/tooltip": "24.8.3",
|
"@vaadin/tooltip": "24.9.0",
|
||||||
"@vaadin/upload": "24.8.3",
|
"@vaadin/upload": "24.9.0",
|
||||||
"@vaadin/router": "2.0.0",
|
"@vaadin/router": "2.0.0",
|
||||||
"@vaadin/vertical-layout": "24.8.3",
|
"@vaadin/vertical-layout": "24.9.0",
|
||||||
"@vaadin/virtual-list": "24.8.3"
|
"@vaadin/virtual-list": "24.9.0"
|
||||||
},
|
},
|
||||||
"vaadin": {
|
"vaadin": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@polymer/polymer": "3.5.2",
|
"@polymer/polymer": "3.5.2",
|
||||||
"@vaadin/bundles": "24.8.3",
|
"@vaadin/bundles": "24.9.0",
|
||||||
"@vaadin/common-frontend": "0.0.19",
|
"@vaadin/common-frontend": "0.0.19",
|
||||||
"@vaadin/hilla-file-router": "24.8.2",
|
"@vaadin/hilla-file-router": "24.9.0",
|
||||||
"@vaadin/hilla-frontend": "24.8.2",
|
"@vaadin/hilla-frontend": "24.9.0",
|
||||||
"@vaadin/hilla-lit-form": "24.8.2",
|
"@vaadin/hilla-lit-form": "24.9.0",
|
||||||
"@vaadin/hilla-react-auth": "24.8.2",
|
"@vaadin/hilla-react-auth": "24.9.0",
|
||||||
"@vaadin/hilla-react-crud": "24.8.2",
|
"@vaadin/hilla-react-crud": "24.9.0",
|
||||||
"@vaadin/hilla-react-form": "24.8.2",
|
"@vaadin/hilla-react-form": "24.9.0",
|
||||||
"@vaadin/hilla-react-i18n": "24.8.2",
|
"@vaadin/hilla-react-i18n": "24.9.0",
|
||||||
"@vaadin/hilla-react-signals": "24.8.2",
|
"@vaadin/hilla-react-signals": "24.9.0",
|
||||||
"@vaadin/polymer-legacy-adapter": "24.8.3",
|
"@vaadin/polymer-legacy-adapter": "24.9.0",
|
||||||
"@vaadin/react-components": "24.8.3",
|
"@vaadin/react-components": "24.9.0",
|
||||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||||
"@vaadin/vaadin-lumo-styles": "24.8.3",
|
"@vaadin/vaadin-lumo-styles": "24.9.0",
|
||||||
"@vaadin/vaadin-material-styles": "24.8.3",
|
"@vaadin/vaadin-material-styles": "24.9.0",
|
||||||
"@vaadin/vaadin-themable-mixin": "24.8.3",
|
"@vaadin/vaadin-themable-mixin": "24.9.0",
|
||||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||||
"construct-style-sheets-polyfill": "3.1.0",
|
"construct-style-sheets-polyfill": "3.1.0",
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
@@ -236,17 +236,17 @@
|
|||||||
"@rollup/pluginutils": "5.1.4",
|
"@rollup/pluginutils": "5.1.4",
|
||||||
"@types/react": "18.3.23",
|
"@types/react": "18.3.23",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "18.3.7",
|
||||||
"@vaadin/hilla-generator-cli": "24.8.2",
|
"@vaadin/hilla-generator-cli": "24.9.0",
|
||||||
"@vaadin/hilla-generator-core": "24.8.2",
|
"@vaadin/hilla-generator-core": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-backbone": "24.8.2",
|
"@vaadin/hilla-generator-plugin-backbone": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-barrel": "24.8.2",
|
"@vaadin/hilla-generator-plugin-barrel": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-client": "24.8.2",
|
"@vaadin/hilla-generator-plugin-client": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-model": "24.8.2",
|
"@vaadin/hilla-generator-plugin-model": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-push": "24.8.2",
|
"@vaadin/hilla-generator-plugin-push": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-signals": "24.8.2",
|
"@vaadin/hilla-generator-plugin-signals": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-subtypes": "24.8.2",
|
"@vaadin/hilla-generator-plugin-subtypes": "24.9.0",
|
||||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.2",
|
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.0",
|
||||||
"@vaadin/hilla-generator-utils": "24.8.2",
|
"@vaadin/hilla-generator-utils": "24.9.0",
|
||||||
"@vitejs/plugin-react": "4.5.0",
|
"@vitejs/plugin-react": "4.5.0",
|
||||||
"async": "3.2.6",
|
"async": "3.2.6",
|
||||||
"glob": "11.0.2",
|
"glob": "11.0.2",
|
||||||
@@ -256,13 +256,13 @@
|
|||||||
"strip-css-comments": "5.0.0",
|
"strip-css-comments": "5.0.0",
|
||||||
"transform-ast": "2.4.4",
|
"transform-ast": "2.4.4",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "6.3.5",
|
"vite": "6.3.6",
|
||||||
"vite-plugin-checker": "0.9.3",
|
"vite-plugin-checker": "0.9.3",
|
||||||
"workbox-build": "7.3.0",
|
"workbox-build": "7.3.0",
|
||||||
"workbox-core": "7.3.0",
|
"workbox-core": "7.3.0",
|
||||||
"workbox-precaching": "7.3.0"
|
"workbox-precaching": "7.3.0"
|
||||||
},
|
},
|
||||||
"disableUsageStatistics": true,
|
"disableUsageStatistics": true,
|
||||||
"hash": "962eccc3fa0735d5234901be4f9e384096113c45bec22564a53688096d62aef4"
|
"hash": "dba97848bdace60924f9cee496353baae70cfa4fccc7bacaf827807c51908866"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import {initializePluginState} from "Frontend/state/PluginState";
|
|||||||
import {isAdmin} from "Frontend/util/utils";
|
import {isAdmin} from "Frontend/util/utils";
|
||||||
import {useRouteMetadata} from "Frontend/util/routing";
|
import {useRouteMetadata} from "Frontend/util/routing";
|
||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
|
import {initializeGameRequestState} from "Frontend/state/GameRequestState";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
client.middlewares = [ErrorHandlingMiddleware];
|
client.middlewares = [ErrorHandlingMiddleware];
|
||||||
@@ -45,10 +46,11 @@ function ViewWithAuth() {
|
|||||||
|
|
||||||
initializeLibraryState();
|
initializeLibraryState();
|
||||||
initializeGameState();
|
initializeGameState();
|
||||||
|
initializeGameRequestState();
|
||||||
|
initializePluginState();
|
||||||
|
|
||||||
if (isAdmin(auth)) {
|
if (isAdmin(auth)) {
|
||||||
initializeScanState();
|
initializeScanState();
|
||||||
initializePluginState();
|
|
||||||
}
|
}
|
||||||
}, [auth]);
|
}, [auth]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {useAuth} from "Frontend/util/auth";
|
|||||||
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
|
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
|
||||||
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react";
|
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react";
|
||||||
import {useNavigate} from "react-router";
|
import {useNavigate} from "react-router";
|
||||||
import {ConfigEndpoint} from "Frontend/generated/endpoints";
|
|
||||||
import Avatar from "Frontend/components/general/Avatar";
|
import Avatar from "Frontend/components/general/Avatar";
|
||||||
import {CollectionElement} from "@react-types/shared";
|
import {CollectionElement} from "@react-types/shared";
|
||||||
import {isAdmin} from "Frontend/util/utils";
|
import {isAdmin} from "Frontend/util/utils";
|
||||||
@@ -11,14 +10,6 @@ export default function ProfileMenu() {
|
|||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
if (auth.state.user?.managedBySso) {
|
|
||||||
window.location.href = (await ConfigEndpoint.getSsoLogoutUrl()) || "/";
|
|
||||||
} else {
|
|
||||||
await auth.logout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileMenuItems = [
|
const profileMenuItems = [
|
||||||
{
|
{
|
||||||
label: "My Profile",
|
label: "My Profile",
|
||||||
@@ -39,7 +30,7 @@ export default function ProfileMenu() {
|
|||||||
{
|
{
|
||||||
label: "Sign Out",
|
label: "Sign Out",
|
||||||
icon: <SignOut/>,
|
icon: <SignOut/>,
|
||||||
onClick: logout,
|
onClick: auth.logout,
|
||||||
color: "primary"
|
color: "primary"
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from "react";
|
||||||
|
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||||
|
import Section from "Frontend/components/general/Section";
|
||||||
|
import {Button} from "@heroui/react";
|
||||||
|
import {useNavigate} from "react-router";
|
||||||
|
|
||||||
|
function GameRequestManagementLayout({getConfig, formik}: any) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<Section title="Game requests configuration"/>
|
||||||
|
<ConfigFormField configElement={getConfig("requests.games.enabled")}/>
|
||||||
|
|
||||||
|
<Section title="Permissions"/>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<ConfigFormField
|
||||||
|
configElement={getConfig("requests.games.allow-guests-to-request-games")}
|
||||||
|
isDisabled={!formik.values.library["allow-public-access"]}/>
|
||||||
|
<ConfigFormField configElement={getConfig("requests.games.max-open-requests-per-user")}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onPress={() => navigate("/requests")}>
|
||||||
|
Manage game requests
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationSchema = Yup.object({
|
||||||
|
requests: Yup.object({
|
||||||
|
games: Yup.object({
|
||||||
|
enabled: Yup.boolean().required("Required"),
|
||||||
|
"allow-guests-to-request-games": Yup.boolean().required("Required"),
|
||||||
|
"max-open-requests-per-user": Yup.number()
|
||||||
|
.min(0, "Must be at least 0")
|
||||||
|
.max(Number.MAX_SAFE_INTEGER, `Must be lower than ${Number.MAX_SAFE_INTEGER}`)
|
||||||
|
.required("Required"),
|
||||||
|
}).required("Required"),
|
||||||
|
}).required("Required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GameRequestManagement = withConfigPage(GameRequestManagementLayout, "Game Requests", validationSchema);
|
||||||
@@ -91,7 +91,7 @@ export default function ProfileManagement() {
|
|||||||
{formik.values.newPassword.length > 0 &&
|
{formik.values.newPassword.length > 0 &&
|
||||||
<SmallInfoField icon={Info}
|
<SmallInfoField icon={Info}
|
||||||
message="You will be logged out of all current sessions"
|
message="You will be logged out of all current sessions"
|
||||||
className="text-foreground/70"
|
className="text-default-500"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {SystemEndpoint} from "Frontend/generated/endpoints";
|
import {SystemEndpoint} from "Frontend/generated/endpoints";
|
||||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||||
import {Button} from "@heroui/react";
|
import {addToast, Button} from "@heroui/react";
|
||||||
import Section from "Frontend/components/general/Section";
|
import Section from "Frontend/components/general/Section";
|
||||||
|
|
||||||
function SystemManagementLayout() {
|
function SystemManagementLayout() {
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
SystemEndpoint.restart().then(() =>
|
||||||
|
addToast({
|
||||||
|
title: "Restarting",
|
||||||
|
description: "Gameyfin is restarting. This may take a few moments.",
|
||||||
|
color: "success"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col mt-4">
|
<div className="flex flex-col mt-4">
|
||||||
<Section title="Restart Gameyfin"/>
|
<Section title="Restart Gameyfin"/>
|
||||||
<Button onPress={() => SystemEndpoint.restart()}>Restart</Button>
|
<Button onPress={restart}>Restart</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import ConfigFormField from "Frontend/components/administration/ConfigFormField"
|
|||||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||||
import Section from "Frontend/components/general/Section";
|
import Section from "Frontend/components/general/Section";
|
||||||
import {UserEndpoint} from "Frontend/generated/endpoints";
|
import {UserEndpoint} from "Frontend/generated/endpoints";
|
||||||
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
|
|
||||||
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
|
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
|
||||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||||
import {Info, UserPlus} from "@phosphor-icons/react";
|
import {Info, UserPlus} from "@phosphor-icons/react";
|
||||||
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
||||||
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
|
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
|
||||||
|
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
||||||
|
|
||||||
function UserManagementLayout({getConfig, formik}: any) {
|
function UserManagementLayout({getConfig, formik}: any) {
|
||||||
const inviteUserModal = useDisclosure();
|
const inviteUserModal = useDisclosure();
|
||||||
const [users, setUsers] = useState<UserInfoDto[]>([]);
|
const [users, setUsers] = useState<ExtendedUserInfoDto[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
UserEndpoint.getAllUsers().then(
|
UserEndpoint.getAllUsers().then(
|
||||||
|
|||||||
@@ -15,13 +15,22 @@ const Avatar = ({...props}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Check if avatar can be loaded from SSO
|
// TODO: Check if avatar can be loaded from SSO
|
||||||
return (
|
if (auth.state.user?.hasAvatar) {
|
||||||
<NextUiAvatar
|
return (
|
||||||
showFallback
|
<NextUiAvatar
|
||||||
src={`/images/avatar?username=${username}`}
|
showFallback
|
||||||
{...props}
|
src={`/images/avatar?username=${username}`}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<NextUiAvatar
|
||||||
|
showFallback
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Avatar;
|
export default Avatar;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||||
|
import {useMemo} from "react";
|
||||||
|
import {CircularProgress} from "@heroui/react";
|
||||||
|
import {metadataCompleteness} from "Frontend/util/utils";
|
||||||
|
|
||||||
|
interface MetadataCompletenessIndicatorProps {
|
||||||
|
game: GameDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetadataCompletenessIndicator({game}: MetadataCompletenessIndicatorProps) {
|
||||||
|
const completeness = useMemo(() => metadataCompleteness(game), [game]);
|
||||||
|
|
||||||
|
const color = useMemo(() => {
|
||||||
|
return completeness > 80 ? "success" : completeness > 50 ? "warning" : "danger";
|
||||||
|
}, [completeness]);
|
||||||
|
|
||||||
|
return <div className="flex flex-row items-center gap-1">
|
||||||
|
<CircularProgress
|
||||||
|
color={color}
|
||||||
|
value={completeness}
|
||||||
|
disableAnimation
|
||||||
|
size="sm"
|
||||||
|
strokeWidth={5}
|
||||||
|
/>
|
||||||
|
<p>{completeness}% </p>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -7,11 +7,11 @@ import Avatar from "Frontend/components/general/Avatar";
|
|||||||
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
|
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
|
||||||
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
|
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
|
||||||
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
|
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
|
||||||
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
|
|
||||||
import RoleChip from "Frontend/components/general/RoleChip";
|
import RoleChip from "Frontend/components/general/RoleChip";
|
||||||
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
|
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
|
||||||
|
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
||||||
|
|
||||||
export function UserManagementCard({user}: { user: UserInfoDto }) {
|
export function UserManagementCard({user}: { user: ExtendedUserInfoDto }) {
|
||||||
const userDeletionConfirmationModal = useDisclosure();
|
const userDeletionConfirmationModal = useDisclosure();
|
||||||
const passwordResetTokenModal = useDisclosure();
|
const passwordResetTokenModal = useDisclosure();
|
||||||
const roleAssignmentModal = useDisclosure();
|
const roleAssignmentModal = useDisclosure();
|
||||||
@@ -141,7 +141,7 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
|
|||||||
<p className="font-semibold">{user.username}</p>
|
<p className="font-semibold">{user.username}</p>
|
||||||
<p className="text-sm max-w-44 truncate" title={user.email}>{user.email}</p>
|
<p className="text-sm max-w-44 truncate" title={user.email}>{user.email}</p>
|
||||||
{user.roles?.map((role) => (
|
{user.roles?.map((role) => (
|
||||||
<RoleChip role={role as string}/>
|
<RoleChip key={role} role={role as string}/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ 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";
|
import {GameAdminDto} from "Frontend/dtos/GameDtos";
|
||||||
|
import MetadataCompletenessIndicator from "Frontend/components/general/MetadataCompletenessIndicator";
|
||||||
|
import {metadataCompleteness} from "Frontend/util/utils";
|
||||||
|
|
||||||
interface LibraryManagementGamesProps {
|
interface LibraryManagementGamesProps {
|
||||||
library: LibraryDto;
|
library: LibraryDto;
|
||||||
@@ -67,6 +69,9 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
case "downloadCount":
|
case "downloadCount":
|
||||||
cmp = a.metadata.downloadCount - b.metadata.downloadCount;
|
cmp = a.metadata.downloadCount - b.metadata.downloadCount;
|
||||||
break;
|
break;
|
||||||
|
case "completeness":
|
||||||
|
cmp = metadataCompleteness(a) - metadataCompleteness(b);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return 0; // No sorting if the column is not recognized
|
return 0; // No sorting if the column is not recognized
|
||||||
}
|
}
|
||||||
@@ -160,6 +165,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
|
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
|
||||||
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
|
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
|
||||||
<TableColumn>Path</TableColumn>
|
<TableColumn>Path</TableColumn>
|
||||||
|
<TableColumn key="completeness" allowsSorting>Completeness</TableColumn>
|
||||||
{/* width={1} keeps the column as far to the right as possible*/}
|
{/* width={1} keeps the column as far to the right as possible*/}
|
||||||
<TableColumn width={1}>Actions</TableColumn>
|
<TableColumn width={1}>Actions</TableColumn>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -182,6 +188,9 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{item.metadata.path}
|
{item.metadata.path}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<MetadataCompletenessIndicator game={item}/>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
|
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import {
|
|||||||
SelectItem
|
SelectItem
|
||||||
} from "@heroui/react";
|
} from "@heroui/react";
|
||||||
import {UserEndpoint} from "Frontend/generated/endpoints";
|
import {UserEndpoint} from "Frontend/generated/endpoints";
|
||||||
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
|
|
||||||
import RoleChip from "Frontend/components/general/RoleChip";
|
import RoleChip from "Frontend/components/general/RoleChip";
|
||||||
import RoleAssignmentResult from "Frontend/generated/org/gameyfin/app/users/enums/RoleAssignmentResult";
|
import RoleAssignmentResult from "Frontend/generated/org/gameyfin/app/users/enums/RoleAssignmentResult";
|
||||||
|
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
||||||
|
|
||||||
interface AssignRolesModalProps {
|
interface AssignRolesModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: () => void;
|
onOpenChange: () => void;
|
||||||
user: UserInfoDto;
|
user: ExtendedUserInfoDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Role {
|
interface Role {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
|||||||
<p className="text-center">No results found.</p>
|
<p className="text-center">No results found.</p>
|
||||||
}
|
}
|
||||||
{searchResults.length === 0 && isSearching &&
|
{searchResults.length === 0 && isSearching &&
|
||||||
<p className="text-center text-foreground/70">Searching...</p>
|
<p className="text-center text-default-500">Searching...</p>
|
||||||
}
|
}
|
||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
className="grid grid-cols-auto-fill gap-4 h-96 overflow-y-scroll justify-evenly">
|
className="grid grid-cols-auto-fill gap-4 h-96 overflow-y-scroll justify-evenly">
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
|||||||
<p className="text-center">No results found.</p>
|
<p className="text-center">No results found.</p>
|
||||||
}
|
}
|
||||||
{searchResults.length === 0 && isSearching &&
|
{searchResults.length === 0 && isSearching &&
|
||||||
<p className="text-center text-foreground/70">Searching...</p>
|
<p className="text-center text-default-500">Searching...</p>
|
||||||
}
|
}
|
||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
className="flex flex-col items-center gap-4 h-96 overflow-y-scroll">
|
className="flex flex-col items-center gap-4 h-96 overflow-y-scroll">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {useSnapshot} from "valtio/react";
|
|||||||
import {pluginState} from "Frontend/state/PluginState";
|
import {pluginState} from "Frontend/state/PluginState";
|
||||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||||
|
|
||||||
interface EditGameMetadataModalProps {
|
interface MatchGameModalProps {
|
||||||
path: string;
|
path: string;
|
||||||
libraryId: number;
|
libraryId: number;
|
||||||
replaceGameId?: number;
|
replaceGameId?: number;
|
||||||
@@ -37,7 +37,7 @@ export default function MatchGameModal({
|
|||||||
initialSearchTerm,
|
initialSearchTerm,
|
||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange
|
onOpenChange
|
||||||
}: EditGameMetadataModalProps) {
|
}: MatchGameModalProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import {
|
||||||
|
addToast,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
Tooltip
|
||||||
|
} from "@heroui/react";
|
||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||||
|
import {GameEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||||
|
import PluginIcon from "../plugin/PluginIcon";
|
||||||
|
import {useSnapshot} from "valtio/react";
|
||||||
|
import {pluginState} from "Frontend/state/PluginState";
|
||||||
|
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||||
|
import GameRequestCreationDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestCreationDto";
|
||||||
|
|
||||||
|
interface RequestGameModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RequestGameModal({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange
|
||||||
|
}: RequestGameModalProps) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isRequesting, setIsRequesting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const plugins = useSnapshot(pluginState).state;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchTerm("");
|
||||||
|
setSearchResults([]);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
async function requestGame(game: GameSearchResultDto) {
|
||||||
|
const request: GameRequestCreationDto = {
|
||||||
|
title: game.title,
|
||||||
|
release: game.release
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await GameRequestEndpoint.create(request);
|
||||||
|
|
||||||
|
addToast({
|
||||||
|
title: "Request submitted",
|
||||||
|
description: `Your request for "${game.title}" has been submitted.`,
|
||||||
|
color: "success"
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setIsSearching(false);
|
||||||
|
setIsRequesting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
setIsSearching(true);
|
||||||
|
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||||
|
setSearchResults(results);
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}
|
||||||
|
hideCloseButton
|
||||||
|
isDismissable={!isSearching && !isRequesting}
|
||||||
|
isKeyboardDismissDisabled={!isSearching && !isRequesting}
|
||||||
|
backdrop="opaque" size="5xl">
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<ModalBody className="my-4">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h2 className="text-xl font-semibold">Request a game</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2 mb-4">
|
||||||
|
<Input value={searchTerm}
|
||||||
|
onValueChange={setSearchTerm}
|
||||||
|
onKeyDown={async (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
await search();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||||
|
<MagnifyingGlass/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Table removeWrapper isStriped isHeaderSticky
|
||||||
|
classNames={{
|
||||||
|
base: "h-80 overflow-y-auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn>Title & Release</TableColumn>
|
||||||
|
<TableColumn>Developer(s)</TableColumn>
|
||||||
|
<TableColumn>Publisher(s)</TableColumn>
|
||||||
|
{/* width={1} keeps the column as far to the right as possible*/}
|
||||||
|
<TableColumn>Sources</TableColumn>
|
||||||
|
<TableColumn width={1}> </TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody emptyContent="Your search did not match any games." items={searchResults}>
|
||||||
|
{(item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{item.developers ? item.developers.map(
|
||||||
|
developer => <p>{developer}</p>
|
||||||
|
) : "unknown"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{item.publishers ? item.publishers.map(
|
||||||
|
publisher => <p>{publisher}</p>
|
||||||
|
) : "unknown"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
{Object.values(item.originalIds).map(
|
||||||
|
originalId => <PluginIcon
|
||||||
|
plugin={plugins[originalId.pluginId] as PluginDto}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip content="Pick this result">
|
||||||
|
<Button isIconOnly size="sm"
|
||||||
|
isDisabled={isRequesting !== null}
|
||||||
|
isLoading={isRequesting === item.id}
|
||||||
|
onPress={async () => {
|
||||||
|
setIsRequesting(item.id);
|
||||||
|
await requestGame(item);
|
||||||
|
setIsRequesting(null);
|
||||||
|
onClose();
|
||||||
|
}}>
|
||||||
|
<ArrowRight/>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ import RecentlyAddedView from "Frontend/views/RecentlyAddedView";
|
|||||||
import LibraryView from "Frontend/views/LibraryView";
|
import LibraryView from "Frontend/views/LibraryView";
|
||||||
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
|
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
|
||||||
import ErrorView from "Frontend/views/ErrorView";
|
import ErrorView from "Frontend/views/ErrorView";
|
||||||
|
import GameRequestView from "Frontend/views/GameRequestView";
|
||||||
|
import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement";
|
||||||
|
|
||||||
export const {router, routes} = new RouterConfigurationBuilder()
|
export const {router, routes} = new RouterConfigurationBuilder()
|
||||||
.withReactRoutes([
|
.withReactRoutes([
|
||||||
@@ -47,6 +49,11 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
|||||||
element: <RecentlyAddedView/>,
|
element: <RecentlyAddedView/>,
|
||||||
handle: {title: 'Recently Added'}
|
handle: {title: 'Recently Added'}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/requests',
|
||||||
|
element: <GameRequestView/>,
|
||||||
|
handle: {title: 'Game requests'}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'library/:libraryId',
|
path: 'library/:libraryId',
|
||||||
element: <LibraryView/>
|
element: <LibraryView/>
|
||||||
@@ -87,6 +94,11 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
|||||||
element: <LibraryManagementView/>,
|
element: <LibraryManagementView/>,
|
||||||
handle: {title: 'Administration - Library'}
|
handle: {title: 'Administration - Library'}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'requests',
|
||||||
|
element: <GameRequestManagement/>,
|
||||||
|
handle: {title: 'Administration - Game Requests'}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'users',
|
path: 'users',
|
||||||
element: <UserManagement/>,
|
element: <UserManagement/>,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {Subscription} from "@vaadin/hilla-frontend";
|
||||||
|
import {proxy} from "valtio/index";
|
||||||
|
import {GameRequestEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
import GameRequestEvent from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestEvent";
|
||||||
|
import GameRequestDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestDto";
|
||||||
|
|
||||||
|
type GameRequestState = {
|
||||||
|
subscription?: Subscription<GameRequestEvent[]>;
|
||||||
|
isLoaded: boolean;
|
||||||
|
state: Record<number, GameRequestDto>;
|
||||||
|
gameRequests: GameRequestDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gameRequestState = proxy<GameRequestState>({
|
||||||
|
get isLoaded() {
|
||||||
|
return this.subscription != null;
|
||||||
|
},
|
||||||
|
state: {},
|
||||||
|
get gameRequests() {
|
||||||
|
return Object.values<GameRequestDto>(this.state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Subscribe to and process state updates from backend **/
|
||||||
|
export async function initializeGameRequestState() {
|
||||||
|
if (gameRequestState.isLoaded) return;
|
||||||
|
|
||||||
|
// Fetch initial game request list
|
||||||
|
const initialEntries = await GameRequestEndpoint.getAll();
|
||||||
|
initialEntries.forEach((gameRequest: GameRequestDto) => {
|
||||||
|
gameRequestState.state[gameRequest.id] = gameRequest;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to real-time updates
|
||||||
|
gameRequestState.subscription = GameRequestEndpoint.subscribe().onNext((gameRequestEvents: GameRequestEvent[]) => {
|
||||||
|
gameRequestEvents.forEach((gameRequestEvent: GameRequestEvent) => {
|
||||||
|
switch (gameRequestEvent.type) {
|
||||||
|
case "created":
|
||||||
|
case "updated":
|
||||||
|
//@ts-ignore
|
||||||
|
gameRequestState.state[gameRequestEvent.gameRequest.id] = gameRequestEvent.gameRequest;
|
||||||
|
break;
|
||||||
|
case "deleted":
|
||||||
|
//@ts-ignore
|
||||||
|
delete gameRequestState.state[gameRequestEvent.gameRequestId];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {getCsrfToken} from "Frontend/util/auth";
|
import {getCsrfToken} from "Frontend/util/auth";
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
|
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||||
|
|
||||||
export function isAdmin(auth: any): boolean {
|
export function isAdmin(auth: any): boolean {
|
||||||
return auth.state.user?.roles?.some((a: string) => a?.includes("ADMIN"));
|
return auth.state.user?.roles?.some((a: string) => a?.includes("ADMIN"));
|
||||||
@@ -207,4 +208,79 @@ export function fileNameFromPath(path: string, includeExtension: boolean = true)
|
|||||||
}
|
}
|
||||||
const dotIndex = fileName.lastIndexOf('.');
|
const dotIndex = fileName.lastIndexOf('.');
|
||||||
return dotIndex < 0 ? fileName : fileName.substring(0, dotIndex);
|
return dotIndex < 0 ? fileName : fileName.substring(0, dotIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the completeness of a GameDto
|
||||||
|
* @param game
|
||||||
|
* @returns completeness percentage (0-100)
|
||||||
|
*/
|
||||||
|
export function metadataCompleteness(game: GameDto) {
|
||||||
|
// Total number of fields considered for completeness
|
||||||
|
// Includes all fields except "comment"
|
||||||
|
const totalFields = 21;
|
||||||
|
|
||||||
|
const filledFields = Object.values(game).filter(value => {
|
||||||
|
if (value === null || value === undefined) return false;
|
||||||
|
if (Array.isArray(value)) return value.length > 0;
|
||||||
|
if (typeof value === "string") return value.trim().length > 0;
|
||||||
|
return true;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return Math.round((filledFields / totalFields) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale a number from one range to another
|
||||||
|
* @param value The number to scale
|
||||||
|
* @param originalRange The original range [min, max]
|
||||||
|
* @param targetRange The target range [min, max]
|
||||||
|
* @returns The scaled number
|
||||||
|
*/
|
||||||
|
function convertRange(value: number, originalRange: number[], targetRange: number[]): number {
|
||||||
|
if (originalRange[0] === targetRange[0] && originalRange[1] === targetRange[1]) return value;
|
||||||
|
return (value - originalRange[0]) * (targetRange[1] - targetRange[0]) / (originalRange[1] - originalRange[0]) + targetRange[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate a compound rating for a GameDto based on its criticRating and userRating.
|
||||||
|
* If both ratings are present, a weighted average is calculated (40% critic, 60% user).
|
||||||
|
* If only one rating is present, that rating is returned.
|
||||||
|
* If neither rating is present, 0 is returned.
|
||||||
|
* @param game The GameDto object containing the ratings.
|
||||||
|
* @param scale The scale to convert the rating to (default is [0, 100]).
|
||||||
|
* @returns The compound rating.
|
||||||
|
*/
|
||||||
|
export function compoundRating(game: GameDto, scale = [0, 100]): number {
|
||||||
|
const weights = {
|
||||||
|
critic: 0.4,
|
||||||
|
user: 0.6
|
||||||
|
};
|
||||||
|
const originalRange = [0, 100];
|
||||||
|
|
||||||
|
const criticRating = game.criticRating ?? 0;
|
||||||
|
const userRating = game.userRating ?? 0;
|
||||||
|
|
||||||
|
if (criticRating === 0 && userRating === 0) return 0;
|
||||||
|
if (criticRating === 0) return convertRange(userRating, originalRange, scale);
|
||||||
|
if (userRating === 0) return convertRange(criticRating, originalRange, scale);
|
||||||
|
|
||||||
|
const avgRating = Math.round((criticRating * weights.critic + userRating * weights.user) * 10) / 10;
|
||||||
|
return convertRange(avgRating, originalRange, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a GameDto's ratings to a star rating out of 5.
|
||||||
|
* If both criticRating and userRating are present, their average is taken.
|
||||||
|
* If neither is present, "N/A" is returned.
|
||||||
|
* @param game The GameDto object containing the ratings.
|
||||||
|
* @returns A string representing the star rating out of 5, or "N/A" if no ratings are available.
|
||||||
|
*/
|
||||||
|
export function starRatingAsString(game: GameDto) {
|
||||||
|
const starRange = [1, 5];
|
||||||
|
|
||||||
|
const rating = compoundRating(game, starRange);
|
||||||
|
if (rating === 0) return "N/A";
|
||||||
|
|
||||||
|
return rating.toFixed(1);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Envelope, GameController, LockKey, Log, Plug, Users, Wrench} from "@phosphor-icons/react";
|
import {Disc, Envelope, GameController, LockKey, Log, Plug, Users, Wrench} from "@phosphor-icons/react";
|
||||||
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
|
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
@@ -7,6 +7,11 @@ const menuItems: MenuItem[] = [
|
|||||||
url: "libraries",
|
url: "libraries",
|
||||||
icon: <GameController/>
|
icon: <GameController/>
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Game Requests",
|
||||||
|
url: "requests",
|
||||||
|
icon: <Disc/>
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Users",
|
title: "Users",
|
||||||
url: "users",
|
url: "users",
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
SortDescriptor,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
|
useDisclosure
|
||||||
|
} from "@heroui/react";
|
||||||
|
import RequestGameModal from "Frontend/components/general/modals/RequestGameModal";
|
||||||
|
import {ArrowUp, Check, Info, PlusCircle, Trash, X} from "@phosphor-icons/react";
|
||||||
|
import React, {useEffect, useMemo, useState} from "react";
|
||||||
|
import {useAuth} from "Frontend/util/auth";
|
||||||
|
import {ConfigEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
import {gameRequestState} from "Frontend/state/GameRequestState";
|
||||||
|
import {useSnapshot} from "valtio/react";
|
||||||
|
import GameRequestDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestDto";
|
||||||
|
import GameRequestStatus from "Frontend/generated/org/gameyfin/app/requests/status/GameRequestStatus";
|
||||||
|
import {isAdmin} from "Frontend/util/utils";
|
||||||
|
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||||
|
|
||||||
|
export default function GameRequestView() {
|
||||||
|
const rowsPerPage = 25;
|
||||||
|
|
||||||
|
const auth = useAuth();
|
||||||
|
const requestGameModal = useDisclosure();
|
||||||
|
const gameRequests = useSnapshot(gameRequestState).gameRequests;
|
||||||
|
|
||||||
|
const [areGameRequestsEnabled, setAreGameRequestsEnabled] = useState(false);
|
||||||
|
const [areGuestsAllowedToRequestGames, setAreGuestsAllowedToRequestGames] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ConfigEndpoint.areGameRequestsEnabled().then(setAreGameRequestsEnabled);
|
||||||
|
ConfigEndpoint.areGuestsAllowedToRequestGames().then(setAreGuestsAllowedToRequestGames);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [filters, setFilters] = useState<"all" | GameRequestStatus[]>([GameRequestStatus.PENDING, GameRequestStatus.APPROVED, GameRequestStatus.REJECTED]);
|
||||||
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "votes", direction: "descending"});
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pages = useMemo(() => {
|
||||||
|
return Math.ceil(getFilteredRequests().length / rowsPerPage);
|
||||||
|
}, [gameRequests, filters]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
return getFilteredRequests();
|
||||||
|
}, [gameRequests, filters, searchTerm]);
|
||||||
|
|
||||||
|
const sortedItems = useMemo(() => {
|
||||||
|
return (filteredItems as GameRequestDto[]).slice().sort((a, b) => {
|
||||||
|
let cmp: number;
|
||||||
|
|
||||||
|
switch (sortDescriptor.column) {
|
||||||
|
case "title":
|
||||||
|
cmp = a.title.localeCompare(b.title);
|
||||||
|
break;
|
||||||
|
case "votes":
|
||||||
|
cmp = a.voters.length - b.voters.length;
|
||||||
|
if (cmp === 0) {
|
||||||
|
// If votes are equal, sort by creation date (newest first)
|
||||||
|
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
const statusOrder = {
|
||||||
|
[GameRequestStatus.PENDING]: 1,
|
||||||
|
[GameRequestStatus.APPROVED]: 2,
|
||||||
|
[GameRequestStatus.REJECTED]: 3,
|
||||||
|
[GameRequestStatus.FULFILLED]: 4
|
||||||
|
};
|
||||||
|
cmp = (statusOrder[a.status] || 99) - (statusOrder[b.status] || 99);
|
||||||
|
break;
|
||||||
|
case "createdAt":
|
||||||
|
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||||
|
break;
|
||||||
|
case "updatedAt":
|
||||||
|
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return 0; // No sorting if the column is not recognized
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDescriptor.direction === "descending") {
|
||||||
|
cmp *= -1; // Reverse the comparison if sorting in descending order
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmp;
|
||||||
|
});
|
||||||
|
}, [filteredItems, sortDescriptor]);
|
||||||
|
|
||||||
|
const pagedItems = useMemo(() => {
|
||||||
|
const start = (page - 1) * rowsPerPage;
|
||||||
|
const end = start + rowsPerPage;
|
||||||
|
return sortedItems.slice(start, end);
|
||||||
|
}, [page, sortedItems]);
|
||||||
|
|
||||||
|
function getFilteredRequests() {
|
||||||
|
let filteredRequests = (gameRequests as GameRequestDto[]).filter((gameRequest) => {
|
||||||
|
return gameRequest.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(gameRequest.requester && gameRequest.requester.username.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
|
});
|
||||||
|
|
||||||
|
filteredRequests = filteredRequests.filter((gameRequest) => {
|
||||||
|
return filters.includes(gameRequest.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleVote(gameRequestId: number) {
|
||||||
|
await GameRequestEndpoint.toggleVote(gameRequestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleApprove(gameRequest: GameRequestDto) {
|
||||||
|
if (gameRequest.status == GameRequestStatus.FULFILLED) return;
|
||||||
|
const newStatus = gameRequest.status === GameRequestStatus.APPROVED ? GameRequestStatus.PENDING : GameRequestStatus.APPROVED;
|
||||||
|
await GameRequestEndpoint.changeStatus(gameRequest.id, newStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleReject(gameRequest: GameRequestDto) {
|
||||||
|
if (gameRequest.status == GameRequestStatus.FULFILLED) return;
|
||||||
|
const newStatus = gameRequest.status === GameRequestStatus.REJECTED ? GameRequestStatus.PENDING : GameRequestStatus.REJECTED;
|
||||||
|
await GameRequestEndpoint.changeStatus(gameRequest.id, newStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRequest(gameRequestId: number) {
|
||||||
|
await GameRequestEndpoint.delete(gameRequestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUserVotedForRequest(gameRequest: GameRequestDto): boolean {
|
||||||
|
if (!auth.state.user) return false;
|
||||||
|
return gameRequest.voters.map(v => v.id).includes(auth.state.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusToBadge(status: GameRequestStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case GameRequestStatus.APPROVED:
|
||||||
|
return <Chip size="sm" radius="sm"
|
||||||
|
className="text-xs bg-success-300 text-success-foreground">Approved</Chip>;
|
||||||
|
case GameRequestStatus.FULFILLED:
|
||||||
|
return <Chip size="sm" radius="sm" className="text-xs bg-success">Fulfilled</Chip>;
|
||||||
|
case GameRequestStatus.REJECTED:
|
||||||
|
return <Chip size="sm" radius="sm"
|
||||||
|
className="text-xs bg-danger-300 text-danger-foreground">Rejected</Chip>;
|
||||||
|
case GameRequestStatus.PENDING:
|
||||||
|
default:
|
||||||
|
return <Chip size="sm" radius="sm" className="text-xs">Pending</Chip>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (<>
|
||||||
|
<div className="flex flex-row justify-between mb-8">
|
||||||
|
<h1 className="text-2xl font-bold">Game Requests</h1>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
{!areGameRequestsEnabled &&
|
||||||
|
<SmallInfoField icon={Info}
|
||||||
|
message="Request submission is disabled"
|
||||||
|
className="text-default-500"/>
|
||||||
|
}
|
||||||
|
<Button className="w-fit"
|
||||||
|
color="primary"
|
||||||
|
startContent={<PlusCircle weight="fill"/>}
|
||||||
|
onPress={requestGameModal.onOpen}
|
||||||
|
isDisabled={!areGameRequestsEnabled || (!auth.state.user && !areGuestsAllowedToRequestGames)}>
|
||||||
|
Request a Game
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-2 justify-between mb-4">
|
||||||
|
<Input
|
||||||
|
className="w-96"
|
||||||
|
isClearable
|
||||||
|
placeholder="Search"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onClear={() => setSearchTerm("")}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
selectedKeys={filters}
|
||||||
|
onSelectionChange={keys => setFilters(Array.from(keys) as any)}
|
||||||
|
selectionMode="multiple"
|
||||||
|
className="w-64"
|
||||||
|
>
|
||||||
|
<SelectItem key={GameRequestStatus.PENDING}>Pending</SelectItem>
|
||||||
|
<SelectItem key={GameRequestStatus.APPROVED}>Approved</SelectItem>
|
||||||
|
<SelectItem key={GameRequestStatus.REJECTED}>Rejected</SelectItem>
|
||||||
|
<SelectItem key={GameRequestStatus.FULFILLED}>Fulfilled</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table removeWrapper isStriped
|
||||||
|
sortDescriptor={sortDescriptor}
|
||||||
|
onSortChange={setSortDescriptor}
|
||||||
|
bottomContent={
|
||||||
|
<div className="flex w-full justify-center sticky">
|
||||||
|
{pagedItems.length > 0 &&
|
||||||
|
<Pagination
|
||||||
|
isCompact
|
||||||
|
showControls
|
||||||
|
showShadow
|
||||||
|
color="primary"
|
||||||
|
page={page}
|
||||||
|
total={pages}
|
||||||
|
onChange={(page) => setPage(page)}
|
||||||
|
/>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn key="title" allowsSorting>Title & Release</TableColumn>
|
||||||
|
<TableColumn>Submitted by</TableColumn>
|
||||||
|
<TableColumn key="createdAt" allowsSorting>Submitted</TableColumn>
|
||||||
|
<TableColumn key="updatedAt" allowsSorting>Updated</TableColumn>
|
||||||
|
<TableColumn key="status" allowsSorting>Status</TableColumn>
|
||||||
|
{/* width={1} keeps the column as far to the right as possible*/}
|
||||||
|
<TableColumn key="votes" allowsSorting width={1}>Votes</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody emptyContent="Your search did not match any requests." items={pagedItems}>
|
||||||
|
{(item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<p className="text-default-500">
|
||||||
|
{item.requester ?
|
||||||
|
item.requester.username :
|
||||||
|
"Guest"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(item.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(item.updatedAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="min-w-24">
|
||||||
|
{statusToBadge(item.status)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<Tooltip
|
||||||
|
content={auth.state.user ? (item.status === GameRequestStatus.FULFILLED ? "You cannot vote on closed requests" : "Vote for this request") : "You must be logged in to vote"}
|
||||||
|
placement="left">
|
||||||
|
<div>
|
||||||
|
<Button size="sm"
|
||||||
|
variant={hasUserVotedForRequest(item as GameRequestDto) ? "solid" : "bordered"}
|
||||||
|
color={hasUserVotedForRequest(item as GameRequestDto) ? "primary" : "default"}
|
||||||
|
isDisabled={!auth.state.user || item.status === GameRequestStatus.FULFILLED}
|
||||||
|
startContent={<ArrowUp/>}
|
||||||
|
onPress={async () => await toggleVote(item.id)}>
|
||||||
|
{item.voters.length}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
{isAdmin(auth) && <div className="flex flex-row gap-2">
|
||||||
|
<Tooltip content="Approve this request">
|
||||||
|
<Button size="sm" isIconOnly
|
||||||
|
variant={item.status === GameRequestStatus.APPROVED ? "solid" : "bordered"}
|
||||||
|
color={item.status === GameRequestStatus.APPROVED ? "primary" : "default"}
|
||||||
|
isDisabled={item.status === GameRequestStatus.FULFILLED}
|
||||||
|
onPress={async () => await toggleApprove(item as GameRequestDto)}>
|
||||||
|
<Check/>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Reject this request">
|
||||||
|
<Button size="sm" isIconOnly
|
||||||
|
variant={item.status === GameRequestStatus.REJECTED ? "solid" : "bordered"}
|
||||||
|
color={item.status === GameRequestStatus.REJECTED ? "primary" : "default"}
|
||||||
|
isDisabled={item.status === GameRequestStatus.FULFILLED}
|
||||||
|
onPress={async () => await toggleReject(item as GameRequestDto)}>
|
||||||
|
<X/>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>}
|
||||||
|
{(isAdmin(auth) || (auth.state.user && item.requester && auth.state.user.id === item.requester.id)) &&
|
||||||
|
<Tooltip content="Delete this request">
|
||||||
|
<Button size="sm" isIconOnly
|
||||||
|
color="danger"
|
||||||
|
onPress={async () => await deleteRequest(item.id)}>
|
||||||
|
<Trash/>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<RequestGameModal isOpen={requestGameModal.isOpen}
|
||||||
|
onOpenChange={requestGameModal.onOpenChange}/>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
@@ -5,11 +5,11 @@ import {GameCover} from "Frontend/components/general/covers/GameCover";
|
|||||||
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
|
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
|
||||||
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
|
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
|
||||||
import {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, starRatingAsString, toTitleCase} from "Frontend/util/utils";
|
||||||
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
|
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
import {CheckCircle, Info, MagnifyingGlass, Pencil, Trash, TriangleDashed} from "@phosphor-icons/react";
|
import {CheckCircle, Info, MagnifyingGlass, Pencil, Star, 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";
|
||||||
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
|
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
|
||||||
@@ -102,14 +102,23 @@ export default function GameView() {
|
|||||||
<GameCover game={game} size={320} radius="none"/>
|
<GameCover game={game} size={320} radius="none"/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="font-semibold text-3xl">{game.title}</p>
|
<div className="flex flex-row gap-4 items-end">
|
||||||
|
<p className="font-semibold text-3xl">
|
||||||
|
{game.title}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-row gap-1 mb-0.5 text-default-500">
|
||||||
|
<Star weight="fill"/>
|
||||||
|
{starRatingAsString(game)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<p className="text-default-500">
|
<p className="text-default-500">
|
||||||
{game.release !== undefined ? new Date(game.release).getFullYear() :
|
{game.release !== undefined ? new Date(game.release).getFullYear() :
|
||||||
<p className="text-default-500">no data</p>}
|
<p className="text-default-500">no data</p>}
|
||||||
</p>
|
</p>
|
||||||
<Tooltip content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`}
|
<Tooltip
|
||||||
placement="right">
|
content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`}
|
||||||
|
placement="right">
|
||||||
<Info/>
|
<Info/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
|
|||||||
import * as PackageJson from "../../../../package.json";
|
import * as PackageJson from "../../../../package.json";
|
||||||
import {Outlet, useLocation, useNavigate} from "react-router";
|
import {Outlet, useLocation, useNavigate} from "react-router";
|
||||||
import {useAuth} from "Frontend/util/auth";
|
import {useAuth} from "Frontend/util/auth";
|
||||||
import {ArrowLeft, DiceSix, Heart, House, ListMagnifyingGlass, SignIn} from "@phosphor-icons/react";
|
import {ArrowLeft, DiceSix, Disc, Heart, House, ListMagnifyingGlass, SignIn} from "@phosphor-icons/react";
|
||||||
import Confetti, {ConfettiProps} from "react-confetti-boom";
|
import Confetti, {ConfettiProps} from "react-confetti-boom";
|
||||||
import {useTheme} from "next-themes";
|
import {useTheme} from "next-themes";
|
||||||
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
|
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
|
||||||
@@ -93,10 +93,24 @@ export default function MainLayout() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</NavbarContent>}
|
</NavbarContent>}
|
||||||
<NavbarContent justify="end">
|
<NavbarContent justify="end" className="items-center">
|
||||||
|
<NavbarItem>
|
||||||
|
<Tooltip content="Request a game" placement="bottom">
|
||||||
|
<Button color="primary"
|
||||||
|
isDisabled={window.location.pathname.startsWith("/requests")}
|
||||||
|
onPress={() => navigate("/requests")}
|
||||||
|
startContent={<Disc weight="fill"/>}>
|
||||||
|
Requests
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</NavbarItem>
|
||||||
{isAdmin(auth) &&
|
{isAdmin(auth) &&
|
||||||
<NavbarItem>
|
<NavbarItem>
|
||||||
<ScanProgressPopover/>
|
<Tooltip content="View library scan results" placement="bottom">
|
||||||
|
<div>
|
||||||
|
<ScanProgressPopover/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</NavbarItem>
|
</NavbarItem>
|
||||||
}
|
}
|
||||||
{auth.state.user &&
|
{auth.state.user &&
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import {Input, Select, SelectItem} from "@heroui/react";
|
import {Button, Input, Select, SelectedItems, SelectItem, Tooltip} from "@heroui/react";
|
||||||
import {MagnifyingGlass} from "@phosphor-icons/react";
|
import {FunnelSimple, FunnelSimpleX, MagnifyingGlass, SortAscending, Star} from "@phosphor-icons/react";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
import {libraryState} from "Frontend/state/LibraryState";
|
import {libraryState} from "Frontend/state/LibraryState";
|
||||||
import {useSearchParams} from "react-router";
|
import {useSearchParams} from "react-router";
|
||||||
import {useEffect, useMemo, useState} from "react";
|
import React, {useEffect, useMemo, useState} from "react";
|
||||||
import {Fzf} from "fzf";
|
import {Fzf} from "fzf";
|
||||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||||
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
|
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
|
||||||
import {toTitleCase} from "Frontend/util/utils";
|
import {compoundRating, toTitleCase} from "Frontend/util/utils";
|
||||||
|
|
||||||
export default function SearchView() {
|
export default function SearchView() {
|
||||||
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
|
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
|
||||||
@@ -24,6 +24,9 @@ export default function SearchView() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||||
|
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [sortBy, setSortBy] = useState("title_asc");
|
||||||
|
|
||||||
// State to track selected filter values
|
// State to track selected filter values
|
||||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
const [selectedLibraries, setSelectedLibraries] = useState<Set<string>>(new Set());
|
const [selectedLibraries, setSelectedLibraries] = useState<Set<string>>(new Set());
|
||||||
@@ -33,6 +36,7 @@ export default function SearchView() {
|
|||||||
const [selectedFeatures, setSelectedFeatures] = useState<Set<string>>(new Set());
|
const [selectedFeatures, setSelectedFeatures] = useState<Set<string>>(new Set());
|
||||||
const [selectedPerspectives, setSelectedPerspectives] = useState<Set<string>>(new Set());
|
const [selectedPerspectives, setSelectedPerspectives] = useState<Set<string>>(new Set());
|
||||||
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(new Set());
|
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(new Set());
|
||||||
|
const [minRating, setMinRating] = useState<number>(1); // Minimum rating filter
|
||||||
|
|
||||||
// Load initial filter values from URL parameters on component mount
|
// Load initial filter values from URL parameters on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,6 +49,9 @@ export default function SearchView() {
|
|||||||
const features = searchParams.getAll("feature");
|
const features = searchParams.getAll("feature");
|
||||||
const perspectives = searchParams.getAll("perspective");
|
const perspectives = searchParams.getAll("perspective");
|
||||||
const keywords = searchParams.getAll("keyword");
|
const keywords = searchParams.getAll("keyword");
|
||||||
|
const sort = searchParams.get("sort") || "title_asc";
|
||||||
|
const minRatingParam = parseInt(searchParams.get("minRating") || "1", 10);
|
||||||
|
const filtersParam = searchParams.get("filters");
|
||||||
|
|
||||||
setSearchTerm(term);
|
setSearchTerm(term);
|
||||||
setSelectedLibraries(new Set(libs));
|
setSelectedLibraries(new Set(libs));
|
||||||
@@ -54,11 +61,14 @@ export default function SearchView() {
|
|||||||
setSelectedFeatures(new Set(features));
|
setSelectedFeatures(new Set(features));
|
||||||
setSelectedPerspectives(new Set(perspectives));
|
setSelectedPerspectives(new Set(perspectives));
|
||||||
setSelectedKeywords(new Set(keywords));
|
setSelectedKeywords(new Set(keywords));
|
||||||
|
setSortBy(sort);
|
||||||
|
setMinRating(isNaN(minRatingParam) ? 1 : minRatingParam);
|
||||||
|
setShowFilters(filtersParam === "1");
|
||||||
|
|
||||||
setInitialLoadComplete(true);
|
setInitialLoadComplete(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update search parameters whenever the filters change
|
// Update search parameters whenever the filters or sort change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialLoadComplete) return;
|
if (!initialLoadComplete) return;
|
||||||
|
|
||||||
@@ -75,52 +85,93 @@ export default function SearchView() {
|
|||||||
newParams.append("lib", lib.toString());
|
newParams.append("lib", lib.toString());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedDevelopers.size > 0) {
|
if (selectedDevelopers.size > 0) {
|
||||||
selectedDevelopers.forEach(dev => {
|
selectedDevelopers.forEach(dev => {
|
||||||
newParams.append("dev", dev);
|
newParams.append("dev", dev);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedGenres.size > 0) {
|
if (selectedGenres.size > 0) {
|
||||||
selectedGenres.forEach(genre => {
|
selectedGenres.forEach(genre => {
|
||||||
newParams.append("genre", genre);
|
newParams.append("genre", genre);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedThemes.size > 0) {
|
if (selectedThemes.size > 0) {
|
||||||
selectedThemes.forEach(theme => {
|
selectedThemes.forEach(theme => {
|
||||||
newParams.append("theme", theme);
|
newParams.append("theme", theme);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedFeatures.size > 0) {
|
if (selectedFeatures.size > 0) {
|
||||||
selectedFeatures.forEach(feature => {
|
selectedFeatures.forEach(feature => {
|
||||||
newParams.append("feature", feature);
|
newParams.append("feature", feature);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedPerspectives.size > 0) {
|
if (selectedPerspectives.size > 0) {
|
||||||
selectedPerspectives.forEach(perspective => {
|
selectedPerspectives.forEach(perspective => {
|
||||||
newParams.append("perspective", perspective);
|
newParams.append("perspective", perspective);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedKeywords.size > 0) {
|
if (selectedKeywords.size > 0) {
|
||||||
selectedKeywords.forEach(keyword => {
|
selectedKeywords.forEach(keyword => {
|
||||||
newParams.append("keyword", keyword);
|
newParams.append("keyword", keyword);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Add minRating param if not default
|
||||||
|
if (minRating > 1) {
|
||||||
|
newParams.set("minRating", minRating.toString());
|
||||||
|
}
|
||||||
|
// Add sort param
|
||||||
|
if (sortBy && sortBy !== "title_asc") {
|
||||||
|
newParams.set("sort", sortBy);
|
||||||
|
}
|
||||||
|
// Add showFilters param
|
||||||
|
if (showFilters) {
|
||||||
|
newParams.set("filters", "1");
|
||||||
|
}
|
||||||
|
|
||||||
setSearchParams(newParams, {replace: true});
|
setSearchParams(newParams, {replace: true});
|
||||||
}, [searchTerm, selectedLibraries, selectedDevelopers, selectedGenres,
|
}, [searchTerm, selectedLibraries, selectedDevelopers, selectedGenres,
|
||||||
selectedThemes, selectedFeatures, selectedPerspectives, selectedKeywords]);
|
selectedThemes, selectedFeatures, selectedPerspectives, selectedKeywords, sortBy, minRating, showFilters]);
|
||||||
|
|
||||||
const filteredGames = useMemo(() => filterGames(), [
|
// Sorting function (refactored to use sortKey and sortDirection)
|
||||||
|
function sortGames(games: GameDto[]): GameDto[] {
|
||||||
|
if (!sortBy) return games;
|
||||||
|
|
||||||
|
const [sortKey, sortDirection] = sortBy.split("_");
|
||||||
|
|
||||||
|
return games.slice().sort((a, b) => {
|
||||||
|
let cmp: number;
|
||||||
|
|
||||||
|
switch (sortKey) {
|
||||||
|
case "title":
|
||||||
|
cmp = a.title.localeCompare(b.title);
|
||||||
|
break;
|
||||||
|
case "release":
|
||||||
|
cmp = (a.release || "").localeCompare(b.release || "");
|
||||||
|
break;
|
||||||
|
case "rating":
|
||||||
|
cmp = compoundRating(a) - compoundRating(b);
|
||||||
|
break;
|
||||||
|
case "added":
|
||||||
|
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||||
|
break;
|
||||||
|
case "updated":
|
||||||
|
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
cmp = 0;
|
||||||
|
}
|
||||||
|
if (sortDirection === "desc") {
|
||||||
|
cmp *= -1; // Reverse the comparison if sorting in descending order
|
||||||
|
}
|
||||||
|
return cmp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAndSortedGames = useMemo(() => sortGames(filterGames()), [
|
||||||
games, searchTerm,
|
games, searchTerm,
|
||||||
selectedLibraries, selectedDevelopers,
|
selectedLibraries, selectedDevelopers,
|
||||||
selectedGenres, selectedThemes,
|
selectedGenres, selectedThemes,
|
||||||
selectedFeatures, selectedPerspectives, selectedKeywords
|
selectedFeatures, selectedPerspectives, selectedKeywords, sortBy, minRating
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function filterGames(): GameDto[] {
|
function filterGames(): GameDto[] {
|
||||||
@@ -181,27 +232,99 @@ export default function SearchView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply minimum rating filter
|
||||||
|
if (minRating > 1) {
|
||||||
|
filtered = filtered.filter(game => {
|
||||||
|
const starRating = compoundRating(game, [1, 5]);
|
||||||
|
if (minRating === 5) {
|
||||||
|
return starRating > 4.5;
|
||||||
|
}
|
||||||
|
return starRating >= minRating;
|
||||||
|
});
|
||||||
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stars(filled: number, total: number = 5) {
|
||||||
|
const stars = [];
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
stars.push(
|
||||||
|
<Star key={i} weight={i < filled ? "fill" : "regular"} className="inline-block"/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className="flex flex-row">
|
||||||
|
{stars}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return <div className="flex flex-col gap-4 items-center w-full">
|
return <div className="flex flex-col gap-4 items-center w-full">
|
||||||
<Input
|
<div className="flex w-full justify-between px-12 gap-4 flex-col lg:flex-row">
|
||||||
classNames={{
|
<Input
|
||||||
base: "w-1/3",
|
classNames={{
|
||||||
mainWrapper: "h-full",
|
base: "w-full lg:w-96 flex-shrink-0",
|
||||||
inputWrapper:
|
mainWrapper: "h-full",
|
||||||
"h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20",
|
inputWrapper:
|
||||||
}}
|
"h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20",
|
||||||
placeholder="Type to search..."
|
}}
|
||||||
startContent={<MagnifyingGlass/>}
|
placeholder="Type to search..."
|
||||||
type="search"
|
startContent={<MagnifyingGlass/>}
|
||||||
value={searchTerm}
|
type="search"
|
||||||
isClearable
|
value={searchTerm}
|
||||||
onChange={(event) => setSearchTerm(event.target.value)}
|
isClearable
|
||||||
onClear={() => setSearchTerm("")}
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
/>
|
onClear={() => setSearchTerm("")}
|
||||||
<div
|
/>
|
||||||
className="w-full justify-center"
|
<div className="flex flex-row gap-2">
|
||||||
|
<Select
|
||||||
|
startContent={<SortAscending/>}
|
||||||
|
selectedKeys={[sortBy]}
|
||||||
|
disallowEmptySelection
|
||||||
|
selectionMode="single"
|
||||||
|
onSelectionChange={keys => setSortBy(Array.from(keys)[0] as any)}
|
||||||
|
className="w-full lg:w-64"
|
||||||
|
>
|
||||||
|
<SelectItem key="title_asc">Title (A-Z)</SelectItem>
|
||||||
|
<SelectItem key="title_desc">Title (Z-A)</SelectItem>
|
||||||
|
<SelectItem key="release_desc">Release Date (Newest)</SelectItem>
|
||||||
|
<SelectItem key="release_asc">Release Date (Oldest)</SelectItem>
|
||||||
|
<SelectItem key="rating_desc">Rating (Highest)</SelectItem>
|
||||||
|
<SelectItem key="rating_asc">Rating (Lowest)</SelectItem>
|
||||||
|
<SelectItem key="added_desc">Date Added (Newest)</SelectItem>
|
||||||
|
<SelectItem key="added_asc">Date Added (Oldest)</SelectItem>
|
||||||
|
<SelectItem key="updated_desc">Last Updated (Newest)</SelectItem>
|
||||||
|
<SelectItem key="updated_asc">Last Updated (Oldest)</SelectItem>
|
||||||
|
</Select>
|
||||||
|
<Tooltip content={showFilters ? "Hide Filters" : "Show Filters"}>
|
||||||
|
<Button isIconOnly
|
||||||
|
variant={showFilters ? "solid" : "bordered"}
|
||||||
|
color={showFilters ? "primary" : "default"}
|
||||||
|
onPress={() => setShowFilters(!showFilters)}
|
||||||
|
aria-label="Toggle Filters"
|
||||||
|
>
|
||||||
|
<FunnelSimple/>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Clear All Filters">
|
||||||
|
<Button isIconOnly
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedLibraries(new Set());
|
||||||
|
setSelectedDevelopers(new Set());
|
||||||
|
setSelectedGenres(new Set());
|
||||||
|
setSelectedThemes(new Set());
|
||||||
|
setSelectedFeatures(new Set());
|
||||||
|
setSelectedPerspectives(new Set());
|
||||||
|
setSelectedKeywords(new Set());
|
||||||
|
setMinRating(1);
|
||||||
|
}}
|
||||||
|
aria-label="Clear All Filters"
|
||||||
|
>
|
||||||
|
<FunnelSimpleX/>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showFilters && <div
|
||||||
|
className="w-full justify-center px-12"
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
|
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
|
||||||
@@ -222,6 +345,24 @@ export default function SearchView() {
|
|||||||
<SelectItem key={library.id}>{library.name}</SelectItem>
|
<SelectItem key={library.id}>{library.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
selectionMode="single"
|
||||||
|
label="Minimum Rating"
|
||||||
|
placeholder="Minimum rating"
|
||||||
|
disallowEmptySelection
|
||||||
|
selectedKeys={[minRating.toString()]}
|
||||||
|
onSelectionChange={keys => setMinRating(parseInt(Array.from(keys)[0] as string, 10))}
|
||||||
|
renderValue={(items: SelectedItems<any>) => {
|
||||||
|
return items.map((item) => stars(parseInt(item.key as string)));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key="1">{stars(1)}</SelectItem>
|
||||||
|
<SelectItem key="2">{stars(2)}</SelectItem>
|
||||||
|
<SelectItem key="3">{stars(3)}</SelectItem>
|
||||||
|
<SelectItem key="4">{stars(4)}</SelectItem>
|
||||||
|
<SelectItem key="5">{stars(5)}</SelectItem>
|
||||||
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
size="sm"
|
size="sm"
|
||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
@@ -301,9 +442,10 @@ export default function SearchView() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 w-full px-4 select-none">
|
}
|
||||||
<CoverGrid games={filteredGames}/>
|
<div className="mt-4 w-full select-none">
|
||||||
{filteredGames.length === 0 && (
|
<CoverGrid games={filteredAndSortedGames}/>
|
||||||
|
{filteredAndSortedGames.length === 0 && (
|
||||||
<div className="text-center mt-8 text-default-500">
|
<div className="text-center mt-8 text-default-500">
|
||||||
No games found matching your filters
|
No games found matching your filters
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,16 +43,12 @@ class ConfigEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Specific read-only endpoints for all users **/
|
/** Specific read-only endpoints for all users **/
|
||||||
|
@DynamicPublicAccess
|
||||||
|
@AnonymousAllowed
|
||||||
|
fun areGameRequestsEnabled(): Boolean = configService.get(ConfigProperties.Requests.Games.Enabled) == true
|
||||||
|
|
||||||
@DynamicPublicAccess
|
@DynamicPublicAccess
|
||||||
@AnonymousAllowed
|
@AnonymousAllowed
|
||||||
fun isSsoEnabled(): Boolean = configService.get(ConfigProperties.SSO.OIDC.Enabled) == true
|
fun areGuestsAllowedToRequestGames(): Boolean =
|
||||||
|
configService.get(ConfigProperties.Requests.Games.AllowGuestsToRequestGames) == true
|
||||||
@DynamicPublicAccess
|
|
||||||
@AnonymousAllowed
|
|
||||||
fun getSsoLogoutUrl(): String? = configService.get(ConfigProperties.SSO.OIDC.LogoutUrl)
|
|
||||||
|
|
||||||
@DynamicPublicAccess
|
|
||||||
@AnonymousAllowed
|
|
||||||
fun isPublicAccessEnabled(): Boolean = configService.get(ConfigProperties.Libraries.AllowPublicAccess) == true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,32 @@ sealed class ConfigProperties<T : Serializable>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Requests */
|
||||||
|
sealed class Requests {
|
||||||
|
sealed class Games {
|
||||||
|
data object Enabled : ConfigProperties<Boolean>(
|
||||||
|
Boolean::class,
|
||||||
|
"requests.games.enabled",
|
||||||
|
"Enable submission of game requests",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
data object AllowGuestsToRequestGames : ConfigProperties<Boolean>(
|
||||||
|
Boolean::class,
|
||||||
|
"requests.games.allow-guests-to-request-games",
|
||||||
|
"Allow guests (not logged in) to create game requests",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
data object MaxOpenRequestsPerUser : ConfigProperties<Int>(
|
||||||
|
Int::class,
|
||||||
|
"requests.games.max-open-requests-per-user",
|
||||||
|
"Maximum number of pending requests per user. Set to 0 for unlimited.",
|
||||||
|
10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** User management */
|
/** User management */
|
||||||
sealed class Users {
|
sealed class Users {
|
||||||
sealed class SignUps {
|
sealed class SignUps {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.fasterxml.jackson.annotation.JsonCreator
|
|||||||
import com.fasterxml.jackson.annotation.JsonValue
|
import com.fasterxml.jackson.annotation.JsonValue
|
||||||
import org.gameyfin.app.users.RoleService
|
import org.gameyfin.app.users.RoleService
|
||||||
import java.lang.Enum
|
import java.lang.Enum
|
||||||
|
import kotlin.Int
|
||||||
|
import kotlin.String
|
||||||
|
|
||||||
enum class Role(val roleName: String, val powerLevel: Int) {
|
enum class Role(val roleName: String, val powerLevel: Int) {
|
||||||
|
|
||||||
@@ -21,12 +23,12 @@ enum class Role(val roleName: String, val powerLevel: Int) {
|
|||||||
@JsonCreator
|
@JsonCreator
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun fromValue(value: String): Role? {
|
fun fromValue(value: String): Role? {
|
||||||
val enumString = value.removePrefix(RoleService.Companion.INTERNAL_ROLE_PREFIX)
|
val enumString = value.removePrefix(RoleService.INTERNAL_ROLE_PREFIX)
|
||||||
return entries.find { it.roleName == enumString }
|
return entries.find { it.roleName == enumString }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun safeValueOf(type: String): Role? {
|
fun safeValueOf(type: String): Role? {
|
||||||
val enumString = type.removePrefix(RoleService.Companion.INTERNAL_ROLE_PREFIX)
|
val enumString = type.removePrefix(RoleService.INTERNAL_ROLE_PREFIX)
|
||||||
return Enum.valueOf(Role::class.java, enumString)
|
return Enum.valueOf(Role::class.java, enumString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,9 +36,9 @@ enum class Role(val roleName: String, val powerLevel: Int) {
|
|||||||
// necessary for the ability to use the Roles class in the @RolesAllowed annotation
|
// necessary for the ability to use the Roles class in the @RolesAllowed annotation
|
||||||
class Names {
|
class Names {
|
||||||
companion object {
|
companion object {
|
||||||
const val SUPERADMIN = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}SUPERADMIN"
|
const val SUPERADMIN = "${RoleService.INTERNAL_ROLE_PREFIX}SUPERADMIN"
|
||||||
const val ADMIN = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}ADMIN"
|
const val ADMIN = "${RoleService.INTERNAL_ROLE_PREFIX}ADMIN"
|
||||||
const val USER = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}USER"
|
const val USER = "${RoleService.INTERNAL_ROLE_PREFIX}USER"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.gameyfin.app.core.config
|
||||||
|
|
||||||
|
import org.gameyfin.app.core.interceptors.EntityUpdateInterceptor
|
||||||
|
import org.hibernate.cfg.AvailableSettings
|
||||||
|
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class JpaConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun hibernatePropertiesCustomizer(entityUpdateInterceptor: EntityUpdateInterceptor): HibernatePropertiesCustomizer {
|
||||||
|
return HibernatePropertiesCustomizer { hibernateProperties ->
|
||||||
|
hibernateProperties[AvailableSettings.INTERCEPTOR] = entityUpdateInterceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.gameyfin.app.core.events
|
package org.gameyfin.app.core.events
|
||||||
|
|
||||||
|
import org.gameyfin.app.games.entities.Game
|
||||||
import org.gameyfin.app.shared.token.Token
|
import org.gameyfin.app.shared.token.Token
|
||||||
import org.gameyfin.app.shared.token.TokenType
|
import org.gameyfin.app.shared.token.TokenType
|
||||||
import org.gameyfin.app.users.entities.User
|
import org.gameyfin.app.users.entities.User
|
||||||
@@ -21,6 +22,12 @@ class RegistrationAttemptWithExistingEmailEvent(source: Any, val existingUser: U
|
|||||||
class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.PasswordReset>, val baseUrl: String) :
|
class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.PasswordReset>, val baseUrl: String) :
|
||||||
ApplicationEvent(source)
|
ApplicationEvent(source)
|
||||||
|
|
||||||
class AccountDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source)
|
class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source)
|
||||||
|
|
||||||
|
class UserDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source)
|
||||||
|
class UserUpdatedEvent(source: Any, val previousState: User, val currentState: User) : ApplicationEvent(source)
|
||||||
|
|
||||||
|
class GameCreatedEvent(source: Any, val game: Game) : ApplicationEvent(source)
|
||||||
|
class GameUpdatedEvent(source: Any, val previousState: Game, val currentState: Game) : ApplicationEvent(source)
|
||||||
|
class GameDeletedEvent(source: Any, val game: Game) : ApplicationEvent(source)
|
||||||
|
|
||||||
class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source)
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package org.gameyfin.app.core.filesystem
|
package org.gameyfin.app.core.filesystem
|
||||||
|
|
||||||
import org.gameyfin.app.config.ConfigService
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.apache.commons.io.FilenameUtils
|
import org.apache.commons.io.FilenameUtils
|
||||||
import org.gameyfin.app.config.ConfigProperties
|
import org.gameyfin.app.config.ConfigProperties
|
||||||
import org.gameyfin.app.libraries.Library
|
import org.gameyfin.app.config.ConfigService
|
||||||
|
import org.gameyfin.app.libraries.entities.Library
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.FileSystems
|
import java.nio.file.FileSystems
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package org.gameyfin.app.core.interceptors
|
||||||
|
|
||||||
|
import org.gameyfin.app.core.events.GameUpdatedEvent
|
||||||
|
import org.gameyfin.app.core.events.UserUpdatedEvent
|
||||||
|
import org.gameyfin.app.games.entities.Game
|
||||||
|
import org.gameyfin.app.games.entities.Image
|
||||||
|
import org.gameyfin.app.users.entities.User
|
||||||
|
import org.gameyfin.app.util.EventPublisherHolder
|
||||||
|
import org.hibernate.Interceptor
|
||||||
|
import org.hibernate.type.Type
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class EntityUpdateInterceptor() : Interceptor {
|
||||||
|
|
||||||
|
override fun onFlushDirty(
|
||||||
|
entity: Any?,
|
||||||
|
id: Any?,
|
||||||
|
currentState: Array<out Any?>?,
|
||||||
|
previousState: Array<out Any?>?,
|
||||||
|
propertyNames: Array<out String>?,
|
||||||
|
types: Array<out Type>?
|
||||||
|
): Boolean {
|
||||||
|
if (entity == null || currentState == null || previousState == null || propertyNames == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
when (entity) {
|
||||||
|
is Game -> {
|
||||||
|
val previousGame = reconstructGame(entity, previousState, propertyNames)
|
||||||
|
val currentGame = reconstructGame(entity, currentState, propertyNames)
|
||||||
|
EventPublisherHolder.publish(GameUpdatedEvent(this, previousGame, currentGame))
|
||||||
|
}
|
||||||
|
|
||||||
|
is User -> {
|
||||||
|
val previousUser = reconstructUser(entity, previousState, propertyNames)
|
||||||
|
val currentUser = reconstructUser(entity, currentState, propertyNames)
|
||||||
|
EventPublisherHolder.publish(UserUpdatedEvent(this, previousUser, currentUser))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconstructGame(originalGame: Game, state: Array<out Any?>, propertyNames: Array<out String>): Game {
|
||||||
|
val reconstructed = Game(
|
||||||
|
library = originalGame.library,
|
||||||
|
metadata = originalGame.metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
for (i in propertyNames.indices) {
|
||||||
|
when (propertyNames[i]) {
|
||||||
|
"id" -> reconstructed.id = state[i] as? Long
|
||||||
|
"createdAt" -> reconstructed.createdAt = state[i] as? java.time.Instant
|
||||||
|
"updatedAt" -> reconstructed.updatedAt = state[i] as? java.time.Instant
|
||||||
|
"title" -> reconstructed.title = state[i] as? String
|
||||||
|
"coverImage" -> reconstructed.coverImage = state[i] as? Image
|
||||||
|
"headerImage" -> reconstructed.headerImage = state[i] as? Image
|
||||||
|
"comment" -> reconstructed.comment = state[i] as? String
|
||||||
|
"summary" -> reconstructed.summary = state[i] as? String
|
||||||
|
"release" -> reconstructed.release = state[i] as? java.time.Instant
|
||||||
|
"userRating" -> reconstructed.userRating = state[i] as? Int
|
||||||
|
"criticRating" -> reconstructed.criticRating = state[i] as? Int
|
||||||
|
"images" -> {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(state[i] as? MutableList<Image>)?.let { reconstructed.images = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reconstructed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconstructUser(originalUser: User, state: Array<out Any?>, propertyNames: Array<out String>): User {
|
||||||
|
val reconstructed = User(
|
||||||
|
username = originalUser.username,
|
||||||
|
email = originalUser.email
|
||||||
|
)
|
||||||
|
|
||||||
|
for (i in propertyNames.indices) {
|
||||||
|
when (propertyNames[i]) {
|
||||||
|
"id" -> reconstructed.id = state[i] as? Long
|
||||||
|
"password" -> reconstructed.password = state[i] as? String
|
||||||
|
"oidcProviderId" -> reconstructed.oidcProviderId = state[i] as? String
|
||||||
|
"emailConfirmed" -> reconstructed.emailConfirmed = state[i] as? Boolean ?: false
|
||||||
|
"enabled" -> reconstructed.enabled = state[i] as? Boolean ?: false
|
||||||
|
"avatar" -> reconstructed.avatar = state[i] as? Image
|
||||||
|
"roles" -> {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(state[i] as? List<org.gameyfin.app.core.Role>)?.let { reconstructed.roles = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reconstructed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +1,46 @@
|
|||||||
package org.gameyfin.app.core.plugins
|
package org.gameyfin.app.core.plugins
|
||||||
|
|
||||||
|
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
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.annotations.DynamicPublicAccess
|
||||||
import org.gameyfin.app.core.plugins.dto.PluginUpdateDto
|
import org.gameyfin.app.core.plugins.dto.PluginUpdateDto
|
||||||
import org.gameyfin.app.core.security.isCurrentUserAdmin
|
|
||||||
import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult
|
import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@RolesAllowed(Role.Names.ADMIN)
|
@DynamicPublicAccess
|
||||||
|
@AnonymousAllowed
|
||||||
class PluginEndpoint(
|
class PluginEndpoint(
|
||||||
private val pluginService: PluginService,
|
private val pluginService: PluginService,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@PermitAll
|
|
||||||
fun subscribe(): Flux<List<PluginUpdateDto>> {
|
fun subscribe(): Flux<List<PluginUpdateDto>> {
|
||||||
return if (isCurrentUserAdmin()) PluginService.subscribe()
|
return PluginService.subscribe()
|
||||||
else Flux.empty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAll() = pluginService.getAll().sortedByDescending { it.priority }
|
fun getAll() = pluginService.getAll().sortedByDescending { it.priority }
|
||||||
|
|
||||||
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun enablePlugin(pluginId: String) = pluginService.enablePlugin(pluginId)
|
fun enablePlugin(pluginId: String) = pluginService.enablePlugin(pluginId)
|
||||||
|
|
||||||
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun disablePlugin(pluginId: String) = pluginService.disablePlugin(pluginId)
|
fun disablePlugin(pluginId: String) = pluginService.disablePlugin(pluginId)
|
||||||
|
|
||||||
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun setPluginPriorities(pluginPriorities: Map<String, Int>) =
|
fun setPluginPriorities(pluginPriorities: Map<String, Int>) =
|
||||||
pluginService.setPluginPriorities(pluginPriorities)
|
pluginService.setPluginPriorities(pluginPriorities)
|
||||||
|
|
||||||
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun validatePluginConfig(pluginId: String): PluginConfigValidationResult =
|
fun validatePluginConfig(pluginId: String): PluginConfigValidationResult =
|
||||||
pluginService.validatePluginConfig(pluginId, true)
|
pluginService.validatePluginConfig(pluginId, true)
|
||||||
|
|
||||||
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun validateNewConfig(pluginId: String, config: Map<String, String>): PluginConfigValidationResult =
|
fun validateNewConfig(pluginId: String, config: Map<String, String>): PluginConfigValidationResult =
|
||||||
pluginService.validatePluginConfig(pluginId, config)
|
pluginService.validatePluginConfig(pluginId, config)
|
||||||
|
|
||||||
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun updateConfig(pluginId: String, updatedConfig: Map<String, String>) =
|
fun updateConfig(pluginId: String, updatedConfig: Map<String, String>) =
|
||||||
pluginService.updateConfig(pluginId, updatedConfig)
|
pluginService.updateConfig(pluginId, updatedConfig)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package org.gameyfin.app.core.plugins.dto
|
||||||
|
|
||||||
|
class ExternalProviderIdDto(
|
||||||
|
val pluginId: String,
|
||||||
|
val externalProviderId: String,
|
||||||
|
) {
|
||||||
|
override fun toString(): String {
|
||||||
|
return "$pluginId:$externalProviderId"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,59 +1,69 @@
|
|||||||
package org.gameyfin.app.core.security
|
package org.gameyfin.app.core.security
|
||||||
|
|
||||||
import com.vaadin.flow.spring.security.VaadinWebSecurity
|
import com.vaadin.flow.spring.security.VaadinAwareSecurityContextHolderStrategyConfiguration
|
||||||
|
import com.vaadin.flow.spring.security.VaadinSecurityConfigurer
|
||||||
|
import com.vaadin.hilla.route.RouteUtil
|
||||||
import org.gameyfin.app.config.ConfigProperties
|
import org.gameyfin.app.config.ConfigProperties
|
||||||
import org.gameyfin.app.config.ConfigService
|
import org.gameyfin.app.config.ConfigService
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Conditional
|
import org.springframework.context.annotation.Conditional
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.core.env.Environment
|
import org.springframework.core.env.Environment
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
import org.springframework.security.config.annotation.web.builders.WebSecurity
|
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||||
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer
|
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy
|
import org.springframework.security.config.http.SessionCreationPolicy
|
||||||
import org.springframework.security.core.session.SessionRegistry
|
import org.springframework.security.core.session.SessionRegistry
|
||||||
import org.springframework.security.oauth2.client.registration.ClientRegistration
|
import org.springframework.security.oauth2.client.registration.ClientRegistration
|
||||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
|
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
|
||||||
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
|
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
|
||||||
import org.springframework.security.oauth2.core.AuthorizationGrantType
|
import org.springframework.security.oauth2.core.AuthorizationGrantType
|
||||||
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler
|
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler
|
||||||
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
|
@Import(
|
||||||
|
VaadinAwareSecurityContextHolderStrategyConfiguration::class
|
||||||
|
)
|
||||||
class SecurityConfig(
|
class SecurityConfig(
|
||||||
private val environment: Environment,
|
private val environment: Environment,
|
||||||
private val config: ConfigService,
|
private val config: ConfigService,
|
||||||
private val ssoAuthenticationSuccessHandler: SsoAuthenticationSuccessHandler,
|
private val ssoAuthenticationSuccessHandler: SsoAuthenticationSuccessHandler,
|
||||||
private val sessionRegistry: SessionRegistry
|
private val sessionRegistry: SessionRegistry
|
||||||
) : VaadinWebSecurity() {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val SSO_PROVIDER_KEY = "oidc"
|
const val SSO_PROVIDER_KEY = "oidc"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(Exception::class)
|
@Bean
|
||||||
override fun configure(http: HttpSecurity) {
|
fun filterChain(http: HttpSecurity, routeUtil: RouteUtil): SecurityFilterChain {
|
||||||
|
http.authorizeHttpRequests { auth ->
|
||||||
// Configure your static resources with public access before calling super.configure(HttpSecurity) as it adds final anyRequest matcher
|
// Set default security policy that permits Hilla internal requests and denies all other
|
||||||
http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry ->
|
auth.requestMatchers(routeUtil::isRouteAllowed).permitAll()
|
||||||
auth.requestMatchers("/login").permitAll()
|
// Gameyfin static resources and public endpoints
|
||||||
.requestMatchers("/setup").permitAll()
|
.requestMatchers(
|
||||||
.requestMatchers("/reset-password").permitAll()
|
"/login",
|
||||||
.requestMatchers("/accept-invitation").permitAll()
|
"/setup",
|
||||||
.requestMatchers("/public/**").permitAll()
|
"/reset-password",
|
||||||
.requestMatchers("/images/**").permitAll()
|
"/accept-invitation",
|
||||||
.requestMatchers("/favicon.ico").permitAll()
|
"/public/**",
|
||||||
.requestMatchers("/favicon.svg").permitAll()
|
"/images/**",
|
||||||
|
"/favicon.ico",
|
||||||
// Dynamic public access for certain endpoints
|
"/favicon.svg"
|
||||||
auth.requestMatchers("/").access(DynamicPublicAccessAuthorizationManager(config))
|
).permitAll()
|
||||||
.requestMatchers("/game/**").access(DynamicPublicAccessAuthorizationManager(config))
|
// Dynamic public access for certain endpoints
|
||||||
.requestMatchers("/library/**").access(DynamicPublicAccessAuthorizationManager(config))
|
.requestMatchers(
|
||||||
.requestMatchers("/search/**").access(DynamicPublicAccessAuthorizationManager(config))
|
"/",
|
||||||
.requestMatchers("/download/**").access(DynamicPublicAccessAuthorizationManager(config))
|
"/game/**",
|
||||||
|
"/library/**",
|
||||||
|
"/search/**",
|
||||||
|
"/requests/**",
|
||||||
|
"/download/**"
|
||||||
|
).access(DynamicPublicAccessAuthorizationManager(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
http.sessionManagement { sessionManagement ->
|
http.sessionManagement { sessionManagement ->
|
||||||
@@ -66,11 +76,14 @@ class SecurityConfig(
|
|||||||
// Not needed since the frontend is served by the backend
|
// Not needed since the frontend is served by the backend
|
||||||
http.cors { cors -> cors.disable() }
|
http.cors { cors -> cors.disable() }
|
||||||
|
|
||||||
super.configure(http)
|
|
||||||
|
|
||||||
setLoginView(http, "/login", "/")
|
|
||||||
|
|
||||||
if (config.get(ConfigProperties.SSO.OIDC.Enabled) == true) {
|
if (config.get(ConfigProperties.SSO.OIDC.Enabled) == true) {
|
||||||
|
|
||||||
|
http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
|
||||||
|
// Redirect to SSO provider on logout
|
||||||
|
configurer.loginView("/login", config.get(ConfigProperties.SSO.OIDC.LogoutUrl))
|
||||||
|
}
|
||||||
|
|
||||||
// Use custom success handler to handle user registration
|
// Use custom success handler to handle user registration
|
||||||
http.oauth2Login { oauth2Login -> oauth2Login.successHandler(ssoAuthenticationSuccessHandler) }
|
http.oauth2Login { oauth2Login -> oauth2Login.successHandler(ssoAuthenticationSuccessHandler) }
|
||||||
// Prevent unnecessary redirects
|
// Prevent unnecessary redirects
|
||||||
@@ -80,16 +93,18 @@ class SecurityConfig(
|
|||||||
http.exceptionHandling { exceptionHandling ->
|
http.exceptionHandling { exceptionHandling ->
|
||||||
exceptionHandling.authenticationEntryPoint(CustomAuthenticationEntryPoint())
|
exceptionHandling.authenticationEntryPoint(CustomAuthenticationEntryPoint())
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Use default Vaadin login URLs
|
||||||
|
http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
|
||||||
|
configurer.loginView("/login")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(Exception::class)
|
|
||||||
public override fun configure(web: WebSecurity) {
|
|
||||||
super.configure(web)
|
|
||||||
|
|
||||||
if ("dev" in environment.activeProfiles) {
|
if ("dev" in environment.activeProfiles) {
|
||||||
web.ignoring().requestMatchers("/h2-console/**")
|
http.authorizeHttpRequests { auth -> auth.requestMatchers("/h2-console/**").permitAll() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return http.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
package org.gameyfin.app.core.security
|
package org.gameyfin.app.core.security
|
||||||
|
|
||||||
import org.gameyfin.app.core.Role
|
import org.gameyfin.app.core.Role
|
||||||
|
import org.springframework.security.core.Authentication
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
|
||||||
|
fun getCurrentAuth(): Authentication? {
|
||||||
|
return SecurityContextHolder.getContext().authentication
|
||||||
|
}
|
||||||
|
|
||||||
fun isCurrentUserAdmin(): Boolean {
|
fun isCurrentUserAdmin(): Boolean {
|
||||||
return SecurityContextHolder.getContext().authentication?.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN }
|
return getCurrentAuth()?.isAdmin() ?: false
|
||||||
?: false
|
}
|
||||||
|
|
||||||
|
fun Authentication.isAdmin(): Boolean {
|
||||||
|
return this.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN } ?: false
|
||||||
}
|
}
|
||||||
@@ -5,8 +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.core.plugins.dto.ExternalProviderIdDto
|
||||||
import org.gameyfin.app.core.security.isCurrentUserAdmin
|
import org.gameyfin.app.core.security.isCurrentUserAdmin
|
||||||
import org.gameyfin.app.games.dto.*
|
import org.gameyfin.app.games.dto.GameDto
|
||||||
|
import org.gameyfin.app.games.dto.GameEvent
|
||||||
|
import org.gameyfin.app.games.dto.GameSearchResultDto
|
||||||
|
import org.gameyfin.app.games.dto.GameUpdateDto
|
||||||
import org.gameyfin.app.libraries.LibraryCoreService
|
import org.gameyfin.app.libraries.LibraryCoreService
|
||||||
import org.gameyfin.app.libraries.LibraryService
|
import org.gameyfin.app.libraries.LibraryService
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
@@ -30,6 +34,10 @@ class GameEndpoint(
|
|||||||
|
|
||||||
fun getAll(): List<GameDto> = gameService.getAll()
|
fun getAll(): List<GameDto> = gameService.getAll()
|
||||||
|
|
||||||
|
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
|
||||||
|
return gameService.getPotentialMatches(searchTerm)
|
||||||
|
}
|
||||||
|
|
||||||
@RolesAllowed(Role.Names.ADMIN)
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun updateGame(game: GameUpdateDto) = gameService.edit(game)
|
fun updateGame(game: GameUpdateDto) = gameService.edit(game)
|
||||||
|
|
||||||
@@ -40,12 +48,12 @@ class GameEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@RolesAllowed(Role.Names.ADMIN)
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
|
fun matchManually(
|
||||||
return gameService.getPotentialMatches(searchTerm)
|
originalIds: Map<String, ExternalProviderIdDto>,
|
||||||
}
|
path: String,
|
||||||
|
libraryId: Long,
|
||||||
@RolesAllowed(Role.Names.ADMIN)
|
replaceGameId: Long?
|
||||||
fun matchManually(originalIds: Map<String, OriginalIdDto>, path: String, libraryId: Long, replaceGameId: Long?) {
|
) {
|
||||||
val library = libraryService.getById(libraryId)
|
val library = libraryService.getById(libraryId)
|
||||||
val game = gameService.matchManually(originalIds, Path.of(path), library, replaceGameId)
|
val game = gameService.matchManually(originalIds, Path.of(path), library, replaceGameId)
|
||||||
if (game != null) {
|
if (game != null) {
|
||||||
|
|||||||
@@ -12,27 +12,27 @@ import org.gameyfin.app.core.alphaNumeric
|
|||||||
import org.gameyfin.app.core.filesystem.FilesystemService
|
import org.gameyfin.app.core.filesystem.FilesystemService
|
||||||
import org.gameyfin.app.core.filterValuesNotNull
|
import org.gameyfin.app.core.filterValuesNotNull
|
||||||
import org.gameyfin.app.core.plugins.PluginService
|
import org.gameyfin.app.core.plugins.PluginService
|
||||||
|
import org.gameyfin.app.core.plugins.dto.ExternalProviderIdDto
|
||||||
import org.gameyfin.app.core.plugins.management.GameyfinPluginDescriptor
|
import org.gameyfin.app.core.plugins.management.GameyfinPluginDescriptor
|
||||||
import org.gameyfin.app.core.plugins.management.GameyfinPluginManager
|
import org.gameyfin.app.core.plugins.management.GameyfinPluginManager
|
||||||
import org.gameyfin.app.core.plugins.management.PluginManagementEntry
|
import org.gameyfin.app.core.plugins.management.PluginManagementEntry
|
||||||
import org.gameyfin.app.core.replaceRomanNumerals
|
import org.gameyfin.app.core.replaceRomanNumerals
|
||||||
|
import org.gameyfin.app.core.security.getCurrentAuth
|
||||||
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.extensions.toDtos
|
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.entities.Library
|
||||||
import org.gameyfin.app.media.ImageService
|
import org.gameyfin.app.media.ImageService
|
||||||
import org.gameyfin.app.users.UserService
|
import org.gameyfin.app.users.UserService
|
||||||
import org.gameyfin.pluginapi.gamemetadata.*
|
import org.gameyfin.pluginapi.gamemetadata.*
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
import reactor.core.publisher.Sinks
|
import reactor.core.publisher.Sinks
|
||||||
import java.net.URI
|
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -104,10 +104,9 @@ class GameService(
|
|||||||
return entities.toDtos()
|
return entities.toDtos()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
private fun create(game: Game): Game {
|
||||||
fun create(game: Game): Game? {
|
game.publishers = game.publishers.map { companyService.createOrGet(it) }.toMutableList()
|
||||||
game.publishers = game.publishers.map { companyService.createOrGet(it) }
|
game.developers = game.developers.map { companyService.createOrGet(it) }.toMutableList()
|
||||||
game.developers = game.developers.map { companyService.createOrGet(it) }
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
game.coverImage?.let {
|
game.coverImage?.let {
|
||||||
@@ -124,7 +123,6 @@ class GameService(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.error { "Error downloading images for game '${game.title}' (${game.id}): ${e.message}" }
|
log.error { "Error downloading images for game '${game.title}' (${game.id}): ${e.message}" }
|
||||||
log.debug(e) {}
|
log.debug(e) {}
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
game.metadata.fileSize = filesystemService.calculateFileSize(game.metadata.path)
|
game.metadata.fileSize = filesystemService.calculateFileSize(game.metadata.path)
|
||||||
@@ -137,9 +135,11 @@ class GameService(
|
|||||||
val gamesToBePersisted = games.filter { it.id == null }
|
val gamesToBePersisted = games.filter { it.id == null }
|
||||||
|
|
||||||
gamesToBePersisted.forEach { game ->
|
gamesToBePersisted.forEach { game ->
|
||||||
game.publishers = game.publishers.map { companyService.createOrGet(it) }
|
game.publishers = game.publishers.map { companyService.createOrGet(it) }.toMutableList()
|
||||||
game.developers = game.developers.map { companyService.createOrGet(it) }
|
game.developers = game.developers.map { companyService.createOrGet(it) }.toMutableList()
|
||||||
game
|
game.coverImage?.let { game.coverImage = imageService.createOrGet(it) }
|
||||||
|
game.headerImage?.let { game.headerImage = imageService.createOrGet(it) }
|
||||||
|
game.images = game.images.map { imageService.createOrGet(it) }.toMutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
return gameRepository.saveAll(gamesToBePersisted)
|
return gameRepository.saveAll(gamesToBePersisted)
|
||||||
@@ -149,11 +149,10 @@ class GameService(
|
|||||||
val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
|
val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
|
||||||
?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found")
|
?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found")
|
||||||
|
|
||||||
val userDetails = SecurityContextHolder.getContext().authentication.principal
|
val user = when (val userDetails = getCurrentAuth()?.principal) {
|
||||||
val user = when (userDetails) {
|
|
||||||
is UserDetails -> userService.getByUsernameNonNull(userDetails.username)
|
is UserDetails -> userService.getByUsernameNonNull(userDetails.username)
|
||||||
is OidcUser -> userService.getByUsernameNonNull(userDetails.preferredUsername)
|
is OidcUser -> userService.getByUsernameNonNull(userDetails.preferredUsername)
|
||||||
else -> throw IllegalStateException("Unkown user type: ${userDetails::class.java.name}")
|
else -> throw IllegalStateException("Unkown user type: ${userDetails?.javaClass?.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update only non-null fields
|
// Update only non-null fields
|
||||||
@@ -166,14 +165,18 @@ class GameService(
|
|||||||
existingGame.metadata.fields["release"]?.source = GameFieldUserSource(user = user)
|
existingGame.metadata.fields["release"]?.source = GameFieldUserSource(user = user)
|
||||||
}
|
}
|
||||||
gameUpdateDto.coverUrl?.let {
|
gameUpdateDto.coverUrl?.let {
|
||||||
val newCoverImage = Image(originalUrl = URI.create(it).toURL(), type = ImageType.COVER)
|
val newCoverImage = imageService.createOrGet(
|
||||||
|
Image(originalUrl = it, type = ImageType.COVER)
|
||||||
|
)
|
||||||
imageService.downloadIfNew(newCoverImage)
|
imageService.downloadIfNew(newCoverImage)
|
||||||
|
|
||||||
existingGame.coverImage = newCoverImage
|
existingGame.coverImage = newCoverImage
|
||||||
existingGame.metadata.fields["coverImage"]?.source = GameFieldUserSource(user = user)
|
existingGame.metadata.fields["coverImage"]?.source = GameFieldUserSource(user = user)
|
||||||
}
|
}
|
||||||
gameUpdateDto.headerUrl?.let {
|
gameUpdateDto.headerUrl?.let {
|
||||||
val newHeaderImage = Image(originalUrl = URI.create(it).toURL(), type = ImageType.HEADER)
|
val newHeaderImage = imageService.createOrGet(
|
||||||
|
Image(originalUrl = it, type = ImageType.HEADER)
|
||||||
|
)
|
||||||
imageService.downloadIfNew(newHeaderImage)
|
imageService.downloadIfNew(newHeaderImage)
|
||||||
|
|
||||||
existingGame.headerImage = newHeaderImage
|
existingGame.headerImage = newHeaderImage
|
||||||
@@ -190,11 +193,13 @@ class GameService(
|
|||||||
gameUpdateDto.developers?.let {
|
gameUpdateDto.developers?.let {
|
||||||
existingGame.developers =
|
existingGame.developers =
|
||||||
it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.DEVELOPER)) }
|
it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.DEVELOPER)) }
|
||||||
|
.toMutableList()
|
||||||
existingGame.metadata.fields["developers"]?.source = GameFieldUserSource(user = user)
|
existingGame.metadata.fields["developers"]?.source = GameFieldUserSource(user = user)
|
||||||
}
|
}
|
||||||
gameUpdateDto.publishers?.let {
|
gameUpdateDto.publishers?.let {
|
||||||
existingGame.publishers =
|
existingGame.publishers =
|
||||||
it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.PUBLISHER)) }
|
it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.PUBLISHER)) }
|
||||||
|
.toMutableList()
|
||||||
existingGame.metadata.fields["publishers"]?.source = GameFieldUserSource(user = user)
|
existingGame.metadata.fields["publishers"]?.source = GameFieldUserSource(user = user)
|
||||||
}
|
}
|
||||||
gameUpdateDto.genres?.let {
|
gameUpdateDto.genres?.let {
|
||||||
@@ -260,12 +265,12 @@ class GameService(
|
|||||||
|
|
||||||
val game = getById(game.id!!)
|
val game = getById(game.id!!)
|
||||||
|
|
||||||
val originalIds: Map<String, OriginalIdDto> = game.metadata.originalIds
|
val originalIds: Map<String, ExternalProviderIdDto> = game.metadata.originalIds
|
||||||
.map { (provider, originalId) ->
|
.map { (provider, originalId) ->
|
||||||
val providerId = pluginManager.getExtensions(provider.pluginId).first()?.javaClass?.name ?: return null
|
val providerId = pluginManager.getExtensions(provider.pluginId).first()?.javaClass?.name ?: return null
|
||||||
val pluginId = provider.pluginId
|
val pluginId = provider.pluginId
|
||||||
val originalId = originalId
|
val originalId = originalId
|
||||||
providerId to OriginalIdDto(pluginId, originalId)
|
providerId to ExternalProviderIdDto(pluginId, originalId)
|
||||||
}
|
}
|
||||||
.toMap()
|
.toMap()
|
||||||
|
|
||||||
@@ -378,7 +383,7 @@ class GameService(
|
|||||||
"publishers",
|
"publishers",
|
||||||
game.publishers,
|
game.publishers,
|
||||||
updatedGame.publishers,
|
updatedGame.publishers,
|
||||||
{ game.publishers = it ?: emptyList() },
|
{ game.publishers = it ?: mutableListOf() },
|
||||||
updatedGame.metadata.fields["publishers"]
|
updatedGame.metadata.fields["publishers"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -387,7 +392,7 @@ class GameService(
|
|||||||
"developers",
|
"developers",
|
||||||
game.developers,
|
game.developers,
|
||||||
updatedGame.developers,
|
updatedGame.developers,
|
||||||
{ game.developers = it ?: emptyList() },
|
{ game.developers = it ?: mutableListOf() },
|
||||||
updatedGame.metadata.fields["developers"]
|
updatedGame.metadata.fields["developers"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -441,7 +446,7 @@ class GameService(
|
|||||||
"images",
|
"images",
|
||||||
game.images,
|
game.images,
|
||||||
updatedGame.images,
|
updatedGame.images,
|
||||||
{ game.images = it ?: emptyList() },
|
{ game.images = it ?: mutableListOf() },
|
||||||
updatedGame.metadata.fields["images"]
|
updatedGame.metadata.fields["images"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -504,12 +509,12 @@ class GameService(
|
|||||||
sorted.mapNotNull { selector(it.second) }.firstOrNull { it.isNotEmpty() }
|
sorted.mapNotNull { selector(it.second) }.firstOrNull { it.isNotEmpty() }
|
||||||
|
|
||||||
// Collect originalIds for this group
|
// Collect originalIds for this group
|
||||||
val originalIds: Map<String, OriginalIdDto> = group
|
val originalIds: Map<String, ExternalProviderIdDto> = group
|
||||||
.mapNotNull { (provider, metadata) ->
|
.mapNotNull { (provider, metadata) ->
|
||||||
val providerId = provider.javaClass.name
|
val providerId = provider.javaClass.name
|
||||||
val pluginId = providerToManagementEntry[provider]?.pluginId ?: return@mapNotNull null
|
val pluginId = providerToManagementEntry[provider]?.pluginId ?: return@mapNotNull null
|
||||||
val originalId = metadata.originalId
|
val originalId = metadata.originalId
|
||||||
if (providerId != null) providerId to OriginalIdDto(pluginId, originalId) else null
|
if (providerId != null) providerId to ExternalProviderIdDto(pluginId, originalId) else null
|
||||||
}
|
}
|
||||||
.toMap()
|
.toMap()
|
||||||
|
|
||||||
@@ -567,7 +572,7 @@ class GameService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun matchManually(
|
fun matchManually(
|
||||||
originalIds: Map<String, OriginalIdDto>,
|
originalIds: Map<String, ExternalProviderIdDto>,
|
||||||
path: Path,
|
path: Path,
|
||||||
library: Library,
|
library: Library,
|
||||||
replaceGameId: Long? = null,
|
replaceGameId: Long? = null,
|
||||||
@@ -578,7 +583,7 @@ class GameService(
|
|||||||
coroutineScope {
|
coroutineScope {
|
||||||
metadataPlugins.associateWith { plugin ->
|
metadataPlugins.associateWith { plugin ->
|
||||||
async {
|
async {
|
||||||
val originalId = originalIds[plugin.javaClass.name]?.originalId ?: return@async null
|
val originalId = originalIds[plugin.javaClass.name]?.externalProviderId ?: return@async null
|
||||||
try {
|
try {
|
||||||
return@async plugin.fetchById(originalId)
|
return@async plugin.fetchById(originalId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -758,14 +763,18 @@ class GameService(
|
|||||||
}
|
}
|
||||||
metadata.coverUrls?.firstOrNull()?.let { coverUrl ->
|
metadata.coverUrls?.firstOrNull()?.let { coverUrl ->
|
||||||
if (!metadataMap.containsKey("coverImage")) {
|
if (!metadataMap.containsKey("coverImage")) {
|
||||||
mergedGame.coverImage = Image(originalUrl = coverUrl.toURL(), type = ImageType.COVER)
|
mergedGame.coverImage = imageService.createOrGet(
|
||||||
|
Image(originalUrl = coverUrl.toString(), type = ImageType.COVER)
|
||||||
|
)
|
||||||
metadataMap["coverImage"] =
|
metadataMap["coverImage"] =
|
||||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadata.headerUrls?.firstOrNull()?.let { headerUrl ->
|
metadata.headerUrls?.firstOrNull()?.let { headerUrl ->
|
||||||
if (!metadataMap.containsKey("headerImage")) {
|
if (!metadataMap.containsKey("headerImage")) {
|
||||||
mergedGame.headerImage = Image(originalUrl = headerUrl.toURL(), type = ImageType.HEADER)
|
mergedGame.headerImage = imageService.createOrGet(
|
||||||
|
Image(originalUrl = headerUrl.toString(), type = ImageType.HEADER)
|
||||||
|
)
|
||||||
metadataMap["headerImage"] =
|
metadataMap["headerImage"] =
|
||||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||||
}
|
}
|
||||||
@@ -794,7 +803,7 @@ class GameService(
|
|||||||
metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy ->
|
metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy ->
|
||||||
if (!metadataMap.containsKey("publishers")) {
|
if (!metadataMap.containsKey("publishers")) {
|
||||||
mergedGame.publishers =
|
mergedGame.publishers =
|
||||||
publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }
|
publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }.toMutableList()
|
||||||
metadataMap["publishers"] =
|
metadataMap["publishers"] =
|
||||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||||
}
|
}
|
||||||
@@ -802,7 +811,7 @@ class GameService(
|
|||||||
metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy ->
|
metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy ->
|
||||||
if (!metadataMap.containsKey("developers")) {
|
if (!metadataMap.containsKey("developers")) {
|
||||||
mergedGame.developers =
|
mergedGame.developers =
|
||||||
developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }
|
developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }.toMutableList()
|
||||||
metadataMap["developers"] =
|
metadataMap["developers"] =
|
||||||
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||||
}
|
}
|
||||||
@@ -843,7 +852,11 @@ class GameService(
|
|||||||
metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls ->
|
metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls ->
|
||||||
if (!metadataMap.containsKey("images")) {
|
if (!metadataMap.containsKey("images")) {
|
||||||
mergedGame.images = runBlocking {
|
mergedGame.images = runBlocking {
|
||||||
screenshotUrls.map { Image(originalUrl = it.toURL(), type = ImageType.SCREENSHOT) }
|
screenshotUrls.map {
|
||||||
|
imageService.createOrGet(
|
||||||
|
Image(originalUrl = it.toString(), type = ImageType.SCREENSHOT)
|
||||||
|
)
|
||||||
|
}.toMutableList()
|
||||||
}
|
}
|
||||||
metadataMap["images"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
metadataMap["images"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ sealed interface GameDto {
|
|||||||
val metadata: GameMetadataDto
|
val metadata: GameMetadataDto
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
@JsonInclude(JsonInclude.Include.ALWAYS)
|
||||||
data class GameUserDto(
|
data class GameUserDto(
|
||||||
override val id: Long,
|
override val id: Long,
|
||||||
override val createdAt: Instant,
|
override val createdAt: Instant,
|
||||||
@@ -55,7 +55,7 @@ data class GameUserDto(
|
|||||||
override val metadata: GameMetadataUserDto
|
override val metadata: GameMetadataUserDto
|
||||||
) : GameDto
|
) : GameDto
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
@JsonInclude(JsonInclude.Include.ALWAYS)
|
||||||
data class GameAdminDto(
|
data class GameAdminDto(
|
||||||
override val id: Long,
|
override val id: Long,
|
||||||
override val createdAt: Instant,
|
override val createdAt: Instant,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.gameyfin.app.games.dto
|
package org.gameyfin.app.games.dto
|
||||||
|
|
||||||
|
import org.gameyfin.app.core.plugins.dto.ExternalProviderIdDto
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@@ -11,18 +12,9 @@ class GameSearchResultDto(
|
|||||||
val release: Instant?,
|
val release: Instant?,
|
||||||
val publishers: Collection<String>?,
|
val publishers: Collection<String>?,
|
||||||
val developers: Collection<String>?,
|
val developers: Collection<String>?,
|
||||||
val originalIds: Map<String, OriginalIdDto>
|
val originalIds: Map<String, ExternalProviderIdDto>
|
||||||
)
|
)
|
||||||
|
|
||||||
class OriginalIdDto(
|
|
||||||
val pluginId: String,
|
|
||||||
val originalId: String,
|
|
||||||
) {
|
|
||||||
override fun toString(): String {
|
|
||||||
return "$pluginId:$originalId"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class UrlWithSourceDto(
|
class UrlWithSourceDto(
|
||||||
val url: String,
|
val url: String,
|
||||||
val pluginId: String
|
val pluginId: String
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package org.gameyfin.app.games.entities
|
package org.gameyfin.app.games.entities
|
||||||
|
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
import org.gameyfin.app.libraries.Library
|
import jakarta.persistence.CascadeType.*
|
||||||
|
import org.gameyfin.app.libraries.entities.Library
|
||||||
import org.gameyfin.pluginapi.gamemetadata.GameFeature
|
import org.gameyfin.pluginapi.gamemetadata.GameFeature
|
||||||
import org.gameyfin.pluginapi.gamemetadata.Genre
|
import org.gameyfin.pluginapi.gamemetadata.Genre
|
||||||
import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective
|
import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective
|
||||||
@@ -28,15 +29,14 @@ class Game(
|
|||||||
var updatedAt: Instant? = null,
|
var updatedAt: Instant? = null,
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "library_id")
|
|
||||||
val library: Library,
|
val library: Library,
|
||||||
|
|
||||||
var title: String? = null,
|
var title: String? = null,
|
||||||
|
|
||||||
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
|
@ManyToOne(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
|
||||||
var coverImage: Image? = null,
|
var coverImage: Image? = null,
|
||||||
|
|
||||||
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
|
@ManyToOne(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
|
||||||
var headerImage: Image? = null,
|
var headerImage: Image? = null,
|
||||||
|
|
||||||
@Lob
|
@Lob
|
||||||
@@ -53,11 +53,11 @@ class Game(
|
|||||||
|
|
||||||
var criticRating: Int? = null,
|
var criticRating: Int? = null,
|
||||||
|
|
||||||
@ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH])
|
@ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
|
||||||
var publishers: List<Company> = emptyList(),
|
var publishers: MutableList<Company> = mutableListOf(),
|
||||||
|
|
||||||
@ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH])
|
@ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
|
||||||
var developers: List<Company> = emptyList(),
|
var developers: MutableList<Company> = mutableListOf(),
|
||||||
|
|
||||||
@ElementCollection(targetClass = Genre::class)
|
@ElementCollection(targetClass = Genre::class)
|
||||||
var genres: List<Genre> = emptyList(),
|
var genres: List<Genre> = emptyList(),
|
||||||
@@ -74,16 +74,14 @@ class Game(
|
|||||||
@ElementCollection(targetClass = PlayerPerspective::class)
|
@ElementCollection(targetClass = PlayerPerspective::class)
|
||||||
var perspectives: List<PlayerPerspective> = emptyList(),
|
var perspectives: List<PlayerPerspective> = emptyList(),
|
||||||
|
|
||||||
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
|
@ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
|
||||||
var images: List<Image> = emptyList(),
|
var images: MutableList<Image> = mutableListOf(),
|
||||||
|
|
||||||
@ElementCollection
|
@ElementCollection
|
||||||
var videoUrls: List<URI> = emptyList(),
|
var videoUrls: List<URI> = emptyList(),
|
||||||
|
|
||||||
@Embedded
|
@Embedded
|
||||||
var metadata: GameMetadata
|
var metadata: GameMetadata
|
||||||
|
|
||||||
|
|
||||||
) {
|
) {
|
||||||
constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString()))
|
constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString()))
|
||||||
}
|
}
|
||||||
@@ -3,28 +3,35 @@ package org.gameyfin.app.games.entities
|
|||||||
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.core.events.GameCreatedEvent
|
||||||
|
import org.gameyfin.app.core.events.GameDeletedEvent
|
||||||
import org.gameyfin.app.games.GameService
|
import org.gameyfin.app.games.GameService
|
||||||
import org.gameyfin.app.games.dto.GameAdminEvent
|
import org.gameyfin.app.games.dto.GameAdminEvent
|
||||||
import org.gameyfin.app.games.dto.GameUserEvent
|
import org.gameyfin.app.games.dto.GameUserEvent
|
||||||
import org.gameyfin.app.games.extensions.toAdminDto
|
import org.gameyfin.app.games.extensions.toAdminDto
|
||||||
import org.gameyfin.app.games.extensions.toUserDto
|
import org.gameyfin.app.games.extensions.toUserDto
|
||||||
|
import org.gameyfin.app.util.EventPublisherHolder
|
||||||
|
|
||||||
class GameEntityListener {
|
class GameEntityListener {
|
||||||
|
|
||||||
@PostPersist
|
@PostPersist
|
||||||
fun created(game: Game) {
|
fun created(game: Game) {
|
||||||
GameService.emitUser(GameUserEvent.Created(game.toUserDto()))
|
GameService.emitUser(GameUserEvent.Created(game.toUserDto()))
|
||||||
GameService.emitAdmin(GameAdminEvent.Created(game.toAdminDto()))
|
GameService.emitAdmin(GameAdminEvent.Created(game.toAdminDto()))
|
||||||
|
EventPublisherHolder.publish(GameCreatedEvent(this, game))
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostUpdate
|
@PostUpdate
|
||||||
fun updated(game: Game) {
|
fun updated(game: Game) {
|
||||||
GameService.emitUser(GameUserEvent.Updated(game.toUserDto()))
|
GameService.emitUser(GameUserEvent.Updated(game.toUserDto()))
|
||||||
GameService.emitAdmin(GameAdminEvent.Updated(game.toAdminDto()))
|
GameService.emitAdmin(GameAdminEvent.Updated(game.toAdminDto()))
|
||||||
|
// GameUpdateEvent triggered via {@link org.gameyfin.app.core.interceptors.EntityUpdateInterceptor#onFlushDirty}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostRemove
|
@PostRemove
|
||||||
fun deleted(game: Game) {
|
fun deleted(game: Game) {
|
||||||
GameService.emitUser(GameUserEvent.Deleted(game.id!!))
|
GameService.emitUser(GameUserEvent.Deleted(game.id!!))
|
||||||
GameService.emitAdmin(GameAdminEvent.Deleted(game.id!!))
|
GameService.emitAdmin(GameAdminEvent.Deleted(game.id!!))
|
||||||
|
EventPublisherHolder.publish(GameDeletedEvent(this, game))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
package org.gameyfin.app.games.entities
|
package org.gameyfin.app.games.entities
|
||||||
|
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.GeneratedValue
|
||||||
|
import jakarta.persistence.GenerationType
|
||||||
|
import jakarta.persistence.Id
|
||||||
import org.springframework.content.commons.annotations.ContentId
|
import org.springframework.content.commons.annotations.ContentId
|
||||||
import org.springframework.content.commons.annotations.ContentLength
|
import org.springframework.content.commons.annotations.ContentLength
|
||||||
import org.springframework.content.commons.annotations.MimeType
|
import org.springframework.content.commons.annotations.MimeType
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@EntityListeners(ImageEntityListener::class)
|
|
||||||
class Image(
|
class Image(
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
var id: Long? = null,
|
var id: Long? = null,
|
||||||
|
|
||||||
val originalUrl: URL? = null,
|
val originalUrl: String? = null,
|
||||||
|
|
||||||
val type: ImageType,
|
val type: ImageType,
|
||||||
|
|
||||||
@@ -25,19 +26,7 @@ class Image(
|
|||||||
|
|
||||||
@MimeType
|
@MimeType
|
||||||
var mimeType: String? = null
|
var mimeType: String? = null
|
||||||
) {
|
)
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (other !is Image) return false
|
|
||||||
return originalUrl.toString() == other.originalUrl.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var result = id?.hashCode() ?: 0
|
|
||||||
result = 31 * result + originalUrl?.toString().hashCode()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class ImageType {
|
enum class ImageType {
|
||||||
COVER,
|
COVER,
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package org.gameyfin.app.games.entities
|
|
||||||
|
|
||||||
import jakarta.persistence.PostRemove
|
|
||||||
import org.gameyfin.app.media.ImageService
|
|
||||||
import org.springframework.context.ApplicationContext
|
|
||||||
import org.springframework.context.ApplicationContextAware
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class ImageEntityListener : ApplicationContextAware {
|
|
||||||
companion object {
|
|
||||||
private lateinit var applicationContext: ApplicationContext
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setApplicationContext(context: ApplicationContext) {
|
|
||||||
applicationContext = context
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getImageService(): ImageService {
|
|
||||||
return applicationContext.getBean(ImageService::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostRemove
|
|
||||||
fun deleted(image: Image) {
|
|
||||||
getImageService().deleteFile(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,5 +2,17 @@ package org.gameyfin.app.games.repositories
|
|||||||
|
|
||||||
import org.gameyfin.app.games.entities.Game
|
import org.gameyfin.app.games.entities.Game
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
interface GameRepository : JpaRepository<Game, Long>
|
interface GameRepository : JpaRepository<Game, Long> {
|
||||||
|
@Query("SELECT g FROM Game g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release)")
|
||||||
|
fun findByTitleAndReleaseYear(
|
||||||
|
@Param("title") title: String,
|
||||||
|
@Param("release") release: Instant?
|
||||||
|
): List<Game>
|
||||||
|
|
||||||
|
@Query("SELECT CASE WHEN COUNT(g) > 0 THEN true ELSE false END FROM Game g WHERE g.coverImage.id = :imageId OR g.headerImage.id = :imageId OR :imageId IN (SELECT i.id FROM g.images i)")
|
||||||
|
fun existsByImage(@Param("imageId") imageId: Long): Boolean
|
||||||
|
}
|
||||||
@@ -2,8 +2,7 @@ package org.gameyfin.app.games.repositories
|
|||||||
|
|
||||||
import org.gameyfin.app.games.entities.Image
|
import org.gameyfin.app.games.entities.Image
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
interface ImageRepository : JpaRepository<Image, Long> {
|
interface ImageRepository : JpaRepository<Image, Long> {
|
||||||
fun findByOriginalUrl(originalUrl: URL): Image?
|
fun findByOriginalUrl(originalUrl: String): Image?
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ 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.entities.Library
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.gameyfin.app.libraries
|
package org.gameyfin.app.libraries
|
||||||
|
|
||||||
|
import org.gameyfin.app.libraries.entities.Library
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,14 @@ 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.games.entities.Image
|
import org.gameyfin.app.games.entities.Image
|
||||||
import org.gameyfin.app.libraries.dto.LibraryScanProgress
|
import org.gameyfin.app.libraries.dto.*
|
||||||
import org.gameyfin.app.libraries.dto.LibraryScanStatus
|
import org.gameyfin.app.libraries.entities.Library
|
||||||
import org.gameyfin.app.libraries.dto.LibraryScanStep
|
|
||||||
import org.gameyfin.app.libraries.enums.ScanType
|
import org.gameyfin.app.libraries.enums.ScanType
|
||||||
import org.gameyfin.app.libraries.scan.*
|
import org.gameyfin.app.libraries.scan.*
|
||||||
import org.gameyfin.app.media.ImageService
|
import org.gameyfin.app.media.ImageService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
import reactor.core.publisher.Sinks
|
import reactor.core.publisher.Sinks
|
||||||
import java.net.URL
|
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.concurrent.Callable
|
import java.util.concurrent.Callable
|
||||||
@@ -345,19 +343,13 @@ class LibraryScanService(
|
|||||||
// Deduplicate by originalUrl
|
// Deduplicate by originalUrl
|
||||||
val uniqueImages = allImages
|
val uniqueImages = allImages
|
||||||
.filter { it.originalUrl != null }
|
.filter { it.originalUrl != null }
|
||||||
.distinctBy { it.originalUrl }
|
.distinctBy { it.originalUrl.toString() }
|
||||||
|
|
||||||
// Map to track which Image entity was used for download per originalUrl
|
|
||||||
val downloadedImageMap = ConcurrentHashMap<URL, Image>()
|
|
||||||
|
|
||||||
// Download each unique image in parallel
|
// Download each unique image in parallel
|
||||||
val imageDownloadTasks = uniqueImages.map { image ->
|
val imageDownloadTasks = uniqueImages.map { image ->
|
||||||
Callable {
|
Callable {
|
||||||
try {
|
try {
|
||||||
imageService.downloadIfNew(image)
|
imageService.downloadIfNew(image)
|
||||||
image.originalUrl?.let { url ->
|
|
||||||
downloadedImageMap[url] = image
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.error { "Error downloading image '${image.originalUrl}': ${e.message}" }
|
log.error { "Error downloading image '${image.originalUrl}': ${e.message}" }
|
||||||
log.debug(e) {}
|
log.debug(e) {}
|
||||||
@@ -369,18 +361,20 @@ class LibraryScanService(
|
|||||||
}
|
}
|
||||||
executor.invokeAll(imageDownloadTasks)
|
executor.invokeAll(imageDownloadTasks)
|
||||||
|
|
||||||
// After downloads, associate the contentId with all other Image entities in the batch with the same originalUrl
|
// For remaining duplicate images, just copy the content metadata from the downloaded unique image
|
||||||
for ((url, downloadedImage) in downloadedImageMap) {
|
val uniqueImagesByUrl = uniqueImages.associateBy { it.originalUrl.toString() }
|
||||||
val contentId = downloadedImage.contentId
|
|
||||||
if (contentId != null) {
|
allImages.filter { it.originalUrl != null && it !in uniqueImages }
|
||||||
allImages.filter { it.originalUrl.toString() == url.toString() && it !== downloadedImage }
|
.forEach { duplicateImage ->
|
||||||
.forEach { image ->
|
val downloadedImage = uniqueImagesByUrl[duplicateImage.originalUrl.toString()]
|
||||||
imageService.downloadIfNew(image)
|
if (downloadedImage != null && downloadedImage.contentId != null) {
|
||||||
progress.currentStep.current = completedImageDownload.incrementAndGet()
|
duplicateImage.contentId = downloadedImage.contentId
|
||||||
emit(progress)
|
duplicateImage.contentLength = downloadedImage.contentLength
|
||||||
}
|
duplicateImage.mimeType = downloadedImage.mimeType
|
||||||
|
}
|
||||||
|
progress.currentStep.current = completedImageDownload.incrementAndGet()
|
||||||
|
emit(progress)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return DownloadImagesResult(gamesWithImages = games)
|
return DownloadImagesResult(gamesWithImages = games)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ 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.libraries.dto.*
|
import org.gameyfin.app.libraries.dto.*
|
||||||
|
import org.gameyfin.app.libraries.entities.DirectoryMapping
|
||||||
|
import org.gameyfin.app.libraries.entities.Library
|
||||||
import org.gameyfin.app.libraries.enums.ScanType
|
import org.gameyfin.app.libraries.enums.ScanType
|
||||||
import org.gameyfin.app.libraries.extensions.toDtos
|
import org.gameyfin.app.libraries.extensions.toDtos
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.gameyfin.app.libraries.dto
|
package org.gameyfin.app.libraries.dto
|
||||||
|
|
||||||
import org.gameyfin.app.libraries.LibraryScanResult
|
|
||||||
import org.gameyfin.app.libraries.enums.ScanType
|
import org.gameyfin.app.libraries.enums.ScanType
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package org.gameyfin.app.libraries
|
package org.gameyfin.app.libraries.dto
|
||||||
|
|
||||||
interface LibraryScanResult {
|
interface LibraryScanResult {
|
||||||
/**
|
/**
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package org.gameyfin.app.libraries
|
package org.gameyfin.app.libraries.entities
|
||||||
|
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
|
|
||||||
+2
-3
@@ -1,8 +1,7 @@
|
|||||||
package org.gameyfin.app.libraries
|
package org.gameyfin.app.libraries.entities
|
||||||
|
|
||||||
import org.gameyfin.app.games.entities.Game
|
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
import org.gameyfin.app.games.entities.LibraryEntityListener
|
import org.gameyfin.app.games.entities.Game
|
||||||
import org.hibernate.annotations.CreationTimestamp
|
import org.hibernate.annotations.CreationTimestamp
|
||||||
import org.hibernate.annotations.UpdateTimestamp
|
import org.hibernate.annotations.UpdateTimestamp
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
+1
-2
@@ -1,9 +1,8 @@
|
|||||||
package org.gameyfin.app.games.entities
|
package org.gameyfin.app.libraries.entities
|
||||||
|
|
||||||
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.LibraryService
|
import org.gameyfin.app.libraries.LibraryService
|
||||||
import org.gameyfin.app.libraries.dto.LibraryAdminEvent
|
import org.gameyfin.app.libraries.dto.LibraryAdminEvent
|
||||||
import org.gameyfin.app.libraries.dto.LibraryUserEvent
|
import org.gameyfin.app.libraries.dto.LibraryUserEvent
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package org.gameyfin.app.libraries.extensions
|
package org.gameyfin.app.libraries.extensions
|
||||||
|
|
||||||
import org.gameyfin.app.core.security.isCurrentUserAdmin
|
import org.gameyfin.app.core.security.isCurrentUserAdmin
|
||||||
import org.gameyfin.app.libraries.Library
|
|
||||||
import org.gameyfin.app.libraries.dto.*
|
import org.gameyfin.app.libraries.dto.*
|
||||||
|
import org.gameyfin.app.libraries.entities.Library
|
||||||
|
|
||||||
|
|
||||||
fun Library.toDto(): LibraryDto {
|
fun Library.toDto(): LibraryDto {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.gameyfin.app.core.Role
|
|||||||
import org.gameyfin.app.core.Utils
|
import org.gameyfin.app.core.Utils
|
||||||
import org.gameyfin.app.core.annotations.DynamicPublicAccess
|
import org.gameyfin.app.core.annotations.DynamicPublicAccess
|
||||||
import org.gameyfin.app.core.plugins.PluginService
|
import org.gameyfin.app.core.plugins.PluginService
|
||||||
|
import org.gameyfin.app.core.security.getCurrentAuth
|
||||||
import org.gameyfin.app.games.entities.Image
|
import org.gameyfin.app.games.entities.Image
|
||||||
import org.gameyfin.app.games.entities.ImageType
|
import org.gameyfin.app.games.entities.ImageType
|
||||||
import org.gameyfin.app.users.UserService
|
import org.gameyfin.app.users.UserService
|
||||||
@@ -15,8 +16,6 @@ import org.springframework.core.io.InputStreamResource
|
|||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.security.core.Authentication
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ class ImageEndpoint(
|
|||||||
@GetMapping("/plugins/{id}/logo")
|
@GetMapping("/plugins/{id}/logo")
|
||||||
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? {
|
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? {
|
||||||
val logo = pluginService.getLogo(pluginId)
|
val logo = pluginService.getLogo(pluginId)
|
||||||
return Utils.Companion.inputStreamToResponseEntity(logo)
|
return Utils.inputStreamToResponseEntity(logo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/avatar")
|
@GetMapping("/avatar")
|
||||||
@@ -61,10 +60,10 @@ class ImageEndpoint(
|
|||||||
@PermitAll
|
@PermitAll
|
||||||
@PostMapping("/avatar/upload")
|
@PostMapping("/avatar/upload")
|
||||||
fun uploadAvatar(@RequestParam("file") file: MultipartFile) {
|
fun uploadAvatar(@RequestParam("file") file: MultipartFile) {
|
||||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
|
|
||||||
val image: Image = if (!userService.hasAvatar(auth.name)) {
|
val image: Image = if (!userService.hasAvatar(auth.name)) {
|
||||||
imageService.createFile(ImageType.AVATAR, file.inputStream, file.contentType!!)
|
imageService.createFromInputStream(ImageType.AVATAR, file.inputStream, file.contentType!!)
|
||||||
} else {
|
} else {
|
||||||
val existingAvatar = userService.getAvatar(auth.name)!!
|
val existingAvatar = userService.getAvatar(auth.name)!!
|
||||||
imageService.updateFileContent(existingAvatar, file.inputStream, file.contentType!!)
|
imageService.updateFileContent(existingAvatar, file.inputStream, file.contentType!!)
|
||||||
@@ -76,7 +75,7 @@ class ImageEndpoint(
|
|||||||
@PermitAll
|
@PermitAll
|
||||||
@PostMapping("/avatar/delete")
|
@PostMapping("/avatar/delete")
|
||||||
fun deleteAvatar() {
|
fun deleteAvatar() {
|
||||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
userService.deleteAvatar(auth.name)
|
userService.deleteAvatar(auth.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +85,7 @@ class ImageEndpoint(
|
|||||||
userService.deleteAvatar(name)
|
userService.deleteAvatar(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getImageContent(id: Long): ResponseEntity<InputStreamResource>? {
|
private fun getImageContent(id: Long): ResponseEntity<InputStreamResource> {
|
||||||
val image = imageService.getImage(id) ?: return ResponseEntity.notFound().build()
|
val image = imageService.getImage(id) ?: return ResponseEntity.notFound().build()
|
||||||
|
|
||||||
val file = image.let { imageService.getFileContent(it) }
|
val file = image.let { imageService.getFileContent(it) }
|
||||||
|
|||||||
@@ -2,40 +2,119 @@ package org.gameyfin.app.media
|
|||||||
|
|
||||||
import org.apache.tika.Tika
|
import org.apache.tika.Tika
|
||||||
import org.apache.tika.io.TikaInputStream
|
import org.apache.tika.io.TikaInputStream
|
||||||
|
import org.gameyfin.app.core.events.GameDeletedEvent
|
||||||
|
import org.gameyfin.app.core.events.GameUpdatedEvent
|
||||||
|
import org.gameyfin.app.core.events.UserDeletedEvent
|
||||||
|
import org.gameyfin.app.core.events.UserUpdatedEvent
|
||||||
import org.gameyfin.app.games.entities.Image
|
import org.gameyfin.app.games.entities.Image
|
||||||
import org.gameyfin.app.games.entities.ImageType
|
import org.gameyfin.app.games.entities.ImageType
|
||||||
|
import org.gameyfin.app.games.repositories.GameRepository
|
||||||
import org.gameyfin.app.games.repositories.ImageContentStore
|
import org.gameyfin.app.games.repositories.ImageContentStore
|
||||||
import org.gameyfin.app.games.repositories.ImageRepository
|
import org.gameyfin.app.games.repositories.ImageRepository
|
||||||
|
import org.gameyfin.app.users.persistence.UserRepository
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.event.TransactionPhase
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ImageService(
|
class ImageService(
|
||||||
private val imageRepository: ImageRepository,
|
private val imageRepository: ImageRepository,
|
||||||
private val imageContentStore: ImageContentStore
|
private val imageContentStore: ImageContentStore,
|
||||||
|
private val gameRepository: GameRepository,
|
||||||
|
private val userRepository: UserRepository
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private val tika = Tika();
|
private val tika = Tika()
|
||||||
|
}
|
||||||
|
|
||||||
|
@TransactionalEventListener(
|
||||||
|
classes = [GameDeletedEvent::class],
|
||||||
|
phase = TransactionPhase.AFTER_COMPLETION
|
||||||
|
)
|
||||||
|
fun onGameDeleted(event: GameDeletedEvent) {
|
||||||
|
val imagesToDelete = listOfNotNull(event.game.coverImage, event.game.headerImage)
|
||||||
|
.toMutableList()
|
||||||
|
.apply { addAll(event.game.images) }
|
||||||
|
|
||||||
|
imagesToDelete.forEach { deleteImageIfUnused(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TransactionalEventListener(
|
||||||
|
classes = [GameUpdatedEvent::class],
|
||||||
|
phase = TransactionPhase.AFTER_COMPLETION
|
||||||
|
)
|
||||||
|
fun onGameUpdated(event: GameUpdatedEvent) {
|
||||||
|
val imagesBeforeUpdate = listOfNotNull(event.previousState.coverImage, event.previousState.headerImage)
|
||||||
|
.toMutableList()
|
||||||
|
.apply { addAll(event.previousState.images) }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
val imagesStillInUse = listOfNotNull(event.currentState.coverImage, event.currentState.headerImage)
|
||||||
|
.toMutableList()
|
||||||
|
.apply { addAll(event.currentState.images) }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
imagesBeforeUpdate.minus(imagesStillInUse).forEach { deleteImageIfUnused(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TransactionalEventListener(
|
||||||
|
classes = [UserDeletedEvent::class],
|
||||||
|
phase = TransactionPhase.AFTER_COMPLETION
|
||||||
|
)
|
||||||
|
fun onAccountDeleted(event: UserDeletedEvent) {
|
||||||
|
event.user.avatar?.let { deleteImageIfUnused(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TransactionalEventListener(
|
||||||
|
classes = [UserUpdatedEvent::class],
|
||||||
|
phase = TransactionPhase.AFTER_COMPLETION
|
||||||
|
)
|
||||||
|
fun onUserUpdated(event: UserUpdatedEvent) {
|
||||||
|
event.previousState.avatar?.let { previousAvatar ->
|
||||||
|
if (previousAvatar != event.currentState.avatar) {
|
||||||
|
deleteImageIfUnused(previousAvatar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createOrGet(image: Image): Image {
|
||||||
|
if (image.originalUrl != null) {
|
||||||
|
imageRepository.findByOriginalUrl(image.originalUrl)?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageRepository.save(image)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun downloadIfNew(image: Image) {
|
fun downloadIfNew(image: Image) {
|
||||||
if (image.originalUrl == null) throw IllegalArgumentException("Image must have an original URL")
|
if (image.originalUrl == null) throw IllegalArgumentException("Image must have an original URL")
|
||||||
|
|
||||||
|
// Always try to get existing image first to avoid detached entity issues
|
||||||
val existingImage = imageRepository.findByOriginalUrl(image.originalUrl)
|
val existingImage = imageRepository.findByOriginalUrl(image.originalUrl)
|
||||||
|
|
||||||
if (existingImage != null && existingImage.contentId != null) {
|
if (existingImage != null && existingImage.contentId != null) {
|
||||||
|
// If we have an existing image with content, associate it with the current image
|
||||||
imageContentStore.associate(image, existingImage.contentId)
|
imageContentStore.associate(image, existingImage.contentId)
|
||||||
|
// Update the current image's content metadata
|
||||||
|
image.contentId = existingImage.contentId
|
||||||
|
image.contentLength = existingImage.contentLength
|
||||||
|
image.mimeType = existingImage.mimeType
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
TikaInputStream.get { image.originalUrl.openStream() }.use { input ->
|
// If no existing image or existing image has no content, download it
|
||||||
|
TikaInputStream.get { URI.create(image.originalUrl).toURL().openStream() }.use { input ->
|
||||||
image.mimeType = tika.detect(input)
|
image.mimeType = tika.detect(input)
|
||||||
imageContentStore.setContent(image, input)
|
imageContentStore.setContent(image, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save the image to ensure it's persisted
|
||||||
|
imageRepository.save(image)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createFile(type: ImageType, content: InputStream, mimeType: String): Image {
|
fun createFromInputStream(type: ImageType, content: InputStream, mimeType: String): Image {
|
||||||
val image = Image(type = type, mimeType = mimeType)
|
val image = Image(type = type, mimeType = mimeType)
|
||||||
imageRepository.save(image)
|
imageRepository.save(image)
|
||||||
return imageContentStore.setContent(image, content)
|
return imageContentStore.setContent(image, content)
|
||||||
@@ -45,19 +124,20 @@ class ImageService(
|
|||||||
return imageRepository.findByIdOrNull(id)
|
return imageRepository.findByIdOrNull(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getFileContent(id: Long): InputStream? {
|
|
||||||
val image = getImage(id) ?: return null
|
|
||||||
return getFileContent(image)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFileContent(image: Image): InputStream? {
|
fun getFileContent(image: Image): InputStream? {
|
||||||
return imageContentStore.getContent(image)
|
return imageContentStore.getContent(image)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteFile(image: Image) {
|
fun deleteImageIfUnused(image: Image) {
|
||||||
imageContentStore.unsetContent(image)
|
val imageId = image.id ?: return
|
||||||
imageRepository.delete(image)
|
|
||||||
|
val isImageStillInUse = gameRepository.existsByImage(imageId) || userRepository.existsByAvatar(imageId)
|
||||||
|
|
||||||
|
if (!isImageStillInUse) {
|
||||||
|
imageContentStore.unsetContent(image)
|
||||||
|
imageRepository.delete(image)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image {
|
fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.gameyfin.app.messages
|
|||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.gameyfin.app.core.events.*
|
import org.gameyfin.app.core.events.*
|
||||||
|
import org.gameyfin.app.core.security.getCurrentAuth
|
||||||
import org.gameyfin.app.messages.providers.AbstractMessageProvider
|
import org.gameyfin.app.messages.providers.AbstractMessageProvider
|
||||||
import org.gameyfin.app.messages.templates.MessageTemplateService
|
import org.gameyfin.app.messages.templates.MessageTemplateService
|
||||||
import org.gameyfin.app.messages.templates.MessageTemplates
|
import org.gameyfin.app.messages.templates.MessageTemplates
|
||||||
@@ -10,8 +11,6 @@ import org.gameyfin.app.users.UserService
|
|||||||
import org.springframework.context.ApplicationContext
|
import org.springframework.context.ApplicationContext
|
||||||
import org.springframework.context.event.EventListener
|
import org.springframework.context.event.EventListener
|
||||||
import org.springframework.scheduling.annotation.Async
|
import org.springframework.scheduling.annotation.Async
|
||||||
import org.springframework.security.core.Authentication
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ class MessageService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
|
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
|
||||||
val template = templateService.getMessageTemplate(templateKey)
|
val template = templateService.getMessageTemplate(templateKey)
|
||||||
sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders)
|
sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders)
|
||||||
@@ -207,8 +206,8 @@ class MessageService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Async
|
@Async
|
||||||
@EventListener(AccountDeletedEvent::class)
|
@EventListener(UserDeletedEvent::class)
|
||||||
fun onAccountDeletion(event: AccountDeletedEvent) {
|
fun onAccountDeletion(event: UserDeletedEvent) {
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
log.error { "No message provider available, can't send account deletion message" }
|
log.error { "No message provider available, can't send account deletion message" }
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package org.gameyfin.app.requests
|
||||||
|
|
||||||
|
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||||
|
import com.vaadin.hilla.Endpoint
|
||||||
|
import jakarta.annotation.security.PermitAll
|
||||||
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
import org.gameyfin.app.config.ConfigService
|
||||||
|
import org.gameyfin.app.core.Role
|
||||||
|
import org.gameyfin.app.core.annotations.DynamicPublicAccess
|
||||||
|
import org.gameyfin.app.requests.dto.GameRequestCreationDto
|
||||||
|
import org.gameyfin.app.requests.dto.GameRequestEvent
|
||||||
|
import org.gameyfin.app.requests.status.GameRequestStatus
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import reactor.core.publisher.Flux
|
||||||
|
|
||||||
|
@Endpoint
|
||||||
|
@Service
|
||||||
|
@DynamicPublicAccess
|
||||||
|
@AnonymousAllowed
|
||||||
|
class GameRequestEndpoint(
|
||||||
|
private val gameRequestService: GameRequestService,
|
||||||
|
private val config: ConfigService
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun subscribe(): Flux<List<GameRequestEvent>> {
|
||||||
|
return GameRequestService.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAll() = gameRequestService.getAll()
|
||||||
|
|
||||||
|
fun create(gameRequest: GameRequestCreationDto) {
|
||||||
|
gameRequestService.createRequest(gameRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PermitAll
|
||||||
|
fun toggleVote(gameRequestId: Long) {
|
||||||
|
gameRequestService.toggleRequestVote(gameRequestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PermitAll
|
||||||
|
fun delete(gameRequestId: Long) {
|
||||||
|
gameRequestService.deleteRequest(gameRequestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
|
fun changeStatus(gameRequestId: Long, newStatus: GameRequestStatus) {
|
||||||
|
gameRequestService.changeRequestStatus(gameRequestId, newStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.gameyfin.app.requests
|
||||||
|
|
||||||
|
import org.gameyfin.app.requests.entities.GameRequest
|
||||||
|
import org.gameyfin.app.requests.status.GameRequestStatus
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
interface GameRequestRepository : JpaRepository<GameRequest, Long> {
|
||||||
|
@Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release)")
|
||||||
|
fun findByTitleAndReleaseYear(
|
||||||
|
@Param("title") title: String,
|
||||||
|
@Param("release") release: Instant?
|
||||||
|
): List<GameRequest>
|
||||||
|
|
||||||
|
@Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release) AND g.status NOT IN (:excludedStatuses)")
|
||||||
|
fun findRequestsByTitleAndReleaseYearAndStatusNotIn(
|
||||||
|
@Param("title") title: String,
|
||||||
|
@Param("release") release: Instant?,
|
||||||
|
@Param("excludedStatuses") excludedStatuses: List<GameRequestStatus>
|
||||||
|
): List<GameRequest>
|
||||||
|
|
||||||
|
@Query("SELECT g FROM GameRequest g WHERE g.requester.id = :requesterId AND g.status IN (:statuses)")
|
||||||
|
fun findRequestsByRequesterIdAndStatusIn(
|
||||||
|
@Param("requesterId") requesterId: Long?,
|
||||||
|
@Param("statuses") statuses: List<GameRequestStatus>
|
||||||
|
): List<GameRequest>
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package org.gameyfin.app.requests
|
||||||
|
|
||||||
|
import com.vaadin.hilla.exception.EndpointException
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.gameyfin.app.config.ConfigProperties
|
||||||
|
import org.gameyfin.app.config.ConfigService
|
||||||
|
import org.gameyfin.app.core.events.GameCreatedEvent
|
||||||
|
import org.gameyfin.app.core.security.getCurrentAuth
|
||||||
|
import org.gameyfin.app.core.security.isAdmin
|
||||||
|
import org.gameyfin.app.games.repositories.GameRepository
|
||||||
|
import org.gameyfin.app.requests.dto.GameRequestCreationDto
|
||||||
|
import org.gameyfin.app.requests.dto.GameRequestDto
|
||||||
|
import org.gameyfin.app.requests.dto.GameRequestEvent
|
||||||
|
import org.gameyfin.app.requests.entities.GameRequest
|
||||||
|
import org.gameyfin.app.requests.extensions.toDto
|
||||||
|
import org.gameyfin.app.requests.extensions.toDtos
|
||||||
|
import org.gameyfin.app.requests.status.GameRequestStatus
|
||||||
|
import org.gameyfin.app.users.UserService
|
||||||
|
import org.gameyfin.app.users.entities.User
|
||||||
|
import org.springframework.context.event.EventListener
|
||||||
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import reactor.core.publisher.Flux
|
||||||
|
import reactor.core.publisher.Sinks
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.toJavaDuration
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class GameRequestService(
|
||||||
|
private val config: ConfigService,
|
||||||
|
private val userService: UserService,
|
||||||
|
private val gameRequestRepository: GameRequestRepository,
|
||||||
|
private val gameRepository: GameRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
/* Websockets */
|
||||||
|
private val gameRequestEvents = Sinks.many().multicast().onBackpressureBuffer<GameRequestEvent>(1024, false)
|
||||||
|
|
||||||
|
fun subscribe(): Flux<List<GameRequestEvent>> {
|
||||||
|
log.debug { "New user subscription for gameRequestEvents" }
|
||||||
|
return gameRequestEvents.asFlux()
|
||||||
|
.buffer(100.milliseconds.toJavaDuration())
|
||||||
|
.doOnSubscribe {
|
||||||
|
log.debug { "Subscriber added to gameRequestEvents [${gameRequestEvents.currentSubscriberCount()}]" }
|
||||||
|
}
|
||||||
|
.doFinally {
|
||||||
|
log.debug { "Subscriber removed from gameRequestEvents with signal type $it [${gameRequestEvents.currentSubscriberCount()}]" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emit(event: GameRequestEvent) {
|
||||||
|
gameRequestEvents.tryEmitNext(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAll(): List<GameRequestDto> {
|
||||||
|
val entities = gameRequestRepository.findAll()
|
||||||
|
return entities.toDtos()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createRequest(gameRequest: GameRequestCreationDto) {
|
||||||
|
|
||||||
|
// Check if requests are enabled
|
||||||
|
if (config.get(ConfigProperties.Requests.Games.Enabled) != true) {
|
||||||
|
throw EndpointException("Game requests are disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if game is already available
|
||||||
|
val existingGames = gameRepository.findByTitleAndReleaseYear(gameRequest.title, gameRequest.release)
|
||||||
|
if (existingGames.isNotEmpty()) {
|
||||||
|
throw EndpointException(
|
||||||
|
"This game is already available (ID: ${existingGames[0].id})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a request with the same title and release year already exists
|
||||||
|
val existingRequests = gameRequestRepository.findByTitleAndReleaseYear(
|
||||||
|
gameRequest.title,
|
||||||
|
gameRequest.release
|
||||||
|
)
|
||||||
|
if (existingRequests.isNotEmpty()) {
|
||||||
|
throw EndpointException("A request for this game already exists (ID: ${existingRequests[0].id})")
|
||||||
|
}
|
||||||
|
|
||||||
|
val auth = getCurrentAuth()
|
||||||
|
val currentUser = auth?.let { userService.getByUsername(it.name) }
|
||||||
|
|
||||||
|
// Check if guests are allowed to create requests
|
||||||
|
if (config.get(ConfigProperties.Requests.Games.AllowGuestsToRequestGames) != true && currentUser == null) {
|
||||||
|
throw EndpointException("Only registered users can submit game requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has too many open requests (0 means no limit per user)
|
||||||
|
// Note: All guests are treated as a single user with null ID and thus share their request limit
|
||||||
|
// Note: Admins are exempt from this limit
|
||||||
|
val pendingRequestsForUser = gameRequestRepository.findRequestsByRequesterIdAndStatusIn(
|
||||||
|
currentUser?.id,
|
||||||
|
listOf(GameRequestStatus.PENDING)
|
||||||
|
)
|
||||||
|
val maxRequestsPerUser = config.get(ConfigProperties.Requests.Games.MaxOpenRequestsPerUser) ?: 0
|
||||||
|
if (maxRequestsPerUser == 0 || (auth?.isAdmin() != true && pendingRequestsForUser.size >= maxRequestsPerUser)) {
|
||||||
|
throw EndpointException("You have reached the maximum number of pending requests (${maxRequestsPerUser})")
|
||||||
|
}
|
||||||
|
|
||||||
|
val newGameRequest = GameRequest(
|
||||||
|
title = gameRequest.title,
|
||||||
|
release = gameRequest.release,
|
||||||
|
status = GameRequestStatus.PENDING,
|
||||||
|
requester = currentUser,
|
||||||
|
voters = mutableSetOf<User>().apply {
|
||||||
|
currentUser?.let { add(it) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
gameRequestRepository.save(newGameRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteRequest(id: Long) {
|
||||||
|
val gameRequest = gameRequestRepository.findById(id)
|
||||||
|
.orElseThrow { NoSuchElementException("No game request found with id $id") }
|
||||||
|
|
||||||
|
val auth = getCurrentAuth()
|
||||||
|
val currentUser = auth?.let { userService.getByUsername(it.name) }
|
||||||
|
val requester = gameRequest.requester
|
||||||
|
|
||||||
|
// Check if the current user is the requester or an admin
|
||||||
|
// Note: Requests submitted by guests (request is null) can only be deleted by an admin
|
||||||
|
if (auth?.isAdmin() != true && (requester == null || requester.id != currentUser?.id)) {
|
||||||
|
throw EndpointException("Only the requester or an admin can delete a game request")
|
||||||
|
}
|
||||||
|
|
||||||
|
gameRequestRepository.delete(gameRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeRequestStatus(id: Long, status: GameRequestStatus) {
|
||||||
|
val gameRequest = gameRequestRepository.findById(id)
|
||||||
|
.orElseThrow { NoSuchElementException("No game request found with id $id") }
|
||||||
|
|
||||||
|
if (gameRequest.status == GameRequestStatus.FULFILLED) {
|
||||||
|
log.debug { "Status of requests with status ${GameRequestStatus.FULFILLED} can't be changed" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gameRequest.status = status
|
||||||
|
gameRequestRepository.save(gameRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun toggleRequestVote(id: Long) {
|
||||||
|
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
|
val currentUser =
|
||||||
|
userService.getByUsername(auth.name) ?: throw IllegalStateException("Current user not found")
|
||||||
|
val gameRequest = gameRequestRepository.findById(id)
|
||||||
|
.orElseThrow { NoSuchElementException("No game request found with id $id") }
|
||||||
|
|
||||||
|
// Replace the voters collection to ensure Hibernate detects the change
|
||||||
|
val updatedVoters = gameRequest.voters.toMutableSet()
|
||||||
|
if (updatedVoters.contains(currentUser)) {
|
||||||
|
updatedVoters.remove(currentUser)
|
||||||
|
} else {
|
||||||
|
updatedVoters.add(currentUser)
|
||||||
|
}
|
||||||
|
gameRequest.voters = updatedVoters
|
||||||
|
|
||||||
|
// Ensure the entity is marked as dirty
|
||||||
|
gameRequest.status = gameRequest.status
|
||||||
|
|
||||||
|
gameRequestRepository.save(gameRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener(GameCreatedEvent::class)
|
||||||
|
fun completeMatchingRequests(gameCreatedEvent: GameCreatedEvent) {
|
||||||
|
val game = gameCreatedEvent.game
|
||||||
|
val gameTitle = game.title
|
||||||
|
val gameRelease = game.release
|
||||||
|
|
||||||
|
if (gameTitle == null) {
|
||||||
|
log.warn { "Game '${game.id}' is missing title, cannot complete matching requests" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val matchingRequests = gameRequestRepository.findRequestsByTitleAndReleaseYearAndStatusNotIn(
|
||||||
|
gameTitle,
|
||||||
|
gameRelease,
|
||||||
|
listOf(GameRequestStatus.FULFILLED)
|
||||||
|
)
|
||||||
|
|
||||||
|
matchingRequests.forEach { request ->
|
||||||
|
request.status = GameRequestStatus.FULFILLED
|
||||||
|
request.linkedGameId = game.id
|
||||||
|
val persistedRequest = gameRequestRepository.save(request)
|
||||||
|
emit(GameRequestEvent.Updated(persistedRequest.toDto()))
|
||||||
|
log.info { "Marked game request '${request.title}' (${request.release}) as FULFILLED because game is now available" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.gameyfin.app.requests.dto
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
|
||||||
|
class GameRequestCreationDto(
|
||||||
|
val title: String,
|
||||||
|
val release: Instant?
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.gameyfin.app.requests.dto
|
||||||
|
|
||||||
|
import org.gameyfin.app.requests.status.GameRequestStatus
|
||||||
|
import org.gameyfin.app.users.dto.UserInfoDto
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
class GameRequestDto(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val release: Instant?,
|
||||||
|
val status: GameRequestStatus,
|
||||||
|
val requester: UserInfoDto?,
|
||||||
|
val voters: List<UserInfoDto>,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val updatedAt: Instant
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.gameyfin.app.requests.dto
|
||||||
|
|
||||||
|
sealed class GameRequestEvent {
|
||||||
|
abstract val type: String
|
||||||
|
|
||||||
|
data class Created(val gameRequest: GameRequestDto, override val type: String = "created") : GameRequestEvent()
|
||||||
|
data class Updated(val gameRequest: GameRequestDto, override val type: String = "updated") : GameRequestEvent()
|
||||||
|
data class Deleted(val gameRequestId: Long, override val type: String = "deleted") : GameRequestEvent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package org.gameyfin.app.requests.entities
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import org.gameyfin.app.requests.status.GameRequestStatus
|
||||||
|
import org.gameyfin.app.users.entities.User
|
||||||
|
import org.hibernate.annotations.CreationTimestamp
|
||||||
|
import org.hibernate.annotations.OnDelete
|
||||||
|
import org.hibernate.annotations.OnDeleteAction
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@EntityListeners(GameRequestEntityListener::class)
|
||||||
|
class GameRequest(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
|
var id: Long? = null,
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
val title: String,
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
val release: Instant?,
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
var status: GameRequestStatus,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.EAGER)
|
||||||
|
@OnDelete(action = OnDeleteAction.SET_NULL)
|
||||||
|
var requester: User? = null,
|
||||||
|
|
||||||
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
|
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||||
|
var voters: MutableSet<User> = mutableSetOf(),
|
||||||
|
|
||||||
|
var linkedGameId: Long? = null,
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
|
var createdAt: Instant? = null,
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(nullable = false)
|
||||||
|
var updatedAt: Instant? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package org.gameyfin.app.requests.entities
|
||||||
|
|
||||||
|
import jakarta.persistence.PostPersist
|
||||||
|
import jakarta.persistence.PostRemove
|
||||||
|
import jakarta.persistence.PostUpdate
|
||||||
|
import org.gameyfin.app.requests.GameRequestService
|
||||||
|
import org.gameyfin.app.requests.dto.GameRequestEvent
|
||||||
|
import org.gameyfin.app.requests.extensions.toDto
|
||||||
|
|
||||||
|
class GameRequestEntityListener {
|
||||||
|
@PostPersist
|
||||||
|
fun created(gameRequest: GameRequest) {
|
||||||
|
GameRequestService.emit(GameRequestEvent.Created(gameRequest.toDto()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostUpdate
|
||||||
|
fun updated(gameRequest: GameRequest) {
|
||||||
|
GameRequestService.emit(GameRequestEvent.Updated(gameRequest.toDto()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostRemove
|
||||||
|
fun deleted(gameRequest: GameRequest) {
|
||||||
|
GameRequestService.emit(GameRequestEvent.Deleted(gameRequest.id!!))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.gameyfin.app.requests.extensions
|
||||||
|
|
||||||
|
import org.gameyfin.app.requests.dto.GameRequestDto
|
||||||
|
import org.gameyfin.app.requests.entities.GameRequest
|
||||||
|
import org.gameyfin.app.users.extensions.toUserInfoDto
|
||||||
|
|
||||||
|
fun GameRequest.toDto(): GameRequestDto {
|
||||||
|
return GameRequestDto(
|
||||||
|
id = this.id!!,
|
||||||
|
title = this.title,
|
||||||
|
release = this.release,
|
||||||
|
status = this.status,
|
||||||
|
requester = this.requester?.toUserInfoDto(),
|
||||||
|
voters = this.voters.map { it.toUserInfoDto() },
|
||||||
|
createdAt = this.createdAt!!,
|
||||||
|
updatedAt = this.updatedAt!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Collection<GameRequest>.toDtos(): List<GameRequestDto> {
|
||||||
|
return this.map { it.toDto() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.gameyfin.app.requests.status
|
||||||
|
|
||||||
|
enum class GameRequestStatus {
|
||||||
|
PENDING,
|
||||||
|
APPROVED,
|
||||||
|
REJECTED,
|
||||||
|
FULFILLED
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package org.gameyfin.app.setup
|
|||||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
import com.vaadin.hilla.exception.EndpointException
|
import com.vaadin.hilla.exception.EndpointException
|
||||||
import org.gameyfin.app.users.dto.UserInfoDto
|
import org.gameyfin.app.users.dto.ExtendedUserInfoDto
|
||||||
import org.gameyfin.app.users.dto.UserRegistrationDto
|
import org.gameyfin.app.users.dto.UserRegistrationDto
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@@ -16,7 +16,7 @@ class SetupEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@AnonymousAllowed
|
@AnonymousAllowed
|
||||||
fun registerSuperAdmin(superAdminRegistration: UserRegistrationDto): UserInfoDto {
|
fun registerSuperAdmin(superAdminRegistration: UserRegistrationDto): ExtendedUserInfoDto {
|
||||||
if (setupService.isSetupCompleted()) throw EndpointException("Setup already completed")
|
if (setupService.isSetupCompleted()) throw EndpointException("Setup already completed")
|
||||||
return setupService.createInitialAdminUser(superAdminRegistration)
|
return setupService.createInitialAdminUser(superAdminRegistration)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package org.gameyfin.app.setup
|
package org.gameyfin.app.setup
|
||||||
|
|
||||||
import org.gameyfin.app.users.UserService
|
|
||||||
import org.gameyfin.app.core.Role
|
import org.gameyfin.app.core.Role
|
||||||
import org.gameyfin.app.users.RoleService
|
import org.gameyfin.app.users.RoleService
|
||||||
import org.gameyfin.app.users.dto.UserInfoDto
|
import org.gameyfin.app.users.UserService
|
||||||
|
import org.gameyfin.app.users.dto.ExtendedUserInfoDto
|
||||||
import org.gameyfin.app.users.dto.UserRegistrationDto
|
import org.gameyfin.app.users.dto.UserRegistrationDto
|
||||||
import org.gameyfin.app.users.entities.User
|
import org.gameyfin.app.users.entities.User
|
||||||
|
import org.gameyfin.app.users.extensions.toExtendedUserInfoDto
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -26,7 +27,7 @@ class SetupService(
|
|||||||
/**
|
/**
|
||||||
* Creates the initial user with Super-Admin permissions
|
* Creates the initial user with Super-Admin permissions
|
||||||
*/
|
*/
|
||||||
fun createInitialAdminUser(registration: UserRegistrationDto): UserInfoDto {
|
fun createInitialAdminUser(registration: UserRegistrationDto): ExtendedUserInfoDto {
|
||||||
val superAdmin = User(
|
val superAdmin = User(
|
||||||
username = registration.username,
|
username = registration.username,
|
||||||
password = registration.password,
|
password = registration.password,
|
||||||
@@ -36,6 +37,6 @@ class SetupService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val user = userService.registerOrUpdateUser(superAdmin)
|
val user = userService.registerOrUpdateUser(superAdmin)
|
||||||
return userService.toUserInfo(user)
|
return user.toExtendedUserInfoDto()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package org.gameyfin.app.users
|
package org.gameyfin.app.users
|
||||||
|
|
||||||
|
import org.gameyfin.app.core.security.getCurrentAuth
|
||||||
import org.gameyfin.app.users.entities.User
|
import org.gameyfin.app.users.entities.User
|
||||||
import org.springframework.security.core.Authentication
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
import org.springframework.security.core.session.SessionInformation
|
import org.springframework.security.core.session.SessionInformation
|
||||||
import org.springframework.security.core.session.SessionRegistry
|
import org.springframework.security.core.session.SessionRegistry
|
||||||
@@ -11,14 +11,12 @@ import org.springframework.stereotype.Service
|
|||||||
class SessionService(private val sessionRegistry: SessionRegistry) {
|
class SessionService(private val sessionRegistry: SessionRegistry) {
|
||||||
|
|
||||||
fun logoutAllSessions() {
|
fun logoutAllSessions() {
|
||||||
val auth: Authentication? = SecurityContextHolder.getContext().authentication
|
val auth = getCurrentAuth()
|
||||||
if (auth != null) {
|
val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(auth?.principal, false)
|
||||||
val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(auth.principal, false)
|
for (sessionInfo in sessions) {
|
||||||
for (sessionInfo in sessions) {
|
sessionInfo.expireNow()
|
||||||
sessionInfo.expireNow()
|
|
||||||
}
|
|
||||||
SecurityContextHolder.clearContext()
|
|
||||||
}
|
}
|
||||||
|
SecurityContextHolder.clearContext()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun logoutAllSessions(user: User) {
|
fun logoutAllSessions(user: User) {
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ 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.dto.UserInfoDto
|
import org.gameyfin.app.core.security.getCurrentAuth
|
||||||
|
import org.gameyfin.app.users.dto.ExtendedUserInfoDto
|
||||||
import org.gameyfin.app.users.dto.UserUpdateDto
|
import org.gameyfin.app.users.dto.UserUpdateDto
|
||||||
import org.gameyfin.app.users.enums.RoleAssignmentResult
|
import org.gameyfin.app.users.enums.RoleAssignmentResult
|
||||||
import org.springframework.security.core.Authentication
|
import org.springframework.security.core.Authentication
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
|
||||||
|
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@@ -18,15 +18,15 @@ class UserEndpoint(
|
|||||||
private val roleService: RoleService
|
private val roleService: RoleService
|
||||||
) {
|
) {
|
||||||
@AnonymousAllowed
|
@AnonymousAllowed
|
||||||
fun getUserInfo(): UserInfoDto? {
|
fun getUserInfo(): ExtendedUserInfoDto? {
|
||||||
val auth = SecurityContextHolder.getContext().authentication
|
val auth = getCurrentAuth()
|
||||||
if (!auth.isAuthenticated || auth.principal == "anonymousUser") return null
|
if (auth?.isAuthenticated == false || auth?.principal == "anonymousUser") return null
|
||||||
return userService.getUserInfo()
|
return userService.getUserInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PermitAll
|
@PermitAll
|
||||||
fun updateUser(updates: UserUpdateDto) {
|
fun updateUser(updates: UserUpdateDto) {
|
||||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
userService.updateUser(auth.name, updates)
|
userService.updateUser(auth.name, updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ class UserEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@RolesAllowed(Role.Names.ADMIN)
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun getAllUsers(): List<UserInfoDto> {
|
fun getAllUsers(): List<ExtendedUserInfoDto> {
|
||||||
return userService.getAllUsers()
|
return userService.getAllUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class UserEndpoint(
|
|||||||
|
|
||||||
@PermitAll
|
@PermitAll
|
||||||
fun deleteUser() {
|
fun deleteUser() {
|
||||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
userService.deleteUser(auth.name)
|
userService.deleteUser(auth.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ class UserEndpoint(
|
|||||||
|
|
||||||
@RolesAllowed(Role.Names.ADMIN)
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun getRolesBelow(): List<String> {
|
fun getRolesBelow(): List<String> {
|
||||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
return roleService.getRolesBelowAuth(auth).map { it.roleName }
|
return roleService.getRolesBelowAuth(auth).map { it.roleName }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,22 @@ import org.gameyfin.app.config.ConfigProperties
|
|||||||
import org.gameyfin.app.config.ConfigService
|
import org.gameyfin.app.config.ConfigService
|
||||||
import org.gameyfin.app.core.Role
|
import org.gameyfin.app.core.Role
|
||||||
import org.gameyfin.app.core.Utils
|
import org.gameyfin.app.core.Utils
|
||||||
import org.gameyfin.app.core.events.*
|
import org.gameyfin.app.core.events.AccountStatusChangedEvent
|
||||||
|
import org.gameyfin.app.core.events.EmailNeedsConfirmationEvent
|
||||||
|
import org.gameyfin.app.core.events.RegistrationAttemptWithExistingEmailEvent
|
||||||
|
import org.gameyfin.app.core.events.UserRegistrationWaitingForApprovalEvent
|
||||||
|
import org.gameyfin.app.core.security.getCurrentAuth
|
||||||
import org.gameyfin.app.games.entities.Image
|
import org.gameyfin.app.games.entities.Image
|
||||||
import org.gameyfin.app.media.ImageService
|
import org.gameyfin.app.media.ImageService
|
||||||
import org.gameyfin.app.users.dto.UserInfoDto
|
import org.gameyfin.app.users.dto.ExtendedUserInfoDto
|
||||||
import org.gameyfin.app.users.dto.UserRegistrationDto
|
import org.gameyfin.app.users.dto.UserRegistrationDto
|
||||||
import org.gameyfin.app.users.dto.UserUpdateDto
|
import org.gameyfin.app.users.dto.UserUpdateDto
|
||||||
import org.gameyfin.app.users.emailconfirmation.EmailConfirmationService
|
import org.gameyfin.app.users.emailconfirmation.EmailConfirmationService
|
||||||
import org.gameyfin.app.users.enums.RoleAssignmentResult
|
import org.gameyfin.app.users.enums.RoleAssignmentResult
|
||||||
|
import org.gameyfin.app.users.extensions.toAuthorities
|
||||||
|
import org.gameyfin.app.users.extensions.toExtendedUserInfoDto
|
||||||
import org.gameyfin.app.users.persistence.UserRepository
|
import org.gameyfin.app.users.persistence.UserRepository
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.security.core.GrantedAuthority
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
|
||||||
import org.springframework.security.core.userdetails.User
|
import org.springframework.security.core.userdetails.User
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService
|
import org.springframework.security.core.userdetails.UserDetailsService
|
||||||
@@ -56,7 +59,7 @@ class UserService(
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
toAuthorities(user.roles)
|
user.roles.toAuthorities()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +70,7 @@ class UserService(
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
toAuthorities(user.roles)
|
user.roles.toAuthorities()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +80,8 @@ class UserService(
|
|||||||
fun findByOidcProviderId(oidcProviderId: String): org.gameyfin.app.users.entities.User? =
|
fun findByOidcProviderId(oidcProviderId: String): org.gameyfin.app.users.entities.User? =
|
||||||
userRepository.findByOidcProviderId(oidcProviderId)
|
userRepository.findByOidcProviderId(oidcProviderId)
|
||||||
|
|
||||||
fun getAllUsers(): List<UserInfoDto> {
|
fun getAllUsers(): List<ExtendedUserInfoDto> {
|
||||||
return userRepository.findAll().map { u -> toUserInfo(u) }
|
return userRepository.findAll().map { it.toExtendedUserInfoDto() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getByEmail(email: String): org.gameyfin.app.users.entities.User? {
|
fun getByEmail(email: String): org.gameyfin.app.users.entities.User? {
|
||||||
@@ -93,20 +96,22 @@ class UserService(
|
|||||||
return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'")
|
return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUserInfo(): UserInfoDto {
|
fun getUserInfo(): ExtendedUserInfoDto {
|
||||||
val auth = SecurityContextHolder.getContext().authentication
|
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
val principal = auth.principal
|
val principal = auth.principal
|
||||||
|
|
||||||
if (principal is OidcUser) {
|
if (principal is OidcUser) {
|
||||||
val oidcUser = org.gameyfin.app.users.entities.User(principal)
|
val oidcUser = org.gameyfin.app.users.entities.User(principal)
|
||||||
val userInfoDto = toUserInfo(oidcUser)
|
val user = userRepository.findByOidcProviderId(oidcUser.oidcProviderId!!)
|
||||||
|
?: throw UsernameNotFoundException("Unknown OIDC user with provider ID '${oidcUser.oidcProviderId}'")
|
||||||
|
val userInfoDto = user.toExtendedUserInfoDto()
|
||||||
userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities)
|
userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities)
|
||||||
.mapNotNull { Role.Companion.safeValueOf(it.authority) }
|
.mapNotNull { Role.safeValueOf(it.authority) }
|
||||||
return userInfoDto
|
return userInfoDto
|
||||||
}
|
}
|
||||||
|
|
||||||
val user = getByUsernameNonNull(auth.name)
|
val user = getByUsernameNonNull(auth.name)
|
||||||
return toUserInfo(user)
|
return user.toExtendedUserInfoDto()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAvatar(username: String): Image? {
|
fun getAvatar(username: String): Image? {
|
||||||
@@ -124,7 +129,7 @@ class UserService(
|
|||||||
val user = getByUsernameNonNull(username)
|
val user = getByUsernameNonNull(username)
|
||||||
|
|
||||||
if (user.avatar == null) return
|
if (user.avatar == null) return
|
||||||
imageService.deleteFile(user.avatar!!)
|
imageService.deleteImageIfUnused(user.avatar!!)
|
||||||
user.avatar = null
|
user.avatar = null
|
||||||
|
|
||||||
userRepository.save(user)
|
userRepository.save(user)
|
||||||
@@ -158,7 +163,7 @@ class UserService(
|
|||||||
RegistrationAttemptWithExistingEmailEvent(
|
RegistrationAttemptWithExistingEmailEvent(
|
||||||
this,
|
this,
|
||||||
it,
|
it,
|
||||||
Utils.Companion.getBaseUrl()
|
Utils.getBaseUrl()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -179,12 +184,12 @@ class UserService(
|
|||||||
if (adminNeedsToApprove) {
|
if (adminNeedsToApprove) {
|
||||||
eventPublisher.publishEvent(UserRegistrationWaitingForApprovalEvent(this, user))
|
eventPublisher.publishEvent(UserRegistrationWaitingForApprovalEvent(this, user))
|
||||||
} else {
|
} else {
|
||||||
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.Companion.getBaseUrl()))
|
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.emailConfirmed) {
|
if (!user.emailConfirmed) {
|
||||||
val token = emailConfirmationService.generate(user)
|
val token = emailConfirmationService.generate(user)
|
||||||
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.Companion.getBaseUrl()))
|
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.getBaseUrl()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +227,7 @@ class UserService(
|
|||||||
user.email = it
|
user.email = it
|
||||||
user.emailConfirmed = false
|
user.emailConfirmed = false
|
||||||
val token = emailConfirmationService.generate(user)
|
val token = emailConfirmationService.generate(user)
|
||||||
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.Companion.getBaseUrl()))
|
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.getBaseUrl()))
|
||||||
}
|
}
|
||||||
|
|
||||||
userRepository.save(user)
|
userRepository.save(user)
|
||||||
@@ -238,7 +243,7 @@ class UserService(
|
|||||||
return RoleAssignmentResult.NO_ROLES_PROVIDED
|
return RoleAssignmentResult.NO_ROLES_PROVIDED
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentUser = SecurityContextHolder.getContext().authentication
|
val currentUser = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
val targetUser = getByUsernameNonNull(username)
|
val targetUser = getByUsernameNonNull(username)
|
||||||
|
|
||||||
if (!canManage(targetUser)) {
|
if (!canManage(targetUser)) {
|
||||||
@@ -246,7 +251,7 @@ class UserService(
|
|||||||
return RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH
|
return RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH
|
||||||
}
|
}
|
||||||
|
|
||||||
val newAssignedRoles = roleNames.mapNotNull { r -> Role.Companion.safeValueOf(r) }
|
val newAssignedRoles = roleNames.mapNotNull { r -> Role.safeValueOf(r) }
|
||||||
val newAssignedRolesLevel = roleService.getHighestRole(newAssignedRoles).powerLevel
|
val newAssignedRolesLevel = roleService.getHighestRole(newAssignedRoles).powerLevel
|
||||||
val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
|
val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
|
||||||
|
|
||||||
@@ -266,7 +271,7 @@ class UserService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun canManage(targetUser: org.gameyfin.app.users.entities.User): Boolean {
|
fun canManage(targetUser: org.gameyfin.app.users.entities.User): Boolean {
|
||||||
val currentUser = SecurityContextHolder.getContext().authentication
|
val currentUser = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
|
val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
|
||||||
val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel
|
val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel
|
||||||
return currentUserLevel > targetUserLevel
|
return currentUserLevel > targetUserLevel
|
||||||
@@ -276,29 +281,11 @@ class UserService(
|
|||||||
val user = getByUsernameNonNull(username)
|
val user = getByUsernameNonNull(username)
|
||||||
user.enabled = enabled
|
user.enabled = enabled
|
||||||
userRepository.save(user)
|
userRepository.save(user)
|
||||||
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.Companion.getBaseUrl()))
|
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteUser(username: String) {
|
fun deleteUser(username: String) {
|
||||||
val user = getByUsernameNonNull(username)
|
val user = getByUsernameNonNull(username)
|
||||||
userRepository.delete(user)
|
userRepository.delete(user)
|
||||||
eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.Companion.getBaseUrl()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toUserInfo(user: org.gameyfin.app.users.entities.User): UserInfoDto {
|
|
||||||
return UserInfoDto(
|
|
||||||
username = user.username,
|
|
||||||
email = user.email,
|
|
||||||
emailConfirmed = user.emailConfirmed,
|
|
||||||
enabled = user.enabled,
|
|
||||||
hasAvatar = user.avatar != null,
|
|
||||||
avatarId = user.avatar?.id,
|
|
||||||
managedBySso = user.oidcProviderId != null,
|
|
||||||
roles = user.roles
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
|
|
||||||
return roles.map { r -> SimpleGrantedAuthority(r.roleName) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.gameyfin.app.users.dto
|
||||||
|
|
||||||
|
import org.gameyfin.app.core.Role
|
||||||
|
|
||||||
|
data class ExtendedUserInfoDto(
|
||||||
|
val id: Long,
|
||||||
|
val username: String,
|
||||||
|
val managedBySso: Boolean,
|
||||||
|
val email: String,
|
||||||
|
val emailConfirmed: Boolean,
|
||||||
|
val enabled: Boolean,
|
||||||
|
val hasAvatar: Boolean,
|
||||||
|
val avatarId: Long? = null,
|
||||||
|
var roles: List<Role>
|
||||||
|
)
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
package org.gameyfin.app.users.dto
|
package org.gameyfin.app.users.dto
|
||||||
|
|
||||||
import org.gameyfin.app.core.Role
|
|
||||||
|
|
||||||
data class UserInfoDto(
|
data class UserInfoDto(
|
||||||
|
val id: Long,
|
||||||
val username: String,
|
val username: String,
|
||||||
val managedBySso: Boolean,
|
|
||||||
val email: String,
|
|
||||||
val emailConfirmed: Boolean,
|
|
||||||
val enabled: Boolean,
|
|
||||||
val hasAvatar: Boolean,
|
val hasAvatar: Boolean,
|
||||||
val avatarId: Long? = null,
|
val avatarId: Long? = null,
|
||||||
var roles: List<Role>
|
|
||||||
)
|
)
|
||||||
+3
-4
@@ -1,11 +1,10 @@
|
|||||||
package org.gameyfin.app.users.emailconfirmation
|
package org.gameyfin.app.users.emailconfirmation
|
||||||
|
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
import org.gameyfin.app.users.UserService
|
|
||||||
import jakarta.annotation.security.PermitAll
|
import jakarta.annotation.security.PermitAll
|
||||||
|
import org.gameyfin.app.core.security.getCurrentAuth
|
||||||
import org.gameyfin.app.shared.token.TokenValidationResult
|
import org.gameyfin.app.shared.token.TokenValidationResult
|
||||||
import org.springframework.security.core.Authentication
|
import org.gameyfin.app.users.UserService
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
class EmailConfirmationEndpoint(
|
class EmailConfirmationEndpoint(
|
||||||
@@ -20,7 +19,7 @@ class EmailConfirmationEndpoint(
|
|||||||
|
|
||||||
@PermitAll
|
@PermitAll
|
||||||
fun resendEmailConfirmation() {
|
fun resendEmailConfirmation() {
|
||||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
userService.getByUsername(auth.name)?.let {
|
userService.getByUsername(auth.name)?.let {
|
||||||
emailConfirmationService.resendEmailConfirmation(it)
|
emailConfirmationService.resendEmailConfirmation(it)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
package org.gameyfin.app.users.entities
|
package org.gameyfin.app.users.entities
|
||||||
|
|
||||||
import org.gameyfin.app.games.entities.Image
|
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
import org.gameyfin.app.core.Role
|
import org.gameyfin.app.core.Role
|
||||||
import org.gameyfin.app.core.security.EncryptionConverter
|
import org.gameyfin.app.core.security.EncryptionConverter
|
||||||
|
import org.gameyfin.app.games.entities.Image
|
||||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||||
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "users")
|
@Table(name = "users")
|
||||||
|
@EntityListeners(UserEntityListener::class)
|
||||||
class User(
|
class User(
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package org.gameyfin.app.users.entities
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager
|
||||||
|
import jakarta.persistence.PostRemove
|
||||||
|
import jakarta.persistence.PreRemove
|
||||||
|
import org.gameyfin.app.core.Utils
|
||||||
|
import org.gameyfin.app.core.events.UserDeletedEvent
|
||||||
|
import org.gameyfin.app.requests.entities.GameRequest
|
||||||
|
import org.gameyfin.app.util.EntityManagerHolder
|
||||||
|
import org.gameyfin.app.util.EventPublisherHolder
|
||||||
|
|
||||||
|
class UserEntityListener {
|
||||||
|
|
||||||
|
@PreRemove
|
||||||
|
fun preRemove(user: User) {
|
||||||
|
val entityManager: EntityManager = EntityManagerHolder.getEntityManager()
|
||||||
|
|
||||||
|
// Remove user from all GameRequest voters and requester fields
|
||||||
|
val gameRequests = entityManager.createQuery(
|
||||||
|
"SELECT gr FROM GameRequest gr WHERE :user MEMBER OF gr.voters OR gr.requester = :user",
|
||||||
|
GameRequest::class.java
|
||||||
|
).setParameter("user", user).resultList
|
||||||
|
for (gr in gameRequests) {
|
||||||
|
gr.voters.remove(user)
|
||||||
|
if (gr.requester == user) gr.requester = null
|
||||||
|
entityManager.merge(gr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserUpdateEvent triggered via {@link org.gameyfin.app.core.interceptors.EntityUpdateInterceptor#onFlushDirty}
|
||||||
|
|
||||||
|
@PostRemove
|
||||||
|
fun postRemove(user: User) {
|
||||||
|
EventPublisherHolder.publish(UserDeletedEvent(this, user, Utils.getBaseUrl()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package org.gameyfin.app.users.extensions
|
||||||
|
|
||||||
|
import org.gameyfin.app.core.Role
|
||||||
|
import org.gameyfin.app.users.dto.ExtendedUserInfoDto
|
||||||
|
import org.gameyfin.app.users.dto.UserInfoDto
|
||||||
|
import org.gameyfin.app.users.entities.User
|
||||||
|
import org.springframework.security.core.GrantedAuthority
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
|
||||||
|
fun User.toUserInfoDto(): UserInfoDto {
|
||||||
|
return UserInfoDto(
|
||||||
|
id = this.id!!,
|
||||||
|
username = this.username,
|
||||||
|
hasAvatar = this.avatar != null,
|
||||||
|
avatarId = this.avatar?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun User.toExtendedUserInfoDto(): ExtendedUserInfoDto {
|
||||||
|
return ExtendedUserInfoDto(
|
||||||
|
id = this.id!!,
|
||||||
|
username = this.username,
|
||||||
|
email = this.email,
|
||||||
|
emailConfirmed = this.emailConfirmed,
|
||||||
|
enabled = this.enabled,
|
||||||
|
hasAvatar = this.avatar != null,
|
||||||
|
avatarId = this.avatar?.id,
|
||||||
|
managedBySso = this.oidcProviderId != null,
|
||||||
|
roles = this.roles
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Collection<Role>.toAuthorities(): List<GrantedAuthority> {
|
||||||
|
return this.map { r -> SimpleGrantedAuthority(r.roleName) }
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package org.gameyfin.app.users.persistence
|
|||||||
import org.gameyfin.app.core.Role
|
import org.gameyfin.app.core.Role
|
||||||
import org.gameyfin.app.users.entities.User
|
import org.gameyfin.app.users.entities.User
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
|
|
||||||
interface UserRepository : JpaRepository<User, Long> {
|
interface UserRepository : JpaRepository<User, Long> {
|
||||||
fun existsByUsername(userName: String): Boolean
|
fun existsByUsername(userName: String): Boolean
|
||||||
@@ -11,4 +13,7 @@ interface UserRepository : JpaRepository<User, Long> {
|
|||||||
fun findByEmail(email: String): User?
|
fun findByEmail(email: String): User?
|
||||||
fun findByOidcProviderId(oidcProviderId: String): User?
|
fun findByOidcProviderId(oidcProviderId: String): User?
|
||||||
fun countUserByRolesContains(role: Role): Int
|
fun countUserByRolesContains(role: Role): Int
|
||||||
|
|
||||||
|
@Query("SELECT CASE WHEN COUNT(u) > 0 THEN true ELSE false END FROM User u WHERE u.avatar.id = :imageId")
|
||||||
|
fun existsByAvatar(@Param("imageId") imageId: Long): Boolean
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package org.gameyfin.app.users.preferences
|
package org.gameyfin.app.users.preferences
|
||||||
|
|
||||||
import org.gameyfin.app.users.UserService
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
import org.gameyfin.app.core.security.getCurrentAuth
|
||||||
|
import org.gameyfin.app.users.UserService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ class UserPreferencesService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun id(key: String): UserPreferenceKey {
|
private fun id(key: String): UserPreferenceKey {
|
||||||
val auth = SecurityContextHolder.getContext().authentication
|
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
val user = userService.getByUsernameNonNull(auth.name)
|
val user = userService.getByUsernameNonNull(auth.name)
|
||||||
return UserPreferenceKey(key, user.id!!)
|
return UserPreferenceKey(key, user.id!!)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
package org.gameyfin.app.users.registration
|
package org.gameyfin.app.users.registration
|
||||||
|
|
||||||
|
import org.gameyfin.app.core.Utils
|
||||||
import org.gameyfin.app.core.events.AccountStatusChangedEvent
|
import org.gameyfin.app.core.events.AccountStatusChangedEvent
|
||||||
import org.gameyfin.app.core.events.UserInvitationEvent
|
import org.gameyfin.app.core.events.UserInvitationEvent
|
||||||
|
import org.gameyfin.app.core.security.getCurrentAuth
|
||||||
import org.gameyfin.app.shared.token.TokenDto
|
import org.gameyfin.app.shared.token.TokenDto
|
||||||
import org.gameyfin.app.shared.token.TokenRepository
|
import org.gameyfin.app.shared.token.TokenRepository
|
||||||
import org.gameyfin.app.users.UserService
|
|
||||||
import org.gameyfin.app.core.Utils
|
|
||||||
import org.gameyfin.app.shared.token.TokenService
|
import org.gameyfin.app.shared.token.TokenService
|
||||||
import org.gameyfin.app.shared.token.TokenType
|
import org.gameyfin.app.shared.token.TokenType
|
||||||
|
import org.gameyfin.app.users.UserService
|
||||||
import org.gameyfin.app.users.dto.UserRegistrationDto
|
import org.gameyfin.app.users.dto.UserRegistrationDto
|
||||||
import org.gameyfin.app.users.enums.UserInvitationAcceptanceResult
|
import org.gameyfin.app.users.enums.UserInvitationAcceptanceResult
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.security.core.Authentication
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -30,7 +29,7 @@ class InvitationService(
|
|||||||
if (userService.existsByEmail(email))
|
if (userService.existsByEmail(email))
|
||||||
throw IllegalStateException("User with email ${Utils.Companion.maskEmail(email)} is already registered")
|
throw IllegalStateException("User with email ${Utils.Companion.maskEmail(email)} is already registered")
|
||||||
|
|
||||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||||
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
|
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
|
||||||
val payload = mapOf(EMAIL_KEY to email)
|
val payload = mapOf(EMAIL_KEY to email)
|
||||||
val token = super.generateWithPayload(user, payload)
|
val token = super.generateWithPayload(user, payload)
|
||||||
@@ -45,7 +44,8 @@ class InvitationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun acceptInvitation(secret: String, registration: UserRegistrationDto): UserInvitationAcceptanceResult {
|
fun acceptInvitation(secret: String, registration: UserRegistrationDto): UserInvitationAcceptanceResult {
|
||||||
val invitationToken = super.get(secret, TokenType.Invitation) ?: return UserInvitationAcceptanceResult.TOKEN_INVALID
|
val invitationToken =
|
||||||
|
super.get(secret, TokenType.Invitation) ?: return UserInvitationAcceptanceResult.TOKEN_INVALID
|
||||||
val email = invitationToken.payload[EMAIL_KEY] ?: return UserInvitationAcceptanceResult.TOKEN_INVALID
|
val email = invitationToken.payload[EMAIL_KEY] ?: return UserInvitationAcceptanceResult.TOKEN_INVALID
|
||||||
if (invitationToken.expired) return UserInvitationAcceptanceResult.TOKEN_EXPIRED
|
if (invitationToken.expired) return UserInvitationAcceptanceResult.TOKEN_EXPIRED
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.gameyfin.app.util
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager
|
||||||
|
import org.springframework.context.ApplicationContext
|
||||||
|
import org.springframework.context.ApplicationContextAware
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
object EntityManagerHolder : ApplicationContextAware {
|
||||||
|
private var entityManager: EntityManager? = null
|
||||||
|
|
||||||
|
override fun setApplicationContext(context: ApplicationContext) {
|
||||||
|
entityManager = context.getBean(EntityManager::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getEntityManager(): EntityManager {
|
||||||
|
return entityManager ?: throw IllegalStateException("EntityManager not set")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.gameyfin.db.h2
|
||||||
|
|
||||||
|
import java.sql.Connection
|
||||||
|
import java.sql.SQLException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H2 helper methods exposed as SQL ALIASes.
|
||||||
|
* <p>
|
||||||
|
* Kotlin implementation replacing the former Java version so a JDK (javac) is not
|
||||||
|
* required at runtime for defining aliases in migration scripts.
|
||||||
|
*/
|
||||||
|
object H2Aliases {
|
||||||
|
/**
|
||||||
|
* Renames a constraint if it exists, swallowing only H2 error code 90057 (constraint not found).
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
@Throws(SQLException::class)
|
||||||
|
fun renameConstraintIfExists(conn: Connection, table: String, oldName: String, newName: String) {
|
||||||
|
conn.createStatement().use { st ->
|
||||||
|
try {
|
||||||
|
st.execute("ALTER TABLE $table RENAME CONSTRAINT $oldName TO $newName")
|
||||||
|
} catch (e: SQLException) {
|
||||||
|
if (e.errorCode != 90057) { // ignore only 'constraint not found'
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ spring:
|
|||||||
jpa:
|
jpa:
|
||||||
# defer-datasource-initialization: true
|
# defer-datasource-initialization: true
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: update
|
ddl-auto: validate
|
||||||
open-in-view: true
|
open-in-view: true
|
||||||
mustache:
|
mustache:
|
||||||
check-template-location: false
|
check-template-location: false
|
||||||
@@ -50,6 +50,9 @@ spring:
|
|||||||
virtual.enabled: true
|
virtual.enabled: true
|
||||||
mvc:
|
mvc:
|
||||||
async.request-timeout: 0
|
async.request-timeout: 0
|
||||||
|
flyway:
|
||||||
|
baseline-on-migrate: true
|
||||||
|
baseline-version: 2.0.0
|
||||||
|
|
||||||
vaadin:
|
vaadin:
|
||||||
# To improve the performance during development.
|
# To improve the performance during development.
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
-- Flyway Migration: V2.0.0
|
||||||
|
-- Purpose: Initial schema creation for Gameyfin application.
|
||||||
|
|
||||||
|
/******************************************************************************************
|
||||||
|
* 1. Sequences (hi/lo allocation size = 50 for performance)
|
||||||
|
******************************************************************************************/
|
||||||
|
CREATE SEQUENCE COMPANY_SEQ
|
||||||
|
INCREMENT BY 50;
|
||||||
|
|
||||||
|
CREATE SEQUENCE DIRECTORY_MAPPING_SEQ
|
||||||
|
INCREMENT BY 50;
|
||||||
|
|
||||||
|
CREATE SEQUENCE GAME_FIELD_METADATA_SEQ
|
||||||
|
INCREMENT BY 50;
|
||||||
|
|
||||||
|
CREATE SEQUENCE GAME_FIELD_SOURCE_SEQ
|
||||||
|
INCREMENT BY 50;
|
||||||
|
|
||||||
|
CREATE SEQUENCE GAME_SEQ
|
||||||
|
INCREMENT BY 50;
|
||||||
|
|
||||||
|
CREATE SEQUENCE IMAGE_SEQ
|
||||||
|
INCREMENT BY 50;
|
||||||
|
|
||||||
|
CREATE SEQUENCE LIBRARY_SEQ
|
||||||
|
INCREMENT BY 50;
|
||||||
|
|
||||||
|
CREATE SEQUENCE USERS_SEQ
|
||||||
|
INCREMENT BY 50;
|
||||||
|
|
||||||
|
/******************************************************************************************
|
||||||
|
* 2. Tables
|
||||||
|
******************************************************************************************/
|
||||||
|
CREATE TABLE APP_CONFIG
|
||||||
|
(
|
||||||
|
"key" CHARACTER VARYING(255) NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
"value" CHARACTER VARYING(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE COMPANY
|
||||||
|
(
|
||||||
|
ID BIGINT NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
NAME CHARACTER VARYING(255),
|
||||||
|
TYPE TINYINT,
|
||||||
|
CONSTRAINT UK4UCNYHR8I0URHWDUDFAHKOB9E
|
||||||
|
UNIQUE (NAME, TYPE),
|
||||||
|
CHECK ("TYPE" BETWEEN 0 AND 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE DIRECTORY_MAPPING
|
||||||
|
(
|
||||||
|
ID BIGINT NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
EXTERNAL_PATH CHARACTER VARYING(255),
|
||||||
|
INTERNAL_PATH CHARACTER VARYING(255)
|
||||||
|
CONSTRAINT UKJ3GSATFAHEWFOLSEAJ29O3KYT
|
||||||
|
UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IMAGE
|
||||||
|
(
|
||||||
|
ID BIGINT NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
CONTENT_ID CHARACTER VARYING(255),
|
||||||
|
CONTENT_LENGTH BIGINT,
|
||||||
|
MIME_TYPE CHARACTER VARYING(255),
|
||||||
|
ORIGINAL_URL CHARACTER VARYING(255),
|
||||||
|
TYPE TINYINT,
|
||||||
|
CHECK ("TYPE" BETWEEN 0 AND 3)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE LIBRARY
|
||||||
|
(
|
||||||
|
ID BIGINT NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
CREATED_AT TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
NAME CHARACTER VARYING(255),
|
||||||
|
UPDATED_AT TIMESTAMP WITH TIME ZONE NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME
|
||||||
|
(
|
||||||
|
ID BIGINT NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
COMMENT CHARACTER LARGE OBJECT,
|
||||||
|
CREATED_AT TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
CRITIC_RATING INTEGER,
|
||||||
|
DOWNLOAD_COUNT INTEGER,
|
||||||
|
FILE_SIZE BIGINT,
|
||||||
|
MATCH_CONFIRMED BOOLEAN,
|
||||||
|
PATH CHARACTER VARYING(255)
|
||||||
|
CONSTRAINT UK4WXN9FPXFQ8QXPSB7FY0O3NOA
|
||||||
|
UNIQUE,
|
||||||
|
RELEASE TIMESTAMP WITH TIME ZONE,
|
||||||
|
SUMMARY CHARACTER LARGE OBJECT,
|
||||||
|
TITLE CHARACTER VARYING(255),
|
||||||
|
UPDATED_AT TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
USER_RATING INTEGER,
|
||||||
|
COVER_IMAGE_ID BIGINT
|
||||||
|
CONSTRAINT UK52RQ62FLPBNTI77BYKM7UAHKQ
|
||||||
|
UNIQUE,
|
||||||
|
HEADER_IMAGE_ID BIGINT
|
||||||
|
CONSTRAINT UK30B16LLQV54H40XIOGP7T9P35
|
||||||
|
UNIQUE,
|
||||||
|
LIBRARY_ID BIGINT,
|
||||||
|
CONSTRAINT FK6CVB43REAYSNYPI0XDY6HQTVF
|
||||||
|
FOREIGN KEY (COVER_IMAGE_ID) REFERENCES IMAGE,
|
||||||
|
CONSTRAINT FK8N86NDPGKMOO7YOLX6HL8N84G
|
||||||
|
FOREIGN KEY (HEADER_IMAGE_ID) REFERENCES IMAGE,
|
||||||
|
CONSTRAINT FKIUVR8XFB63T1K6T43EYYXVO2C
|
||||||
|
FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_DEVELOPERS
|
||||||
|
(
|
||||||
|
GAME_ID BIGINT NOT NULL,
|
||||||
|
DEVELOPERS_ID BIGINT NOT NULL,
|
||||||
|
CONSTRAINT FKB12PO9L2B9OJBAIHC82MM2QXB
|
||||||
|
FOREIGN KEY (DEVELOPERS_ID) REFERENCES COMPANY,
|
||||||
|
CONSTRAINT FKS4IJSVPIJ53DSL143XVRGBS09
|
||||||
|
FOREIGN KEY (GAME_ID) REFERENCES GAME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_FEATURES
|
||||||
|
(
|
||||||
|
GAME_ID BIGINT NOT NULL,
|
||||||
|
FEATURES TINYINT,
|
||||||
|
CONSTRAINT FK63XLTCT60SCIMPM06K8BHBE4A
|
||||||
|
FOREIGN KEY (GAME_ID) REFERENCES GAME,
|
||||||
|
CHECK ("FEATURES" BETWEEN 0 AND 23)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_GENRES
|
||||||
|
(
|
||||||
|
GAME_ID BIGINT NOT NULL,
|
||||||
|
GENRES TINYINT,
|
||||||
|
CONSTRAINT FKDTSX09YOPD98E0LUEWRUSJD9E
|
||||||
|
FOREIGN KEY (GAME_ID) REFERENCES GAME,
|
||||||
|
CHECK ("GENRES" BETWEEN 0 AND 25)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_IMAGES
|
||||||
|
(
|
||||||
|
GAME_ID BIGINT NOT NULL,
|
||||||
|
IMAGES_ID BIGINT NOT NULL
|
||||||
|
CONSTRAINT UKBDE7M3TKHIEEYBINM2ED0B6X1
|
||||||
|
UNIQUE,
|
||||||
|
CONSTRAINT FK5YWV1DMXCM2VSQUEB7RHQ3JK9
|
||||||
|
FOREIGN KEY (IMAGES_ID) REFERENCES IMAGE,
|
||||||
|
CONSTRAINT FKOWCPUCV45OX8GT28TXGVHF1AA
|
||||||
|
FOREIGN KEY (GAME_ID) REFERENCES GAME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_KEYWORDS
|
||||||
|
(
|
||||||
|
GAME_ID BIGINT NOT NULL,
|
||||||
|
KEYWORDS CHARACTER VARYING(255),
|
||||||
|
CONSTRAINT FKMVF6HNJ7ROMQQM2EX70A9NVAC
|
||||||
|
FOREIGN KEY (GAME_ID) REFERENCES GAME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_PERSPECTIVES
|
||||||
|
(
|
||||||
|
GAME_ID BIGINT NOT NULL,
|
||||||
|
PERSPECTIVES TINYINT,
|
||||||
|
CONSTRAINT FKHUEENG29Y1GHBRDI5QHGUXH6E
|
||||||
|
FOREIGN KEY (GAME_ID) REFERENCES GAME,
|
||||||
|
CHECK ("PERSPECTIVES" BETWEEN 0 AND 7)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_PUBLISHERS
|
||||||
|
(
|
||||||
|
GAME_ID BIGINT NOT NULL,
|
||||||
|
PUBLISHERS_ID BIGINT NOT NULL,
|
||||||
|
CONSTRAINT FK49R2KB61LIJ54BQB4VNTST97N
|
||||||
|
FOREIGN KEY (GAME_ID) REFERENCES GAME,
|
||||||
|
CONSTRAINT FKNGLD5ESGRBRH95J5BJF0HEF85
|
||||||
|
FOREIGN KEY (PUBLISHERS_ID) REFERENCES COMPANY
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_THEMES
|
||||||
|
(
|
||||||
|
GAME_ID BIGINT NOT NULL,
|
||||||
|
THEMES TINYINT,
|
||||||
|
CONSTRAINT FKRV351JXLIOY0A17Y5BBJJ6FW4
|
||||||
|
FOREIGN KEY (GAME_ID) REFERENCES GAME,
|
||||||
|
CHECK ("THEMES" BETWEEN 0 AND 22)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_VIDEO_URLS
|
||||||
|
(
|
||||||
|
GAME_ID BIGINT NOT NULL,
|
||||||
|
VIDEO_URLS BINARY VARYING(255),
|
||||||
|
CONSTRAINT FKJKKWO8WDS086AS7B2KSLSVKM6
|
||||||
|
FOREIGN KEY (GAME_ID) REFERENCES GAME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE LIBRARY_DIRECTORIES
|
||||||
|
(
|
||||||
|
LIBRARY_ID BIGINT NOT NULL,
|
||||||
|
DIRECTORIES_ID BIGINT NOT NULL
|
||||||
|
CONSTRAINT UKB5UM4CADBNC6UC8DVOMO81N5F
|
||||||
|
UNIQUE,
|
||||||
|
CONSTRAINT FKFNCKIU58I9L89MLXV388DY13B
|
||||||
|
FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY,
|
||||||
|
CONSTRAINT FKJDXS58Q1IRTU0IDP6DXJHWAPM
|
||||||
|
FOREIGN KEY (DIRECTORIES_ID) REFERENCES DIRECTORY_MAPPING
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE LIBRARY_GAMES
|
||||||
|
(
|
||||||
|
LIBRARY_ID BIGINT NOT NULL,
|
||||||
|
GAMES_ID BIGINT NOT NULL
|
||||||
|
CONSTRAINT UK3E4VB9NQXPY27VMTA27GU5FY8
|
||||||
|
UNIQUE,
|
||||||
|
CONSTRAINT FK6C71EEDM0I2N1JXDE9BOBWG5M
|
||||||
|
FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY,
|
||||||
|
CONSTRAINT FKDKKKES3DAY0WJ1QMV42KMMFDK
|
||||||
|
FOREIGN KEY (GAMES_ID) REFERENCES GAME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE LIBRARY_UNMATCHED_PATHS
|
||||||
|
(
|
||||||
|
LIBRARY_ID BIGINT NOT NULL,
|
||||||
|
UNMATCHED_PATHS CHARACTER VARYING(255),
|
||||||
|
CONSTRAINT FKSJ51WC2LBNNXY0LKLWELI6VSB
|
||||||
|
FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE PLUGIN_CONFIG
|
||||||
|
(
|
||||||
|
"key" CHARACTER VARYING(255) NOT NULL,
|
||||||
|
PLUGIN_ID CHARACTER VARYING(255) NOT NULL,
|
||||||
|
"value" CHARACTER VARYING(255),
|
||||||
|
PRIMARY KEY ("key", PLUGIN_ID)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE PLUGIN_MANAGEMENT_ENTRY
|
||||||
|
(
|
||||||
|
PLUGIN_ID CHARACTER VARYING(255) NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
ENABLED BOOLEAN NOT NULL,
|
||||||
|
PRIORITY INTEGER NOT NULL,
|
||||||
|
TRUST_LEVEL TINYINT,
|
||||||
|
CHECK ("TRUST_LEVEL" BETWEEN 0 AND 4)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_ORIGINAL_IDS
|
||||||
|
(
|
||||||
|
GAME_ID BIGINT NOT NULL,
|
||||||
|
ORIGINAL_IDS CHARACTER VARYING(255),
|
||||||
|
ORIGINAL_IDS_KEY CHARACTER VARYING(255) NOT NULL,
|
||||||
|
PRIMARY KEY (GAME_ID, ORIGINAL_IDS_KEY),
|
||||||
|
CONSTRAINT FK1CSD5QD7VJT7BTTA3G7HGYBUX
|
||||||
|
FOREIGN KEY (GAME_ID) REFERENCES GAME,
|
||||||
|
CONSTRAINT FKMT0XWLPWPU9NP0Q289JBAHJRY
|
||||||
|
FOREIGN KEY (ORIGINAL_IDS_KEY) REFERENCES PLUGIN_MANAGEMENT_ENTRY
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE USERS
|
||||||
|
(
|
||||||
|
ID BIGINT NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
EMAIL CHARACTER VARYING(255)
|
||||||
|
CONSTRAINT UK6DOTKOTT2KJSP8VW4D0M25FB7
|
||||||
|
UNIQUE,
|
||||||
|
EMAIL_CONFIRMED BOOLEAN NOT NULL,
|
||||||
|
ENABLED BOOLEAN NOT NULL,
|
||||||
|
OIDC_PROVIDER_ID CHARACTER VARYING(255),
|
||||||
|
PASSWORD CHARACTER VARYING(255),
|
||||||
|
USERNAME CHARACTER VARYING(255)
|
||||||
|
CONSTRAINT UKR43AF9AP4EDM43MMTQ01ODDJ6
|
||||||
|
UNIQUE,
|
||||||
|
AVATAR_ID BIGINT
|
||||||
|
CONSTRAINT UKRSULCN2GYNJY3CDDPWMOSV881
|
||||||
|
UNIQUE,
|
||||||
|
CONSTRAINT FK19LFLPG5SEIS4DWRM2LVJLXFV
|
||||||
|
FOREIGN KEY (AVATAR_ID) REFERENCES IMAGE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_FIELD_SOURCE
|
||||||
|
(
|
||||||
|
DTYPE CHARACTER VARYING(31) NOT NULL,
|
||||||
|
ID BIGINT NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
PLUGIN_PLUGIN_ID CHARACTER VARYING(255),
|
||||||
|
USER_ID BIGINT,
|
||||||
|
CONSTRAINT FKNJC4QSS5APFHTPWP42OAEAL5G
|
||||||
|
FOREIGN KEY (PLUGIN_PLUGIN_ID) REFERENCES PLUGIN_MANAGEMENT_ENTRY,
|
||||||
|
CONSTRAINT FKSR1BGTX5XJVMAL7FEFGL982TP
|
||||||
|
FOREIGN KEY (USER_ID) REFERENCES USERS
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_FIELD_METADATA
|
||||||
|
(
|
||||||
|
ID BIGINT NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
UPDATED_AT TIMESTAMP WITH TIME ZONE,
|
||||||
|
SOURCE_ID BIGINT
|
||||||
|
CONSTRAINT UKHW6U2Y9FLWPTI57QB7K0P27BL
|
||||||
|
UNIQUE,
|
||||||
|
CONSTRAINT FKQ4RC409TP8FUBTTM733PMJD8F
|
||||||
|
FOREIGN KEY (SOURCE_ID) REFERENCES GAME_FIELD_SOURCE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE GAME_FIELDS
|
||||||
|
(
|
||||||
|
GAME_ID BIGINT NOT NULL,
|
||||||
|
FIELDS_ID BIGINT NOT NULL
|
||||||
|
CONSTRAINT UK1L5OAH0UOOUV4V5A9P0PAK77X
|
||||||
|
UNIQUE,
|
||||||
|
FIELDS_KEY CHARACTER VARYING(255) NOT NULL,
|
||||||
|
PRIMARY KEY (GAME_ID, FIELDS_KEY),
|
||||||
|
CONSTRAINT FKLNEPI7YWCI86YH21KO9WD9PYF
|
||||||
|
FOREIGN KEY (GAME_ID) REFERENCES GAME,
|
||||||
|
CONSTRAINT FKT8FLOFDAPX5M746S5LW54C5B3
|
||||||
|
FOREIGN KEY (FIELDS_ID) REFERENCES GAME_FIELD_METADATA
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE TOKEN
|
||||||
|
(
|
||||||
|
SECRET CHARACTER VARYING(255) NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
CREATED_ON TIMESTAMP WITH TIME ZONE,
|
||||||
|
PAYLOAD CHARACTER VARYING(255),
|
||||||
|
TYPE CHARACTER VARYING(255),
|
||||||
|
CREATOR_ID BIGINT,
|
||||||
|
CONSTRAINT FKGHOIALAPTI5JFEJ506JBB1O8Y
|
||||||
|
FOREIGN KEY (CREATOR_ID) REFERENCES USERS
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE USER_PREFERENCE
|
||||||
|
(
|
||||||
|
"key" CHARACTER VARYING(255) NOT NULL,
|
||||||
|
USER_ID BIGINT NOT NULL,
|
||||||
|
"value" CHARACTER VARYING(255),
|
||||||
|
PRIMARY KEY ("key", USER_ID)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE USER_ROLES
|
||||||
|
(
|
||||||
|
USER_ID BIGINT NOT NULL,
|
||||||
|
ROLES ENUM ('ADMIN', 'SUPERADMIN', 'USER'),
|
||||||
|
CONSTRAINT FKHFH9DX7W3UBF1CO1VDEV94G3F
|
||||||
|
FOREIGN KEY (USER_ID) REFERENCES USERS
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE JOB_RUN_RESULT
|
||||||
|
(
|
||||||
|
ID BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY,
|
||||||
|
JOB_NAME VARCHAR(255),
|
||||||
|
STARTED_AT TIMESTAMP,
|
||||||
|
FINISHED_AT TIMESTAMP,
|
||||||
|
STATUS VARCHAR(255),
|
||||||
|
MESSAGE VARCHAR(255)
|
||||||
|
);
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
-- Flyway Migration: V2.1.0 (Part 1)
|
||||||
|
-- Purpose:
|
||||||
|
-- 1. Drop unique constraints on GAME.COVER_IMAGE_ID and GAME.HEADER_IMAGE_ID
|
||||||
|
-- 2. Rename all remaining UK*/FK* constraints to human-readable names (idempotent)
|
||||||
|
-- This version has been updated to be resilient: if a hashed constraint name
|
||||||
|
-- does not exist (e.g. on legacy schemas where Hibernate never created it, or
|
||||||
|
-- on future fresh installs where the friendly name may already be present),
|
||||||
|
-- the rename attempt is ignored instead of failing the migration.
|
||||||
|
|
||||||
|
/******************************************************************************************
|
||||||
|
* Helper: Idempotent constraint rename for H2
|
||||||
|
* (Updated) Use precompiled Java class instead of inline Java to avoid needing 'javac' at runtime.
|
||||||
|
* Class: org.gameyfin.db.h2.H2Aliases.renameConstraintIfExists(Connection,String,String,String)
|
||||||
|
******************************************************************************************/
|
||||||
|
CREATE ALIAS IF NOT EXISTS RENAME_CONSTRAINT_IF_EXISTS FOR "org.gameyfin.db.h2.H2Aliases.renameConstraintIfExists";
|
||||||
|
|
||||||
|
/******************************************************************************************
|
||||||
|
* 1. Drop the two unwanted unique constraints on GAME
|
||||||
|
******************************************************************************************/
|
||||||
|
ALTER TABLE GAME
|
||||||
|
DROP CONSTRAINT IF EXISTS UK52RQ62FLPBNTI77BYKM7UAHKQ; -- COVER_IMAGE_ID unique
|
||||||
|
ALTER TABLE GAME
|
||||||
|
DROP CONSTRAINT IF EXISTS UK30B16LLQV54H40XIOGP7T9P35;
|
||||||
|
-- HEADER_IMAGE_ID unique
|
||||||
|
|
||||||
|
/******************************************************************************************
|
||||||
|
* 2. Rename remaining UNIQUE constraints (UK*) (tolerant)
|
||||||
|
******************************************************************************************/
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('COMPANY', 'UK4UCNYHR8I0URHWDUDFAHKOB9E', 'UQ_COMPANY_NAME_TYPE');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('DIRECTORY_MAPPING', 'UKJ3GSATFAHEWFOLSEAJ29O3KYT',
|
||||||
|
'UQ_DIRECTORY_MAPPING_INTERNAL_PATH');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME', 'UK4WXN9FPXFQ8QXPSB7FY0O3NOA', 'UQ_GAME_PATH');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_IMAGES', 'UKBDE7M3TKHIEEYBINM2ED0B6X1', 'UQ_GAME_IMAGES_IMAGE_ID');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_DIRECTORIES', 'UKB5UM4CADBNC6UC8DVOMO81N5F',
|
||||||
|
'UQ_LIBRARY_DIRECTORIES_DIRECTORY_ID');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_GAMES', 'UK3E4VB9NQXPY27VMTA27GU5FY8', 'UQ_LIBRARY_GAMES_GAME_ID');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('USERS', 'UK6DOTKOTT2KJSP8VW4D0M25FB7', 'UQ_USERS_EMAIL');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('USERS', 'UKR43AF9AP4EDM43MMTQ01ODDJ6', 'UQ_USERS_USERNAME');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('USERS', 'UKRSULCN2GYNJY3CDDPWMOSV881', 'UQ_USERS_AVATAR_ID');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELD_METADATA', 'UKHW6U2Y9FLWPTI57QB7K0P27BL',
|
||||||
|
'UQ_GAME_FIELD_METADATA_SOURCE_ID');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELDS', 'UK1L5OAH0UOOUV4V5A9P0PAK77X', 'UQ_GAME_FIELDS_FIELD_METADATA_ID');
|
||||||
|
|
||||||
|
/******************************************************************************************
|
||||||
|
* 3. Rename FOREIGN KEY constraints (FK*) (tolerant)
|
||||||
|
******************************************************************************************/
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME', 'FK6CVB43REAYSNYPI0XDY6HQTVF', 'FK_GAME_COVER_IMAGE');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME', 'FK8N86NDPGKMOO7YOLX6HL8N84G', 'FK_GAME_HEADER_IMAGE');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME', 'FKIUVR8XFB63T1K6T43EYYXVO2C', 'FK_GAME_LIBRARY');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_DEVELOPERS', 'FKB12PO9L2B9OJBAIHC82MM2QXB', 'FK_GAME_DEVELOPERS_COMPANY');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_DEVELOPERS', 'FKS4IJSVPIJ53DSL143XVRGBS09', 'FK_GAME_DEVELOPERS_GAME');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FEATURES', 'FK63XLTCT60SCIMPM06K8BHBE4A', 'FK_GAME_FEATURES_GAME');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_GENRES', 'FKDTSX09YOPD98E0LUEWRUSJD9E', 'FK_GAME_GENRES_GAME');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_IMAGES', 'FK5YWV1DMXCM2VSQUEB7RHQ3JK9', 'FK_GAME_IMAGES_IMAGE');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_IMAGES', 'FKOWCPUCV45OX8GT28TXGVHF1AA', 'FK_GAME_IMAGES_GAME');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_KEYWORDS', 'FKMVF6HNJ7ROMQQM2EX70A9NVAC', 'FK_GAME_KEYWORDS_GAME');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_PERSPECTIVES', 'FKHUEENG29Y1GHBRDI5QHGUXH6E', 'FK_GAME_PERSPECTIVES_GAME');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_PUBLISHERS', 'FK49R2KB61LIJ54BQB4VNTST97N', 'FK_GAME_PUBLISHERS_GAME');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_PUBLISHERS', 'FKNGLD5ESGRBRH95J5BJF0HEF85', 'FK_GAME_PUBLISHERS_COMPANY');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_THEMES', 'FKRV351JXLIOY0A17Y5BBJJ6FW4', 'FK_GAME_THEMES_GAME');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_VIDEO_URLS', 'FKJKKWO8WDS086AS7B2KSLSVKM6', 'FK_GAME_VIDEO_URLS_GAME');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_DIRECTORIES', 'FKFNCKIU58I9L89MLXV388DY13B',
|
||||||
|
'FK_LIBRARY_DIRECTORIES_LIBRARY');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_DIRECTORIES', 'FKJDXS58Q1IRTU0IDP6DXJHWAPM',
|
||||||
|
'FK_LIBRARY_DIRECTORIES_DIRECTORY_MAPPING');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_GAMES', 'FK6C71EEDM0I2N1JXDE9BOBWG5M', 'FK_LIBRARY_GAMES_LIBRARY');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_GAMES', 'FKDKKKES3DAY0WJ1QMV42KMMFDK', 'FK_LIBRARY_GAMES_GAME');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_UNMATCHED_PATHS', 'FKSJ51WC2LBNNXY0LKLWELI6VSB',
|
||||||
|
'FK_LIBRARY_UNMATCHED_PATHS_LIBRARY');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_ORIGINAL_IDS', 'FK1CSD5QD7VJT7BTTA3G7HGYBUX', 'FK_GAME_ORIGINAL_IDS_GAME');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_ORIGINAL_IDS', 'FKMT0XWLPWPU9NP0Q289JBAHJRY', 'FK_GAME_ORIGINAL_IDS_PLUGIN');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('USERS', 'FK19LFLPG5SEIS4DWRM2LVJLXFV', 'FK_USERS_AVATAR_IMAGE');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELD_SOURCE', 'FKNJC4QSS5APFHTPWP42OAEAL5G', 'FK_GAME_FIELD_SOURCE_PLUGIN');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELD_SOURCE', 'FKSR1BGTX5XJVMAL7FEFGL982TP', 'FK_GAME_FIELD_SOURCE_USER');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELD_METADATA', 'FKQ4RC409TP8FUBTTM733PMJD8F', 'FK_GAME_FIELD_METADATA_SOURCE');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELDS', 'FKLNEPI7YWCI86YH21KO9WD9PYF', 'FK_GAME_FIELDS_GAME');
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELDS', 'FKT8FLOFDAPX5M746S5LW54C5B3', 'FK_GAME_FIELDS_METADATA');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('TOKEN', 'FKGHOIALAPTI5JFEJ506JBB1O8Y', 'FK_TOKEN_CREATOR_USER');
|
||||||
|
|
||||||
|
CALL RENAME_CONSTRAINT_IF_EXISTS('USER_ROLES', 'FKHFH9DX7W3UBF1CO1VDEV94G3F', 'FK_USER_ROLES_USER');
|
||||||
|
|
||||||
|
-- End of migration
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Flyway Migration: V2.1.0 (Part 2)
|
||||||
|
-- Purpose:
|
||||||
|
-- 1. Create tables for the game requests feature
|
||||||
|
|
||||||
|
/******************************************************************************************
|
||||||
|
* 1. Create new sequence
|
||||||
|
******************************************************************************************/
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS GAME_REQUEST_SEQ
|
||||||
|
INCREMENT BY 50;
|
||||||
|
|
||||||
|
/******************************************************************************************
|
||||||
|
* 2. Create new tables
|
||||||
|
******************************************************************************************/
|
||||||
|
CREATE TABLE IF NOT EXISTS GAME_REQUEST
|
||||||
|
(
|
||||||
|
ID BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
TITLE VARCHAR(255) NOT NULL,
|
||||||
|
RELEASE TIMESTAMP NOT NULL,
|
||||||
|
STATUS VARCHAR(255) NOT NULL,
|
||||||
|
REQUESTER_ID BIGINT,
|
||||||
|
LINKED_GAME_ID BIGINT,
|
||||||
|
CREATED_AT TIMESTAMP NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT FK_GAMEREQUEST_ON_REQUESTER
|
||||||
|
FOREIGN KEY (REQUESTER_ID) REFERENCES USERS
|
||||||
|
ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS GAME_REQUEST_VOTERS
|
||||||
|
(
|
||||||
|
GAME_REQUEST_ID BIGINT NOT NULL,
|
||||||
|
VOTERS_ID BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (GAME_REQUEST_ID, VOTERS_ID),
|
||||||
|
CONSTRAINT FK_GAMREQVOT_ON_GAME_REQUEST
|
||||||
|
FOREIGN KEY (GAME_REQUEST_ID) REFERENCES GAME_REQUEST
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT FK_GAMREQVOT_ON_USER
|
||||||
|
FOREIGN KEY (VOTERS_ID) REFERENCES USERS
|
||||||
|
);
|
||||||
|
|
||||||
|
-- End of migration
|
||||||
+23
-2
@@ -82,6 +82,8 @@ const themeOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasExportedWebComponents = existsSync(path.resolve(frontendFolder, 'web-component.html'));
|
const hasExportedWebComponents = existsSync(path.resolve(frontendFolder, 'web-component.html'));
|
||||||
|
const commercialBannerComponent = path.resolve(frontendFolder, settings.generatedFolder, 'commercial-banner.js');
|
||||||
|
const hasCommercialBanner = existsSync(commercialBannerComponent);
|
||||||
|
|
||||||
const target = ['safari15', 'es2022'];
|
const target = ['safari15', 'es2022'];
|
||||||
|
|
||||||
@@ -259,6 +261,10 @@ function statsExtracterPlugin(): PluginOption {
|
|||||||
);
|
);
|
||||||
frontendFiles[`index.ts`] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
|
frontendFiles[`index.ts`] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
|
||||||
}
|
}
|
||||||
|
if (hasCommercialBanner) {
|
||||||
|
const fileBuffer = readFileSync(commercialBannerComponent, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
|
||||||
|
frontendFiles[settings.generatedFolder + '/commercial-banner.js'] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
const themeJsonContents: Record<string, string> = {};
|
const themeJsonContents: Record<string, string> = {};
|
||||||
const themesFolder = path.resolve(jarResourcesFolder, 'themes');
|
const themesFolder = path.resolve(jarResourcesFolder, 'themes');
|
||||||
@@ -576,6 +582,7 @@ function preserveUsageStats() {
|
|||||||
export const vaadinConfig: UserConfigFn = (env) => {
|
export const vaadinConfig: UserConfigFn = (env) => {
|
||||||
const devMode = env.mode === 'development';
|
const devMode = env.mode === 'development';
|
||||||
const productionMode = !devMode && !devBundle
|
const productionMode = !devMode && !devBundle
|
||||||
|
const commercialBanner = productionMode && hasCommercialBanner;
|
||||||
|
|
||||||
if (devMode && process.env.watchDogPort) {
|
if (devMode && process.env.watchDogPort) {
|
||||||
// Open a connection with the Java dev-mode handler in order to finish
|
// Open a connection with the Java dev-mode handler in order to finish
|
||||||
@@ -734,14 +741,21 @@ export const vaadinConfig: UserConfigFn = (env) => {
|
|||||||
if (path !== '/web-component.html') {
|
if (path !== '/web-component.html') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const scripts = [
|
||||||
return [
|
|
||||||
{
|
{
|
||||||
tag: 'script',
|
tag: 'script',
|
||||||
attrs: { type: 'module', src: `/generated/vaadin-web-component.ts` },
|
attrs: { type: 'module', src: `/generated/vaadin-web-component.ts` },
|
||||||
injectTo: 'head'
|
injectTo: 'head'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
if (commercialBanner) {
|
||||||
|
scripts.push({
|
||||||
|
tag: 'script',
|
||||||
|
attrs: { type: 'module', src: '/generated/commercial-banner.js' },
|
||||||
|
injectTo: 'head'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return scripts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -768,6 +782,13 @@ export const vaadinConfig: UserConfigFn = (env) => {
|
|||||||
attrs: { type: 'module', src: '/generated/vaadin.ts' },
|
attrs: { type: 'module', src: '/generated/vaadin.ts' },
|
||||||
injectTo: 'head'
|
injectTo: 'head'
|
||||||
});
|
});
|
||||||
|
if (commercialBanner) {
|
||||||
|
scripts.push({
|
||||||
|
tag: 'script',
|
||||||
|
attrs: { type: 'module', src: '/generated/commercial-banner.js' },
|
||||||
|
injectTo: 'head'
|
||||||
|
});
|
||||||
|
}
|
||||||
return scripts;
|
return scripts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
|||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
|
||||||
group = "org.gameyfin"
|
group = "org.gameyfin"
|
||||||
version = "2.0.1"
|
version = "2.1.0-preview"
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
+5
-5
@@ -3,12 +3,12 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
|
|||||||
org.gradle.parallel=true
|
org.gradle.parallel=true
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
# Plugin versions
|
# Plugin versions
|
||||||
kotlinVersion=2.2.0
|
kotlinVersion=2.2.20
|
||||||
kspVersion=2.2.0-2.0.2
|
kspVersion=2.2.20-2.0.3
|
||||||
vaadinVersion=24.8.3
|
vaadinVersion=24.9.0
|
||||||
springBootVersion=3.5.3
|
springBootVersion=3.5.6
|
||||||
springCloudVersion=2025.0.0
|
springCloudVersion=2025.0.0
|
||||||
springDependencyManagementVersion=1.1.7
|
springDependencyManagementVersion=1.1.7
|
||||||
# Dependency versions
|
# Dependency versions
|
||||||
pf4jVersion=3.13.0
|
pf4jVersion=3.13.0
|
||||||
pf4jKspVersion=2.2.0-1.0.3
|
pf4jKspVersion=2.2.20-1.0.3
|
||||||
Reference in New Issue
Block a user