Overhaul startpage (#821)

* chore: bump version to v2.3.0-preview

* Customize start page (#803)

* Update ConfigService to support complex Objects
Implemented tests for ConfigService

* Added DB migration for config table

* Fixed version in banner.txt not being displayed

* Implement Library ordering
Implement "Show recently added games on homepage"

* Fix build.gradle.kts

* FIx bug when creating libraries

* Fix TypeScript errors
Fix library sorting

* Bump actions/checkout from 5 to 6 (#811)

Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Added automatic scanning using file system watchers (#813)

* Implement collections (#814)

* Backend implementation for collections

* Fix database schema and migration script

* Refactor some config values
Fix ArrayInput not being deactivatable

* Remove "AutoRegisterNewUsers" config option

* Fix bug when removing ignored paths

* Add UI for collections (WIP)

* Fix table actions not synced with state
Fix tests

* Finish implementation of collection feature

* Fix tests

* Bump actions/checkout from 5 to 6 (#815)

Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Fix "allow guests to create game requests" not being enabled when guest access is activated

* Fix: Disable loading of EditGameMetadataModal and MatchGameModal in GameView when user is not admin

* WIP: Update start page layout

* Performance improvements (lazy loading and virtualized grids/lists)
Fix various smaller issues

* Implement use of blurhash for all images in backend and covers in frontend

* Fix bugs and test

* Fix code analysis issues

* Remove "UI settings" since they have been made obsolete

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Simon
2025-12-10 00:22:58 +01:00
committed by GitHub
parent 38b95ae102
commit 8d8dca32d8
137 changed files with 5218 additions and 809 deletions
Binary file not shown.
+3 -1
View File
@@ -19,6 +19,7 @@ import {initializeGameRequestState} from "Frontend/state/GameRequestState";
import {initializePlatformState} from "Frontend/state/PlatformState";
import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState";
import {initializeUserState} from "Frontend/state/UserState";
import {initializeCollectionState} from "Frontend/state/CollectionState";
export default function App() {
client.middlewares = [ErrorHandlingMiddleware];
@@ -48,10 +49,11 @@ function ViewWithAuth() {
if (auth.state.initializing || auth.state.loading) return;
initializeLibraryState();
initializeGameState();
initializeCollectionState();
initializePlatformState();
initializeGameRequestState();
initializePluginState();
initializeGameState();
if (isAdmin(auth)) {
initializeScanState();
@@ -1,5 +1,5 @@
import {useAuth} from "Frontend/util/auth";
import { GearFineIcon, QuestionIcon, SignOutIcon, UserIcon } from "@phosphor-icons/react";
import {GearFineIcon, QuestionIcon, SignOutIcon, UserIcon} from "@phosphor-icons/react";
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react";
import {useNavigate} from "react-router";
import Avatar from "Frontend/components/general/Avatar";
@@ -19,7 +19,7 @@ export default function ProfileMenu() {
{
label: "Administration",
icon: <GearFineIcon/>,
onClick: () => navigate("/administration/libraries"),
onClick: () => navigate("/administration/games"),
showIf: isAdmin(auth)
},
{
@@ -12,7 +12,7 @@ import {DownloadSessionCard} from "Frontend/components/general/cards/DownloadSes
import {humanFileSize} from "Frontend/util/utils";
function DownloadManagementLayout({getConfig, formik}: any) {
const sessions = useSnapshot(downloadSessionState).all as SessionStatsDto[];
const sessions = useSnapshot(downloadSessionState).all;
const [lastDaySum, setLastDaySum] = React.useState<number>(0);
React.useEffect(() => {
@@ -0,0 +1,149 @@
import React from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import * as Yup from 'yup';
import "Frontend/util/yup-extensions";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import {ListNumbersIcon, PlusIcon} from "@phosphor-icons/react";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
import LibraryPrioritiesModal from "Frontend/components/general/modals/LibraryPrioritiesModal";
import {collectionState} from "Frontend/state/CollectionState";
import {CollectionOverviewCard} from "Frontend/components/general/cards/CollectionOverviewCard";
import CollectionCreationModal from "Frontend/components/general/modals/CollectionCreationModal";
import CollectionPrioritiesModal from "Frontend/components/general/modals/CollectionPrioritiesModal";
function GameManagementLayout({getConfig, formik}: any) {
const libraries = useSnapshot(libraryState);
const libraryCreationModal = useDisclosure();
const libraryOrderModal = useDisclosure();
const collections = useSnapshot(collectionState);
const collectionCreationModal = useDisclosure();
const collectionOrderModal = useDisclosure();
return (
<div className="flex flex-col">
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
<div className="flex flex-row gap-2">
<Tooltip content="Change library order">
<Button isIconOnly variant="flat" onPress={libraryOrderModal.onOpen}>
<ListNumbersIcon/>
</Button>
</Tooltip>
<Tooltip content="Add new library">
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
<PlusIcon/>
</Button>
</Tooltip>
</div>
</div>
<Divider className="mb-4"/>
{libraries.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{libraries.sorted.map((library) =>
// @ts-ignore
<LibraryOverviewCard library={library} key={library.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No libraries found</p>
}
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Collections</h2>
<div className="flex flex-row gap-2">
<Tooltip content="Change collection order">
<Button isIconOnly variant="flat" onPress={collectionOrderModal.onOpen}>
<ListNumbersIcon/>
</Button>
</Tooltip>
<Tooltip content="Create new collection">
<Button isIconOnly variant="flat" onPress={collectionCreationModal.onOpen}>
<PlusIcon/>
</Button>
</Tooltip>
</div>
</div>
<Divider className="mb-4"/>
{collections.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="collection-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{collections.sorted.map((collection) =>
// @ts-ignore
<CollectionOverviewCard collection={collection} key={collection.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No collections found</p>
}
<Section title="Scanning"/>
<div className="flex flex-col gap-4">
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
<div className="flex flex-row gap-4 items-baseline">
<ConfigFormField configElement={getConfig("library.scan.extract-title-using-regex")}/>
<ConfigFormField configElement={getConfig("library.scan.title-extraction-regex")}
isDisabled={!formik.values.library.scan["extract-title-using-regex"]}/>
</div>
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
</div>
<Section title="Metadata"/>
<div className="flex flex-row items-baseline">
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")}/>
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
isDisabled={!formik.values.library.metadata.update.enabled}/>
</div>
<LibraryCreationModal
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>
<LibraryPrioritiesModal
isOpen={libraryOrderModal.isOpen}
onOpenChange={libraryOrderModal.onOpenChange}
/>
<CollectionCreationModal
isOpen={collectionCreationModal.isOpen}
onOpenChange={collectionCreationModal.onOpenChange}
/>
<CollectionPrioritiesModal
isOpen={collectionOrderModal.isOpen}
onOpenChange={collectionOrderModal.onOpenChange}/>
</div>
);
}
const validationSchema = Yup.object({
library: Yup.object({
metadata: Yup.object({
update: Yup.object({
enabled: Yup.boolean(),
schedule: Yup.string().when("enabled", {
is: true,
then: (schema) => schema.cron()
}),
})
}),
scan: Yup.object({
"extract-title-using-regex": Yup.boolean(),
"title-extraction-regex": Yup.string().when("extract-title-using-regex", {
is: true,
then: (schema) => schema.trim().required("Title extraction regex is required when enabled")
}),
"title-match-min-ratio": Yup.number().min(1, "Must be between 1-100").max(100, "Must be between 1-100")
})
})
});
export const GameManagement = withConfigPage(GameManagementLayout, "Games", validationSchema);
@@ -20,7 +20,7 @@ function GameRequestManagementLayout({getConfig, formik}: any) {
<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"]}/>
isDisabled={!formik.values.security["allow-public-access"]}/>
<ConfigFormField configElement={getConfig("requests.games.max-open-requests-per-user")}/>
</div>
@@ -1,115 +0,0 @@
import React from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import * as Yup from 'yup';
import "Frontend/util/yup-extensions";
import {addToast, Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import {PlusIcon} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
function LibraryManagementLayout({getConfig, formik}: any) {
const libraryCreationModal = useDisclosure();
const state = useSnapshot(libraryState);
async function updateLibrary(library: LibraryUpdateDto) {
await LibraryEndpoint.updateLibrary(library);
addToast({
title: "Library updated",
description: `Library ${library.name} has been updated.`,
color: "success"
})
}
async function removeLibrary(library: LibraryDto) {
await LibraryEndpoint.deleteLibrary(library.id);
addToast({
title: "Library removed",
description: `Library ${library.name} has been removed.`,
color: "success"
})
}
return (
<div className="flex flex-col">
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("library.allow-public-access")}/>
<Section title="Scanning"/>
<div className="flex flex-col gap-4">
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")} isDisabled/>
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
<div className="flex flex-row gap-4 items-baseline">
<ConfigFormField configElement={getConfig("library.scan.extract-title-using-regex")}/>
<ConfigFormField configElement={getConfig("library.scan.title-extraction-regex")}
isDisabled={!formik.values.library.scan["extract-title-using-regex"]}/>
</div>
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
</div>
<Section title="Metadata"/>
<div className="flex flex-row items-baseline">
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")}/>
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
isDisabled={!formik.values.library.metadata.update.enabled}/>
</div>
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
<Tooltip content="Add new library">
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
<PlusIcon/>
</Button>
</Tooltip>
</div>
<Divider className="mb-4"/>
{state.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{state.sorted.map((library) =>
// @ts-ignore
<LibraryOverviewCard library={library} updateLibrary={updateLibrary}
removeLibrary={removeLibrary} key={library.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No libraries found</p>
}
<LibraryCreationModal
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>
</div>
);
}
const validationSchema = Yup.object({
library: Yup.object({
metadata: Yup.object({
update: Yup.object({
enabled: Yup.boolean(),
schedule: Yup.string().when("enabled", {
is: true,
then: (schema) => schema.cron()
}),
})
}),
scan: Yup.object({
"extract-title-using-regex": Yup.boolean(),
"title-extraction-regex": Yup.string().when("extract-title-using-regex", {
is: true,
then: (schema) => schema.trim().required("Title extraction regex is required when enabled")
}),
"title-match-min-ratio": Yup.number().min(1, "Must be between 1-100").max(100, "Must be between 1-100")
})
})
});
export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", validationSchema);
@@ -121,13 +121,13 @@ function MessageManagementLayout({getConfig, formik}: any) {
<EditTemplateModal
isOpen={editorModal.isOpen}
onOpenChange={editorModal.onOpenChange}
selectedTemplate={selectedTemplate!!}
selectedTemplate={selectedTemplate!}
/>
<SendTestNotificationModal
isOpen={testNotificationModal.isOpen}
onOpenChange={testNotificationModal.onOpenChange}
selectedTemplate={selectedTemplate!!}
selectedTemplate={selectedTemplate!}
/>
</div>
);
@@ -19,7 +19,7 @@ export default function PluginManagement() {
<div className="flex flex-col gap-8">
{pluginTypes.map(type =>
// @ts-ignore
<PluginManagementSection key={type} type={type} plugins={state.pluginsByType[type]}/>
<PluginManagementSection key={type} type={type}/>
)}
</div>
</div>
@@ -3,14 +3,14 @@ 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 {addToast, Button, Checkbox, CheckboxGroup, Tooltip} from "@heroui/react";
import { MagicWandIcon, WarningIcon } from "@phosphor-icons/react";
import {addToast, Button} from "@heroui/react";
import {MagicWandIcon} from "@phosphor-icons/react";
function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
function SecurityManagementLayout({getConfig, formik, setSaveMessage}: any) {
useEffect(() => {
if (formik.dirty) {
setSaveMessage("Gameyfin must be restarted for the changes to take effect");
setSaveMessage("Gameyfin must be restarted for changes in the SSO configuration to take effect");
} else {
setSaveMessage(null);
}
@@ -43,41 +43,26 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
return (
<div className="flex flex-col">
<div className="flex flex-row">
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("security.allow-public-access")}/>
<Section title="Single Sign-On"/>
<div className="flex flex-row items-start gap-8">
<div className="flex flex-col">
<h2 className="text-xl font-bold mb-4">General configuration</h2>
<ConfigFormField className="mb-4"
configElement={getConfig("sso.oidc.enabled")}/>
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.roles-claim")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
<div className="flex flex-col flex-1">
<Section title="SSO configuration"/>
<ConfigFormField configElement={getConfig("sso.oidc.enabled")}/>
<Section title="SSO user handling"/>
<div className="flex flex-row items-baseline mb-4">
<CheckboxGroup className="flex flex-col flex-1 items-baseline gap-2"
value={["auto-register-new-users"]}>
<div className="flex flex-row gap-2">
<Checkbox className="items-baseline" value="auto-register-new-users" isDisabled>
Automatically create new users after registration
</Checkbox>
<Tooltip content={"Currently not configurable (always enabled)"} placement="right">
<WarningIcon weight="fill"/>
</Tooltip>
</div>
</CheckboxGroup>
{/*TODO: enable when the issues with unregistered SSO users are sorted
<ConfigFormField configElement={getConfig("sso.oidc.auto-register-new-users")} isDisabled={!formik.values.sso.oidc.enabled}/>
*/}
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled ||
!formik.values.sso.oidc["auto-register-new-users"]}/>
</div>
<div className="flex flex-row items-center gap-4">
<ConfigFormField configElement={getConfig("sso.oidc.roles-claim")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
<Section title="SSO provider configuration"/>
<h2 className="text-xl font-bold mb-4">SSO Provider Configuration</h2>
<ConfigFormField configElement={getConfig("sso.oidc.client-id")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.client-secret")}
@@ -111,7 +96,6 @@ const validationSchema = Yup.object({
sso: Yup.object({
oidc: Yup.object({
enabled: Yup.boolean(),
"auto-register-new-users": Yup.boolean().required(),
"match-existing-users-by": Yup.string().required(),
"client-id": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Client ID is required") : schema
@@ -141,4 +125,4 @@ const validationSchema = Yup.object({
})
});
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", validationSchema);
export const SecurityManagement = withConfigPage(SecurityManagementLayout, "Security", validationSchema);
@@ -4,8 +4,7 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import {UserEndpoint} from "Frontend/generated/endpoints";
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import { InfoIcon, UserPlusIcon } from "@phosphor-icons/react";
import {UserPlusIcon} from "@phosphor-icons/react";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
@@ -32,10 +31,6 @@ function UserManagementLayout({getConfig, formik}: any) {
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Users</h2>
{!getConfig("sso.oidc.auto-register-new-users").value &&
<SmallInfoField className="mb-4 text-warning" icon={InfoIcon}
message="Automatic user registration for SSO users is disabled"/>
}
<Tooltip content="Invite new user">
<Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}>
<UserPlusIcon/>
@@ -3,7 +3,7 @@ import {ConfigEndpoint} from "Frontend/generated/endpoints";
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
import {Form, Formik} from "formik";
import {Button, Skeleton} from "@heroui/react";
import { CheckIcon, InfoIcon } from "@phosphor-icons/react";
import {CheckIcon, InfoIcon} from "@phosphor-icons/react";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
import {useSnapshot} from "valtio/react";
@@ -32,7 +32,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
}
function getConfig(key: string): ConfigEntryDto | undefined {
return state.state[key] as ConfigEntryDto | undefined;
return state.state[key];
}
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
@@ -11,16 +11,15 @@ import {
} from "@heroui/react";
import {useSnapshot} from "valtio/react";
import {scanState} from "Frontend/state/ScanState";
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
import {libraryState} from "Frontend/state/LibraryState";
import { TargetIcon, WarningIcon } from "@phosphor-icons/react";
import {TargetIcon, WarningIcon} from "@phosphor-icons/react";
import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils";
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
import {useEffect, useState} from "react";
export default function ScanProgressPopover() {
const libraries = useSnapshot(libraryState).state;
const scans = useSnapshot(scanState).sortedByStartTime as LibraryScanProgress[];
const scans = useSnapshot(scanState).sortedByStartTime;
const scanInProgress = useSnapshot(scanState).isScanning;
// Add state to track current time and force re-renders
@@ -50,7 +49,7 @@ export default function ScanProgressPopover() {
</Button>
</PopoverTrigger>
<PopoverContent>
<div className="flex flex-col gap-2 m-2 min-w-96 w-fit">
<div className="flex flex-col gap-2 m-2 min-w-md">
{scans.length === 0 ?
<p className="flex h-12 items-center justify-center text-sm text-default-500">
No scans in progress or in history.
@@ -59,12 +58,12 @@ export default function ScanProgressPopover() {
{scans.map((scan, index) =>
<div className="flex flex-col" key={scan.scanId}>
<div
className="flex flex-row justify-between items-center text-default-500 mb-1">
className="flex flex-row gap-4 justify-between items-center text-default-500 mb-1">
<p>{toTitleCase(scan.type)} scan for library&nbsp;
<Link underline="always"
color="foreground"
size="sm"
href={`/administration/libraries/library/${scan.libraryId}`}>
href={`/administration/games/library/${scan.libraryId}`}>
{libraries[scan.libraryId].name}
</Link>
</p>
@@ -1,8 +1,7 @@
import {Autocomplete, AutocompleteItem} from "@heroui/react";
import { CaretRightIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import {CaretRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useNavigate} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover";
@@ -10,7 +9,7 @@ export default function SearchBar() {
const navigate = useNavigate();
const state = useSnapshot(gameState);
const games = state.games as GameDto[];
const games = state.games;
return <Autocomplete
aria-label="Search for games"
@@ -0,0 +1,75 @@
import {Button, Card, Tooltip} from "@heroui/react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React, {useEffect, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
import {SlidersHorizontalIcon} from "@phosphor-icons/react";
import {useNavigate} from "react-router";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import ChipList from "Frontend/components/general/ChipList";
interface CollectionOverviewCardProps {
collection: CollectionAdminDto;
}
export function CollectionOverviewCard({collection}: CollectionOverviewCardProps) {
const MAX_COVER_COUNT = 5;
const navigate = useNavigate();
const state = useSnapshot(gameState);
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
useEffect(() => {
if (!state.randomlyOrderedGamesByCollectionId) return;
setRandomGames(getRandomGames());
}, [state]);
function getRandomGames() {
if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return [];
const games = state.randomlyOrderedGamesByCollectionId[collection.id]
.filter(game => game.cover?.id != null);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
return (
<Card className="flex flex-col justify-between w-[353px]">
<div className="flex flex-1 justify-center items-center">
<div className="flex flex-1 opacity-10 min-h-[100px]">
<IconBackgroundPattern/>
{randomGames.length > 0 &&
<div className="absolute flex flex-row">
{randomGames.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.cover?.id}/>
))}
</div>
}
</div>
<p className="absolute text-2xl font-bold">{collection.name}</p>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => navigate('collection/' + collection.id)}>
<SlidersHorizontalIcon/>
</Button>
</Tooltip>
</div>
</div>
{collection.stats &&
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
<p>Games</p>
<p>Downloads</p>
<p>Platforms</p>
<p className="font-bold">{collection.stats.gamesCount}</p>
<p className="font-bold">{collection.stats.downloadCount}</p>
<ChipList items={collection.stats.gamePlatforms} maxVisible={0}
defaultContent={collection.stats.gamesCount > 0 ? "All" : "None"}/>
</div>
}
</Card>
);
}
@@ -1,5 +1,4 @@
import {Button, Card, Tooltip} from "@heroui/react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/covers/GameCover";
@@ -23,7 +22,9 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
const randomGames = getRandomGames();
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
const games = state.randomlyOrderedGamesByLibraryId[library.id]
.filter(game => game.cover?.id != null);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
@@ -40,7 +41,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
{randomGames.length > 0 &&
<div className="absolute flex flex-row">
{randomGames.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
<GameCover game={game} size={100} radius="none" key={game.cover?.id}/>
))}
</div>
}
@@ -1,5 +1,20 @@
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
import { CheckCircleIcon, IconContext, PauseCircleIcon, PlayCircleIcon, PowerIcon, QuestionIcon, QuestionMarkIcon, SealCheckIcon, SealQuestionIcon, SealWarningIcon, SlidersHorizontalIcon, StopCircleIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react";
import {
CheckCircleIcon,
IconContext,
PauseCircleIcon,
PlayCircleIcon,
PowerIcon,
QuestionIcon,
QuestionMarkIcon,
SealCheckIcon,
SealQuestionIcon,
SealWarningIcon,
SlidersHorizontalIcon,
StopCircleIcon,
WarningCircleIcon,
XCircleIcon
} from "@phosphor-icons/react";
import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React, {ReactNode} from "react";
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
@@ -105,11 +120,11 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
return state === PluginState.DISABLED;
}
function togglePluginEnabled() {
async function togglePluginEnabled() {
if (isDisabled(plugin.state)) {
PluginEndpoint.enablePlugin(plugin.id);
await PluginEndpoint.enablePlugin(plugin.id);
} else {
PluginEndpoint.disablePlugin(plugin.id);
await PluginEndpoint.disablePlugin(plugin.id);
}
}
@@ -0,0 +1,84 @@
import {Card, Chip, Image} from "@heroui/react";
import React, {useMemo} from "react";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import Rand from "rand-seed";
import {useNavigate} from "react-router";
interface StartPageDisplayCardProps {
item: LibraryDto | CollectionDto;
}
export function StartPageDisplayCard({item}: StartPageDisplayCardProps) {
const navigate = useNavigate();
const isCollection = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is CollectionDto => {
return 'description' in libraryOrCollection;
};
const isLibrary = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is LibraryDto => {
return !('description' in libraryOrCollection);
};
const gamesState = useSnapshot(gameState);
const randomImageId = useMemo<number | null>(() => getRandomImageId(), [item]);
const link = useMemo<string>(() => getLink(), [item]);
const type = isCollection(item) ? 'Collection' : 'Library';
/**
* Gets a random cover ID from the games in the specified library or collection.
* Since the Random class is seeded with the game ID, the same game and image will always be selected for a given library/collection (unless the games inside change).
* @return {number | null} The random cover ID or null if none found.
*/
function getRandomImageId(): number | null {
let games: GameDto[] = [];
if (isCollection(item)) {
games = gamesState.randomlyOrderedGamesByCollectionId[item.id] as GameDto[];
} else if (isLibrary(item)) {
games = gamesState.randomlyOrderedGamesByLibraryId[item.id] as GameDto[];
}
if (!games || games.length == 0) return null;
// Find the first game that has at least one screenshot available
let game: GameDto | undefined = games.find(game => game.images && game.images.length > 0);
if (!game) return null;
const random = new Rand(`${item.id}-${game.id}`);
const randomImageIndex = Math.floor(random.next() * game.images!.length);
return game.images![randomImageIndex].id;
}
function getLink(): string {
if (isCollection(item)) {
return `/collection/${item.id}`;
} else if (isLibrary(item)) {
return `/library/${item.id}`;
}
return '#';
}
return randomImageId && (
<Card isPressable={true}
onPress={() => navigate(link)}
className="h-48 w-96 relative overflow-hidden scale-95 hover:scale-100 shine transition-all select-none">
<Image
src={`images/cover/${randomImageId}`}
className="absolute inset-0 w-full h-full object-cover brightness-40 z-0"
removeWrapper
/>
<div className="flex flex-col gap-1 relative z-10 items-center justify-center h-full">
<h2 className="text-white text-2xl font-bold text-center px-4">
{item.name}
</h2>
<Chip size="sm" radius="sm">{type}</Chip>
</div>
</Card>
);
}
@@ -0,0 +1,57 @@
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
import React, {useEffect, useState} from "react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import {Card} from "@heroui/react";
interface CollectionHeaderProps {
collection: CollectionAdminDto;
className?: string;
}
export default function CollectionHeader({collection, className}: CollectionHeaderProps) {
const MAX_COVER_COUNT = 5;
const state = useSnapshot(gameState);
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
useEffect(() => {
if (!state.randomlyOrderedGamesByCollectionId) return;
setRandomGames(getRandomGames());
}, [state]);
function getRandomGames() {
if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return [];
const games = state.randomlyOrderedGamesByCollectionId[collection.id]
.filter(game => game.images && game.images.length > 0);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
return (
<Card className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
<IconBackgroundPattern/>
<div className="flex flex-row items-center w-full h-full brightness-50">
{randomGames.map((game, idx) => (
<div
key={idx}
className="flex-none overflow-hidden -ml-[10%]"
style={{
width: `calc(100% / ${MAX_COVER_COUNT - 2})`,
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
}}
>
<img
src={`/images/screenshot/${game.images![0].id}`}
alt={`Image ${idx}`}
/>
</div>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-white text-3xl font-bold">{collection.name}</h2>
</div>
</Card>
);
}
@@ -1,16 +1,110 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import type {CellComponentProps} from "react-window";
import {Grid} from "react-window";
import {useEffect, useRef, useState} from "react";
interface CoverGridProps {
games: GameDto[];
}
// Constants for grid layout
const MIN_COLUMN_WIDTH = 180; // Minimum width per item (minmax value from original)
const MAX_COLUMN_WIDTH = 212; // Maximum width per item (minmax value from original)
const GAP = 16; // gap-4 = 1rem = 16px
const ASPECT_RATIO = 12 / 17; // Game cover aspect ratio (width/height)
export default function CoverGrid({games}: CoverGridProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
// Update container width on resize
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth);
}
};
const resizeObserver = new ResizeObserver(updateDimensions);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
updateDimensions();
return () => resizeObserver.disconnect();
}, []);
// Calculate how many columns can fit
const columnCount = Math.max(1, Math.floor((containerWidth + GAP) / (MIN_COLUMN_WIDTH + GAP)));
// Calculate actual column width to distribute space evenly (up to MAX_COLUMN_WIDTH)
const actualColumnWidth = Math.min(
MAX_COLUMN_WIDTH,
Math.floor((containerWidth - (columnCount - 1) * GAP) / columnCount)
);
// Calculate cover height based on width and aspect ratio
// GameCover's size prop is the height, so we need to calculate height from width
const coverHeight = Math.floor(actualColumnWidth / ASPECT_RATIO);
// Calculate row count
const rowCount = Math.ceil(games.length / columnCount);
// Cell renderer for react-window Grid
const Cell = ({
columnIndex,
rowIndex,
style
}: CellComponentProps<{}>) => {
const gameIndex = rowIndex * columnCount + columnIndex;
// Return empty cell if we're past the end of the games array
if (gameIndex >= games.length) {
return <div style={style}/>;
}
const game = games[gameIndex];
return (
<div
style={{
...style,
paddingBottom: GAP,
display: 'flex',
justifyContent: 'center',
boxSizing: 'border-box'
}}
>
<GameCover game={game} interactive={true} size={coverHeight} lazy={true}/>
</div>
);
};
// Column width function to handle the last column differently
const getColumnWidth = (index: number) => {
// Last column doesn't need gap after it
if (index === columnCount - 1) {
return actualColumnWidth;
}
return actualColumnWidth + GAP;
};
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,212px))] gap-4 justify-center">
{games.map((game) => (
<GameCover key={game.id} game={game} interactive={true}/>
))}
<div ref={containerRef} className="w-full">
{containerWidth > 0 && (
<Grid<{}>
columnCount={columnCount}
columnWidth={getColumnWidth}
rowCount={rowCount}
rowHeight={coverHeight + GAP}
defaultWidth={containerWidth}
cellComponent={Cell}
cellProps={{}}
style={{overflowX: 'hidden'}}
/>
)}
</div>
);
}
@@ -1,66 +1,166 @@
import React, {useEffect, useRef, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {ArrowRightIcon} from "@phosphor-icons/react";
import {CaretLeftIcon, CaretRightIcon} from "@phosphor-icons/react";
import {Button, Link} from "@heroui/react";
import {Grid, GridImperativeAPI} from "react-window";
interface CoverRowProps {
games: GameDto[];
title: string;
onPressShowMore: () => void;
link: string;
}
const aspectRatio = 12 / 17; // aspect ratio of the game cover
const defaultImageHeight = 300; // default height for the image
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
const gap = 8; // gap between items in pixels (gap-2 = 0.5rem = 8px)
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
export function CoverRow({games, title, link}: CoverRowProps) {
const gridRef = useRef<GridImperativeAPI | null>(null);
const [scrollPosition, setScrollPosition] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(games.length);
// Update container width on resize
useEffect(() => {
const calculateVisible = () => {
const updateWidth = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1;
setVisibleCount(maxFit < games.length ? maxFit : games.length);
setContainerWidth(containerRef.current.offsetWidth);
}
};
const resizeObserver = new ResizeObserver(calculateVisible);
const resizeObserver = new ResizeObserver(updateWidth);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
calculateVisible(); // initial calculation
updateWidth();
return () => resizeObserver.disconnect();
}, [games.length]);
}, []);
const showMore = visibleCount < games.length;
// Handle scroll updates - track scroll position from the grid element
useEffect(() => {
let gridElement: HTMLDivElement | null = null;
const handleScroll = () => {
if (gridElement) {
setScrollPosition(gridElement.scrollLeft);
}
};
// Small delay to ensure grid is mounted
const timer = setTimeout(() => {
gridElement = gridRef.current?.element ?? null;
if (gridElement) {
gridElement.addEventListener('scroll', handleScroll);
// Initial scroll position
setScrollPosition(gridElement.scrollLeft);
}
}, 100);
return () => {
clearTimeout(timer);
if (gridElement) {
gridElement.removeEventListener('scroll', handleScroll);
}
};
}, [containerWidth, games.length]);
const totalWidth = games.length * (defaultImageWidth + gap);
const maxScroll = Math.max(0, totalWidth - containerWidth);
const scrollLeft = () => {
const gridElement = gridRef.current?.element;
if (gridElement) {
const itemWidth = defaultImageWidth + gap;
const scrollAmount = itemWidth * 3; // Scroll exactly 3 items
const newPosition = Math.max(0, scrollPosition - scrollAmount);
gridElement.scrollTo({
left: newPosition,
behavior: "smooth"
});
}
};
const scrollRight = () => {
const gridElement = gridRef.current?.element;
if (gridElement) {
const itemWidth = defaultImageWidth + gap;
const scrollAmount = itemWidth * 3; // Scroll exactly 3 items
const newPosition = Math.min(maxScroll, scrollPosition + scrollAmount);
gridElement.scrollTo({
left: newPosition,
behavior: "smooth"
});
}
};
const canScrollLeft = scrollPosition > 1; // Allow small margin for floating point issues
const canScrollRight = scrollPosition < maxScroll - 1 && maxScroll > 0;
// Cell renderer for react-window Grid
const Cell = ({columnIndex, style}: {
ariaAttributes: { "aria-colindex": number; role: "gridcell" };
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
}) => {
const game = games[columnIndex];
return (
<div style={{...style, paddingRight: gap}}>
<GameCover game={game} radius="sm" interactive={true}/>
</div>
);
};
return (
<div className="flex flex-col mb-4">
<p className="text-2xl font-bold mb-4">{title}</p>
<div className="w-full relative">
<div ref={containerRef} className="flex flex-row gap-2 rounded-md bg-transparent">
{games.slice(0, visibleCount).map((game, index) => (
<GameCover key={index} game={game} radius="sm" interactive={true}/>
))}
<div className="flex flex-row justify-between items-baseline mb-4">
<Link href={link} className="flex flex-row gap-1 w-fit items-baseline" color="foreground"
underline="hover">
<p className="text-2xl font-bold">{title}</p>
<CaretRightIcon weight="bold" size={16}/>
</Link>
<div className="flex flex-row gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={scrollLeft}
isDisabled={!canScrollLeft}
aria-label="Scroll left"
>
<CaretLeftIcon weight="bold" size={20}/>
</Button>
<Button
isIconOnly
size="sm"
variant="flat"
onPress={scrollRight}
isDisabled={!canScrollRight}
aria-label="Scroll right"
>
<CaretRightIcon weight="bold" size={20}/>
</Button>
</div>
{showMore && (
<div className="flex flex-row items-center justify-end cursor-pointer"
onClick={onPressShowMore}>
<div className="absolute h-full w-1/4 right-0 bottom-0
bg-linear-to-r from-transparent to-background
transition-all duration-300 ease-in-out hover:opacity-80"/>
<div
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
<p className="text-xl font-semibold">Show more</p>
<ArrowRightIcon weight="bold"/>
</div>
</div>
</div>
<div ref={containerRef} className="w-full relative overflow-hidden">
{containerWidth > 0 && (
<Grid<{}>
gridRef={gridRef}
columnCount={games.length}
columnWidth={defaultImageWidth + gap}
rowCount={1}
rowHeight={defaultImageHeight}
defaultHeight={defaultImageHeight}
defaultWidth={containerWidth}
cellComponent={Cell}
cellProps={{}}
className="scrollbar-hide"
style={{overflow: 'auto'}}
/>
)}
</div>
</div>
@@ -1,21 +1,105 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {Image} from "@heroui/react";
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
import {useEffect, useRef, useState} from "react";
import {decode} from "blurhash";
// Cache to track which images have been loaded across component remounts
const loadedImagesCache = new Set<number>();
interface GameCoverProps {
game: GameDto;
size?: number;
radius?: "none" | "sm" | "md" | "lg";
interactive?: boolean;
lazy?: boolean;
}
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) {
const coverContent = Number.isInteger(game.coverId) ? (
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
export function GameCover({game, size = 300, radius = "sm", interactive = false, lazy = false}: GameCoverProps) {
const [shouldLoad, setShouldLoad] = useState(!lazy);
// Check cache to see if this image has already been loaded
const [isImageLoaded, setIsImageLoaded] = useState(
game.cover ? loadedImagesCache.has(game.cover.id) : false
);
const [blurhashUrl, setBlurhashUrl] = useState<string | undefined>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
// Generate blurhash placeholder image
useEffect(() => {
if (game.cover?.blurhash) {
try {
// Decode blurhash to pixel data
const pixels = decode(game.cover.blurhash, 32, 45); // Small size for placeholder
// Create canvas and draw pixels
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 45;
const ctx = canvas.getContext('2d');
if (ctx) {
const imageData = ctx.createImageData(32, 45);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
// Convert canvas to data URL
setBlurhashUrl(canvas.toDataURL());
}
} catch (e) {
console.error('Failed to decode blurhash:', e);
}
}
}, [game.cover?.blurhash]);
useEffect(() => {
if (!lazy || shouldLoad) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setShouldLoad(true);
observer.disconnect();
}
});
},
{
rootMargin: '200px', // Start loading 200px before the element enters viewport
}
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, [lazy, shouldLoad]);
// Preload the real image when shouldLoad becomes true
useEffect(() => {
if (!shouldLoad || !game.cover || isImageLoaded) return;
const img = document.createElement('img');
img.src = `images/cover/${game.cover.id}`;
img.onload = () => {
loadedImagesCache.add(game.cover!.id);
setIsImageLoaded(true);
};
img.onerror = () => {
// If image fails to load, we'll just show the fallback
setIsImageLoaded(true);
};
}, [shouldLoad, game.cover, isImageLoaded]);
const coverContent = game.cover ? (
<div
ref={containerRef}
className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}
>
<Image
alt={game.title}
className="z-0 object-cover aspect-12/17"
src={`images/cover/${game.coverId}`}
src={shouldLoad && isImageLoaded ? `images/cover/${game.cover.id}` : blurhashUrl}
radius={radius}
height={size}
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
@@ -1,6 +1,5 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import React from "react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
@@ -17,7 +16,9 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
const randomGames = getRandomGames();
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
const games = state.randomlyOrderedGamesByLibraryId[library.id]
.filter(game => game.images && game.images.length > 0);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
@@ -36,7 +37,7 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
}}
>
<img
src={`/images/screenshot/${game.imageIds![0]}`}
src={`/images/screenshot/${game.images![0].id}`}
alt={`Image ${idx}`}
/>
</div>
@@ -1,7 +1,7 @@
import {FieldArray, useField} from "formik";
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
import {KeyboardEvent, useState} from "react";
import { PlusIcon } from "@phosphor-icons/react";
import {PlusIcon} from "@phosphor-icons/react";
// @ts-ignore
const ArrayInput = ({label, ...props}) => {
@@ -35,13 +35,23 @@ const ArrayInput = ({label, ...props}) => {
<div className="flex flex-row flex-wrap gap-2 items-center">
{field.value.map((element: any, index: number) => (
<Chip key={index} onClose={() => arrayHelpers.remove(index)}>
<Chip key={index}
onClose={() => arrayHelpers.remove(index)}
isDisabled={props.isDisabled}
>
{element}
</Chip>
))}
<Popover placement="bottom" showArrow={true}>
<PopoverTrigger>
<Button isIconOnly size="sm" variant="light" radius="full"><PlusIcon/></Button>
<Button isIconOnly
size="sm"
variant="light"
radius="full"
isDisabled={props.isDisabled}
>
<PlusIcon/>
</Button>
</PopoverTrigger>
<PopoverContent>
<Input
@@ -77,7 +77,7 @@ export default function FileTreeView({onPathChange}: { onPathChange: (file: stri
if (subDirectories === undefined) return;
const newNodes = fileDtosToNodes(subDirectories as FileDto[]);
const updatedTree = updateTreeWithNewNodes(fileTree!!, element.id, newNodes);
const updatedTree = updateTreeWithNewNodes(fileTree!, element.id, newNodes);
setFileTree(updatedTree);
setFlattenedFileTree(flattenTree(updatedTree));
@@ -2,7 +2,7 @@ import {Image, useDisclosure} from "@heroui/react";
import React from "react";
import {useField} from "formik";
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
import { ImageBrokenIcon, PencilIcon } from "@phosphor-icons/react";
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
// @ts-ignore
@@ -16,12 +16,12 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
return (<>
<div className="relative group aspect-12/17 cursor-pointer bg-background/50"
onClick={gameCoverPickerModal.onOpenChange}>
{field.value || game.coverId ?
{field.value || game.cover?.id ?
<div className="size-full overflow-hidden">
<Image
alt={game.title}
className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.coverId}`}
src={field.value ? field.value : `images/cover/${game.cover?.id}`}
{...props}
{...field}
radius="none"
@@ -16,12 +16,12 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
return (<>
<div className="relative group size-full cursor-pointer bg-background/50"
onClick={gameHeaderPickerModal.onOpenChange}>
{field.value || game.headerId ?
{field.value || game.header?.id ?
<div className="size-full overflow-hidden">
<Image
alt={game.title}
className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.headerId}`}
src={field.value ? field.value : `images/cover/${game.header?.id}`}
{...props}
{...field}
radius="none"
@@ -14,6 +14,7 @@ import * as Yup from "yup";
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
interface LibraryManagementDetailsProps {
library: LibraryDto;
@@ -45,7 +46,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
color: "success"
});
navigate("/administration/libraries");
navigate("/administration/games");
} catch (e) {
addToast({
title: "Error deleting library",
@@ -84,6 +85,8 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
<Input label="Library name" name="name"/>
<CheckboxInput label="Display on homepage" name="metadata.displayOnHomepage" className="mb-4"/>
<ArrayInputAutocomplete options={Array.from(availablePlatforms)} name="platforms" label="Platforms"/>
<DirectoryMappingInput name="directories"/>
@@ -38,12 +38,12 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
const rowsPerPage = 25;
const state = useSnapshot(gameState);
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameAdminDto[] : [];
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] : [];
const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "title", direction: "ascending"});
const [selectedGame, setSelectedGame] = useState<GameAdminDto>(games[0]);
const [selectedGame, setSelectedGame] = useState<GameAdminDto>(games[0] as GameAdminDto);
const editGameModal = useDisclosure();
const matchGameModal = useDisclosure();
@@ -94,7 +94,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
function getFilteredGames() {
let filteredGames = (games as GameAdminDto[]).filter((game) =>
game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.metadata.path!.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) ||
game.developers?.some(developer => developer.toLowerCase().includes(searchTerm.toLowerCase()))
@@ -102,10 +102,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
if (filter === "confirmed") {
return filteredGames.filter(g => g.metadata.matchConfirmed);
}
if (filter === "nonConfirmed") {
} else if (filter === "nonConfirmed") {
return filteredGames.filter(g => !g.metadata.matchConfirmed);
}
return filteredGames;
}
@@ -178,7 +178,8 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<Link href={`/game/${item.id}`}
color="foreground"
className="text-sm"
underline="hover">{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
underline="hover">
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</Link>
</TableCell>
<TableCell>
@@ -238,7 +239,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<EditGameMetadataModal game={selectedGame}
isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal path={selectedGame.metadata.path!!}
<MatchGameModal path={selectedGame.metadata.path!}
libraryId={library.id}
replaceGameId={selectedGame.id}
initialSearchTerm={selectedGame.title}
@@ -85,7 +85,7 @@ export default function LibraryManagementIgnoredPaths({library}: LibraryManageme
}
function getFilteredPaths() {
return library.ignoredPaths!!.filter((path) =>
return library.ignoredPaths!.filter((path) =>
path.path.toLowerCase().includes(searchTerm.toLowerCase())
)
}
@@ -165,7 +165,10 @@ export default function LibraryManagementIgnoredPaths({library}: LibraryManageme
</Tooltip>
<Tooltip content="Remove entry from list">
<Button isIconOnly size="sm" color="danger"
onPress={() => deleteIgnoredPath(item.path)}><TrashIcon/>
onPress={() => deleteIgnoredPath(item.path)}
isDisabled={item.path.sourceType !== IgnoredPathSourceTypeDto.USER}
>
<TrashIcon/>
</Button>
</Tooltip>
</div>
@@ -0,0 +1,90 @@
import React from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/input/Input";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import CollectionCreateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionCreateDto";
import * as Yup from "yup";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
interface CollectionCreationModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function CollectionCreationModal({
isOpen,
onOpenChange
}: CollectionCreationModalProps) {
async function createCollection(collection: CollectionCreateDto) {
await CollectionEndpoint.createCollection(collection);
addToast({
title: "New collection created",
description: `Collection ${collection.name} created!`,
color: "success"
});
}
return (<>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
<ModalContent>
{(onClose) => (
<Formik
initialValues={{
name: "",
description: ""
}}
validationSchema={Yup.object({
name: Yup.string()
.required("Collection name is required")
.max(255, "Collection name must be 255 characters or less")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createCollection(values);
onClose();
}}
>
{(formik) =>
<Form>
<ModalHeader className="flex flex-col gap-1">Create a new collection</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-2">
<Input
name="name"
label="Collection Name"
placeholder="Enter collection name"
value={formik.values.name}
required
/>
<TextAreaInput
name="description"
label="Collection Description"
placeholder="Enter collection description"
value={formik.values.description}
/>
</div>
</ModalBody>
<ModalFooter className="flex flex-row justify-end">
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</ModalFooter>
</Form>
}
</Formik>
)}
</ModalContent>
</Modal>
</>
);
}
@@ -0,0 +1,181 @@
import {useSnapshot} from "valtio/react";
import {
Button,
Input,
Link,
Select,
SelectItem,
SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip
} from "@heroui/react";
import React, {useMemo, useState} from "react";
import {GameAdminDto} from "Frontend/dtos/GameDtos";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import {MinusIcon, PlusIcon} from "@phosphor-icons/react";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import {libraryState} from "Frontend/state/LibraryState";
import {gameState} from "Frontend/state/GameState";
import {collectionState} from "Frontend/state/CollectionState";
interface CollectionGamesTableProps {
collectionId: number;
}
export default function CollectionGamesTable({collectionId}: CollectionGamesTableProps) {
const gamesState = useSnapshot(gameState);
const games = gamesState.games as GameAdminDto[];
const librariesState = useSnapshot(libraryState);
const libraries = librariesState.state as Record<number, LibraryAdminDto>;
const collectionsState = useSnapshot(collectionState);
const collection = collectionsState.state[collectionId];
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "inCollection" | "notInCollection">("all");
function libraryName(game: GameAdminDto) {
return libraries[game.libraryId]?.name || "Unknown";
}
const gameInCollectionMap = useMemo(() => {
const map = new Map<number, boolean>();
games.forEach(game => {
map.set(game.id, collection.gameIds!.includes(game.id));
});
return map;
}, [games, collection.gameIds]);
function isGameInCollection(game: GameAdminDto) {
return gameInCollectionMap.get(game.id) ?? false;
}
const filteredGames = useMemo(() => {
return games
.filter((game) => game.title.toLowerCase().includes(searchTerm.toLowerCase()))
.filter(game => {
if (filter === "inCollection") {
return isGameInCollection(game);
} else if (filter === "notInCollection") {
return !isGameInCollection(game);
}
return true;
});
}, [games, searchTerm, filter, gameInCollectionMap]);
const sortedGames = useMemo(() => {
return filteredGames
.slice()
.sort((a, b) => {
let cmp: number;
switch (sortDescriptor.column) {
case "title":
cmp = a.title.localeCompare(b.title);
break;
case "library":
cmp = (libraryName(a)).localeCompare(libraryName(b));
break;
default:
cmp = 0;
}
if (sortDescriptor.direction === "descending") {
cmp *= -1;
}
return cmp;
})
.map(game => ({...game, _inCollection: isGameInCollection(game)}));
}, [filteredGames, sortDescriptor, libraries, gameInCollectionMap]);
async function addGameToCollection(game: GameAdminDto) {
await CollectionEndpoint.addGameToCollection(collectionId, game.id);
}
async function removeGameFromCollection(game: GameAdminDto) {
await CollectionEndpoint.removeGameFromCollection(collectionId, game.id);
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2 justify-between">
<Input
className="w-96"
isClearable
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClear={() => setSearchTerm("")}
/>
<Select
selectedKeys={[filter]}
disallowEmptySelection
onSelectionChange={keys => setFilter(Array.from(keys)[0] as any)}
className="w-64"
>
<SelectItem key="all">Show all games</SelectItem>
<SelectItem key="inCollection">Show only games in collection</SelectItem>
<SelectItem key="notInCollection">Show only games not in collection</SelectItem>
</Select>
</div>
<Table isStriped isHeaderSticky
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
classNames={{
base: "h-96 overflow-scroll"
}}>
<TableHeader>
<TableColumn key="title" allowsSorting>Title</TableColumn>
<TableColumn key="library" allowsSorting>Library</TableColumn>
<TableColumn width={1}>Actions</TableColumn>
</TableHeader>
<TableBody
emptyContent="Your filters did not match any games."
items={sortedGames}>
{(game) => (
// Key includes _inCollection to force re-render when that value changes
<TableRow key={`${game.id}-${game._inCollection}`}>
<TableCell>
<Link href={`/game/${game.id}`}
color="foreground"
className="text-sm"
underline="hover">
{game.title} ({game.release ? new Date(game.release).getFullYear() : "unknown"})
</Link>
</TableCell>
<TableCell>
<Link href={`/administration/games/library/${game.libraryId}`}
color="foreground"
className="text-sm"
underline="hover">
{libraryName(game)}
</Link>
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
<Tooltip content="Add game to collection">
<Button isIconOnly size="sm"
onPress={() => addGameToCollection(game)}
isDisabled={game._inCollection}>
<PlusIcon/>
</Button>
</Tooltip>
<Tooltip content="Remove game from collection">
<Button isIconOnly size="sm"
onPress={() => removeGameFromCollection(game)}
isDisabled={!game._inCollection}>
<MinusIcon/>
</Button>
</Tooltip>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,42 @@
import React from "react";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import {useSnapshot} from "valtio/react";
import {collectionState} from "Frontend/state/CollectionState";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import CollectionUpdateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionUpdateDto";
import PrioritiesModal from "./PrioritiesModal";
interface CollectionPrioritiesModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function CollectionPrioritiesModal({isOpen, onOpenChange}: CollectionPrioritiesModalProps) {
const collections = useSnapshot(collectionState).sorted;
const updateCollections = async (reorderedCollections: any[]) => {
const updateDtos: CollectionUpdateDto[] = reorderedCollections.map((collection, index): CollectionUpdateDto => {
return {
id: collection.id,
metadata: {
displayOnHomepage: collection.metadata!.displayOnHomepage,
displayOrder: index
}
};
});
await CollectionEndpoint.updateCollections(updateDtos);
};
return (
<PrioritiesModal
title="Edit collection order"
subtitle="Collections higher on the list are displayed at the start"
items={collections as CollectionDto[]}
updateItems={updateCollections}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
);
}
@@ -7,7 +7,6 @@ import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import PluginIcon from "Frontend/components/general/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";
interface GameCoverPickerModalProps {
game: GameDto;
@@ -110,7 +109,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
/>
<div
className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100">
<PluginIcon plugin={state[cover.source] as PluginDto} size={32}
<PluginIcon plugin={state[cover.source]} size={32}
blurred={false} showTooltip={false}/>
<p className="text-s text-center">{cover.title}</p>
<ArrowRightIcon/>
@@ -7,7 +7,6 @@ import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import PluginIcon from "Frontend/components/general/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";
interface GameHeaderPickerModalProps {
game: GameDto;
@@ -109,7 +108,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
/>
<div
className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100">
<PluginIcon plugin={state[header.source] as PluginDto} size={32}
<PluginIcon plugin={state[header.source]} size={32}
blurred={false} showTooltip={false}/>
<p className="text-s text-center">{header.title}</p>
<ArrowRightIcon/>
@@ -1,15 +1,14 @@
import React, {useState} from "react";
import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup";
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
interface LibraryCreationModalProps {
isOpen: boolean;
@@ -24,8 +23,8 @@ export default function LibraryCreationModal({
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
const availablePlatforms = useSnapshot(platformState).available;
async function createLibrary(library: LibraryDto) {
await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation);
async function createLibrary(library: LibraryAdminDto) {
await LibraryEndpoint.createLibrary(library, scanAfterCreation);
addToast({
title: "New library created",
@@ -39,20 +38,25 @@ export default function LibraryCreationModal({
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{name: "", directories: [], platforms: []}}
validationSchema={Yup.object({
name: Yup.string()
.required("Library name is required")
.max(255, "Library name must be 255 characters or less"),
directories: Yup.array()
.of(Yup.object())
.min(1, "At least one directory is required")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
<Formik
initialValues={{
name: "",
directories: [],
platforms: []
}}
validationSchema={Yup.object({
name: Yup.string()
.required("Library name is required")
.max(255, "Library name must be 255 characters or less"),
directories: Yup.array()
.of(Yup.object())
.min(1, "At least one directory is required")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
>
{(formik) =>
<Form>
@@ -0,0 +1,41 @@
import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import PrioritiesModal from "./PrioritiesModal";
interface LibraryPrioritiesModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function LibraryPrioritiesModal({isOpen, onOpenChange}: LibraryPrioritiesModalProps) {
const libraries = useSnapshot(libraryState).sorted;
const updateLibraries = async (reorderedLibraries: LibraryDto[]) => {
const updateDtos: LibraryUpdateDto[] = reorderedLibraries.map((library, index): LibraryUpdateDto => {
return {
id: library.id,
metadata: {
displayOnHomepage: library.metadata!.displayOnHomepage,
displayOrder: index
}
};
});
await LibraryEndpoint.updateLibraries(updateDtos);
};
return (
<PrioritiesModal
title="Edit library order"
subtitle="Libraries higher on the list are displayed at the start"
items={libraries}
updateItems={updateLibraries}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
);
}
@@ -19,7 +19,6 @@ import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/G
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 {libraryState} from "Frontend/state/LibraryState";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
@@ -129,7 +128,7 @@ export default function MatchGameModal({
<div className="flex flex-row gap-2">
{Object.values(item.originalIds).map(
originalId => <PluginIcon
plugin={state[originalId.pluginId] as PluginDto}/>
plugin={state[originalId.pluginId]}/>
)}
</div>
</TableCell>
@@ -1,113 +1,39 @@
import React from "react";
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
import { CaretUpDownIcon } from "@phosphor-icons/react";
import {useListData} from "@react-stately/data";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {PluginEndpoint} from "Frontend/generated/endpoints";
import PrioritiesModal from "./PrioritiesModal";
import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
interface PluginPrioritiesModalProps {
plugins: PluginDto[];
isOpen: boolean;
onOpenChange: () => void;
type: string;
}
export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: PluginPrioritiesModalProps) {
export default function PluginPrioritiesModal({isOpen, onOpenChange, type}: PluginPrioritiesModalProps) {
const plugins = useSnapshot(pluginState).sortedByType[type];
const sortedPlugins = useListData({
initialItems: plugins, // Already sorted in parent
getKey: (plugin) => plugin.id
});
const updatePlugins = async (reorderedPlugins: PluginDto[]) => {
const prioritiesMap: Record<string, number> = {};
const totalPlugins = reorderedPlugins.length;
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({'text/plain': sortedPlugins.getItem(key)!.name})),
onReorder(e) {
if (e.keys.has(e.target.key)) return;
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
sortedPlugins.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
sortedPlugins.moveAfter(e.target.key, e.keys);
}
// Recalculate priority based on new position (reversed)
sortedPlugins.items.forEach((plugin, index) => {
const reversedPriority = sortedPlugins.items.length - index;
sortedPlugins.update(plugin.id, {...plugin, priority: reversedPriority});
});
}
});
function generatePrioritiesMap(): Record<string, number> {
let map: Record<string, number> = {};
const totalPlugins = sortedPlugins.items.length;
sortedPlugins.items.forEach((plugin, index) => {
map[plugin.id] = totalPlugins - index; // Reverse order
reorderedPlugins.forEach((plugin, index) => {
// Reverse order: first item gets highest priority
prioritiesMap[plugin.id] = totalPlugins - index;
});
return map;
}
async function setPluginPriorities(onClose: () => void) {
try {
const prioritiesMap = generatePrioritiesMap();
await PluginEndpoint.setPluginPriorities(prioritiesMap);
addToast({
title: "Plugin order updated",
description: "Plugin order has been updated successfully.",
color: "success"
});
onClose();
} catch (e) {
addToast({
title: "Error",
description: "An error occurred while updating plugin order.",
color: "warning"
});
}
}
await PluginEndpoint.setPluginPriorities(prioritiesMap);
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<p>Edit plugin order</p>
<p className="text-small font-normal">Plugins higher on the list are preferred</p>
</ModalHeader>
<ModalBody>
<ListBox items={sortedPlugins.items}
dragAndDropHooks={dragAndDropHooks}
className="flex flex-col gap-2">
{(plugin: PluginDto) => (
<ListBoxItem
key={plugin.id}
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
<div className="flex flex-row gap-2 items-center">
<Chip size="sm" color="primary">
{sortedPlugins.items.findIndex(p => p.id === plugin.id) + 1}
</Chip>
<p className="font-normal text-small">{plugin.name}</p>
</div>
<CaretUpDownIcon/>
</ListBoxItem>
)}
</ListBox>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={() => setPluginPriorities(onClose)}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<PrioritiesModal
title="Edit plugin order"
subtitle="Plugins higher on the list are preferred"
items={plugins}
updateItems={updatePlugins}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
);
}
@@ -0,0 +1,127 @@
import React, {useEffect, useState} from "react";
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
import {CaretUpDownIcon} from "@phosphor-icons/react";
import {useListData} from "@react-stately/data";
export interface PrioritizableItem {
id: number | string;
name: string;
}
interface PrioritiesModalProps<T extends PrioritizableItem> {
title: string;
subtitle: string;
items: T[];
updateItems: (items: T[]) => Promise<void>;
isOpen: boolean;
onOpenChange: () => void;
}
export default function PrioritiesModal<T extends PrioritizableItem>({
items,
isOpen,
onOpenChange,
title,
subtitle,
updateItems
}: PrioritiesModalProps<T>) {
const sortedItems = useListData<T>({
initialItems: items,
getKey: (item) => item.id
});
// Track order changes to trigger re-renders
const [orderVersion, setOrderVersion] = useState(0);
// Update sortedItems when items change
useEffect(() => {
sortedItems.setSelectedKeys(new Set());
sortedItems.items.forEach(item => sortedItems.remove(item.id));
items.forEach(item => sortedItems.append(item));
setOrderVersion(prev => prev + 1);
}, [items]);
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({'text/plain': sortedItems.getItem(key)!.name})),
onReorder(e) {
if (e.keys.has(e.target.key)) return;
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
sortedItems.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
sortedItems.moveAfter(e.target.key, e.keys);
}
// Trigger re-render after reorder
setOrderVersion(prev => prev + 1);
}
});
async function updateItemOrder(onClose: () => void) {
try {
// Pass the reordered items directly to the update function
// The parent component will handle the actual transformation
await updateItems(sortedItems.items);
addToast({
title: "Order updated",
description: "Item order has been updated successfully.",
color: "success"
});
onClose();
} catch (e) {
addToast({
title: "Error",
description: "An error occurred while updating item order.",
color: "warning"
});
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<p>{title}</p>
<p className="text-small font-normal">{subtitle}</p>
</ModalHeader>
<ModalBody>
<ListBox items={sortedItems.items}
dragAndDropHooks={dragAndDropHooks}
className="flex flex-col gap-2"
key={orderVersion}>
{(item: T) => (
<ListBoxItem
key={item.id}
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
<div className="flex flex-row gap-2 items-center">
<Chip size="sm" color="primary">
{sortedItems.items.findIndex(p => p.id === item.id) + 1}
</Chip>
<p className="font-normal text-small">{item.name}</p>
</div>
<CaretUpDownIcon/>
</ListBoxItem>
)}
</ListBox>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={() => updateItemOrder(onClose)}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -1,17 +1,19 @@
import {Button, Tooltip, useDisclosure} from "@heroui/react";
import { ListNumbersIcon } from "@phosphor-icons/react";
import {ListNumbersIcon} from "@phosphor-icons/react";
import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
import React from "react";
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
import {camelCaseToTitle} from "Frontend/util/utils";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
interface PluginManagementSectionProps {
type: string;
plugins: PluginDto[];
}
export function PluginManagementSection({type, plugins = []}: PluginManagementSectionProps) {
export function PluginManagementSection({type}: PluginManagementSectionProps) {
const plugins = useSnapshot(pluginState).sortedByType[type];
const pluginPrioritiesModal = useDisclosure();
return (
@@ -40,10 +42,9 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
</div>}
<PluginPrioritiesModal
key={plugins.map(p => p.id + p.priority).join(',')} // force re-mount if plugin order changes
plugins={[...plugins].sort((a, b) => b.priority - a.priority)}
isOpen={pluginPrioritiesModal.isOpen}
onOpenChange={pluginPrioritiesModal.onOpenChange}
type={type}
/>
</div>);
}
+4
View File
@@ -6,6 +6,10 @@ import {router} from './routes';
const container = document.getElementById('outlet')!;
const root = createRoot(container);
declare module 'valtio' {
function useSnapshot<T extends object>(p: T): T
}
root.render(
<StrictMode>
<RouterProvider router={router}/>
+20 -15
View File
@@ -4,10 +4,10 @@ import HomeView from "Frontend/views/HomeView";
import SetupView from "Frontend/views/SetupView";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
import App from "Frontend/App";
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
import {GameManagement} from "Frontend/components/administration/GameManagement";
import {UserManagement} from "Frontend/components/administration/UserManagement";
import ProfileManagement from "Frontend/components/administration/ProfileManagement";
import {SsoManagement} from "Frontend/components/administration/SsoManagement";
import {SecurityManagement} from "Frontend/components/administration/SecurityManagement";
import {AdministrationView} from "Frontend/views/AdministrationView";
import {ProfileView} from "Frontend/views/ProfileView";
import {MessageManagement} from "Frontend/components/administration/MessageManagement";
@@ -20,13 +20,14 @@ import {SystemManagement} from "Frontend/components/administration/SystemManagem
import GameView from "Frontend/views/GameView";
import LibraryManagementView from "Frontend/views/LibraryManagementView";
import SearchView from "Frontend/views/SearchView";
import RecentlyAddedView from "Frontend/views/RecentlyAddedView";
import LibraryView from "Frontend/views/LibraryView";
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
import ErrorView from "Frontend/views/ErrorView";
import GameRequestView from "Frontend/views/GameRequestView";
import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement";
import {DownloadManagement} from "Frontend/components/administration/DownloadManagement";
import CollectionManagementView from "Frontend/views/CollectionManagementView";
import CollectionView from "Frontend/views/CollectionView";
export const {router, routes} = new RouterConfigurationBuilder()
.withReactRoutes([
@@ -45,11 +46,6 @@ export const {router, routes} = new RouterConfigurationBuilder()
element: <SearchView/>,
handle: {title: 'Search'}
},
{
path: 'recently-added',
element: <RecentlyAddedView/>,
handle: {title: 'Recently Added'}
},
{
path: '/requests',
element: <GameRequestView/>,
@@ -59,6 +55,10 @@ export const {router, routes} = new RouterConfigurationBuilder()
path: 'library/:libraryId',
element: <LibraryView/>
},
{
path: 'collection/:collectionId',
element: <CollectionView/>
},
{
path: 'game/:gameId',
element: <GameView/>
@@ -86,15 +86,20 @@ export const {router, routes} = new RouterConfigurationBuilder()
handle: {title: 'Administration'},
children: [
{
path: 'libraries',
element: <LibraryManagement/>,
handle: {title: 'Administration - Libraries'}
path: 'games',
element: <GameManagement/>,
handle: {title: 'Administration - Games'}
},
{
path: 'libraries/library/:libraryId',
path: 'games/library/:libraryId',
element: <LibraryManagementView/>,
handle: {title: 'Administration - Library'}
},
{
path: 'games/collection/:collectionId',
element: <CollectionManagementView/>,
handle: {title: 'Administration - Collection'}
},
{
path: 'requests',
element: <GameRequestManagement/>,
@@ -111,9 +116,9 @@ export const {router, routes} = new RouterConfigurationBuilder()
handle: {title: 'Administration - Users'}
},
{
path: 'sso',
element: <SsoManagement/>,
handle: {title: 'Administration - SSO'}
path: 'security',
element: <SecurityManagement/>,
handle: {title: 'Administration - Security'}
},
{
path: 'messages',
@@ -0,0 +1,70 @@
import {Subscription} from "@vaadin/hilla-frontend";
import {proxy} from "valtio/index";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import CollectionEvent from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionEvent";
type CollectionState = {
subscription?: Subscription<CollectionEvent[]>;
isLoaded: boolean;
state: Record<number, CollectionDto>;
collections: CollectionDto[];
sorted: CollectionDto[];
};
export const collectionState = proxy<CollectionState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get collections() {
return Object.values<CollectionDto>(this.state);
},
get sorted() {
return Object.values<CollectionDto>(this.state).sort((a: any, b: any) => {
const orderA = a.metadata?.displayOrder ?? -1;
const orderB = b.metadata?.displayOrder ?? -1;
// Handle -1 as "end of list"
const effectiveOrderA = orderA === -1 ? Number.MAX_SAFE_INTEGER : orderA;
const effectiveOrderB = orderB === -1 ? Number.MAX_SAFE_INTEGER : orderB;
const orderDiff = effectiveOrderA - effectiveOrderB;
if (orderDiff !== 0) {
return orderDiff;
}
// Fallback to creation date (newer first)
return new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime();
});
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeCollectionState() {
if (collectionState.isLoaded) return;
// Fetch initial collection list
const initialEntries = await CollectionEndpoint.getAll();
initialEntries.forEach((collection: CollectionDto) => {
collectionState.state[collection.id] = collection;
});
// Subscribe to real-time updates
collectionState.subscription = CollectionEndpoint.subscribeToCollectionEvents().onNext((collectionEvents: CollectionEvent[]) => {
collectionEvents.forEach((collectionEvent: CollectionEvent) => {
switch (collectionEvent.type) {
case "created":
case "updated":
//@ts-ignore
collectionState.state[collectionEvent.collection.id] = collectionEvent.collection;
break;
case "deleted":
//@ts-ignore
delete collectionState.state[collectionEvent.collectionId];
break;
}
})
});
}
+21 -14
View File
@@ -11,10 +11,10 @@ type GameState = {
state: Record<number, GameDto>;
games: GameDto[];
gamesByLibraryId: Record<number, GameDto[]>;
gamesByCollectionId: Record<number, GameDto[]>;
sortedAlphabetically: GameDto[];
recentlyAdded: GameDto[];
recentlyUpdated: GameDto[];
randomlyOrderedGamesByLibraryId: Record<number, GameDto[]>;
randomlyOrderedGamesByCollectionId: Record<number, GameDto[]>;
knownPublishers: Set<string>;
knownDevelopers: Set<string>;
knownGenres: Set<string>;
@@ -38,26 +38,33 @@ export const gameState = proxy<GameState>({
return acc;
}, {});
},
get gamesByCollectionId() {
return this.sortedAlphabetically.reduce((acc: Record<number, GameDto[]>, game: GameDto) => {
game.collectionIds?.forEach((collectionId: number) => {
(acc[collectionId] ||= []).push(game);
});
return acc;
}, {});
},
get sortedAlphabetically() {
return this.games
.sort((a: GameDto, b: GameDto) => a.title.localeCompare(b.title, undefined, {sensitivity: 'base'}));
},
get recentlyAdded() {
return this.games
.sort((a: GameDto, b: GameDto) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 25);
},
get recentlyUpdated() {
return this.games
.sort((a: GameDto, b: GameDto) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, 25);
},
get randomlyOrderedGamesByLibraryId() {
const result: Record<number, GameDto[]> = {};
for (const libraryId in this.gamesByLibraryId) {
const rand = new Rand(libraryId.toString());
const rand = new Rand(`library-${libraryId}`);
result[libraryId] = this.gamesByLibraryId[libraryId]
.filter((g: GameDto) => g.coverId && g.imageIds && g.imageIds.length > 0)
.sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5);
}
return result;
},
get randomlyOrderedGamesByCollectionId() {
const result: Record<number, GameDto[]> = {};
for (const collectionId in this.gamesByCollectionId) {
const rand = new Rand(`collection-${collectionId}`);
result[collectionId] = this.gamesByCollectionId[collectionId]
.sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5);
}
+14 -2
View File
@@ -23,8 +23,20 @@ export const libraryState = proxy<LibraryState>({
},
get sorted() {
return Object.values<LibraryDto>(this.state).sort((a, b) => {
if (a.name === undefined || b.name === undefined) return 0;
return a.name.localeCompare(b.name);
const orderA = a.metadata!.displayOrder;
const orderB = b.metadata!.displayOrder;
// Handle -1 as "end of list"
const effectiveOrderA = orderA === -1 ? Number.MAX_SAFE_INTEGER : orderA;
const effectiveOrderB = orderB === -1 ? Number.MAX_SAFE_INTEGER : orderB;
const orderDiff = effectiveOrderA - effectiveOrderB;
if (orderDiff !== 0) {
return orderDiff;
}
// Fallback to creation date (newer first)
return new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime();
});
}
});
+9 -4
View File
@@ -9,7 +9,7 @@ type PluginState = {
isLoaded: boolean;
state: Record<string, PluginDto>;
plugins: PluginDto[];
pluginsByType: Record<string, PluginDto[]>;
sortedByType: Record<string, PluginDto[]>;
};
export const pluginState = proxy<PluginState>({
@@ -20,8 +20,8 @@ export const pluginState = proxy<PluginState>({
get plugins() {
return Object.values<PluginDto>(this.state);
},
get pluginsByType() {
return groupPluginsByType(this.state);
get sortedByType() {
return sortPluginsByType(this.state);
}
});
@@ -52,7 +52,7 @@ export async function initializePluginState() {
/** Computed **/
function groupPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> {
function sortPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> {
const pluginsByType: Record<string, PluginDto[]> = {};
// Convert map to array of plugins
@@ -72,5 +72,10 @@ function groupPluginsByType(pluginsMap: Record<string, PluginDto>): Record<strin
}
}
// Sort plugins within each type by priority (descending order - higher priority first)
for (const type in pluginsByType) {
pluginsByType[type].sort((a, b) => b.priority - a.priority);
}
return pluginsByType;
}
+2 -3
View File
@@ -1,6 +1,5 @@
import {Middleware, MiddlewareContext, MiddlewareNext} from '@vaadin/hilla-frontend';
import {addToast} from "@heroui/react";
import {getReasonPhrase} from "http-status-codes";
export const ErrorHandlingMiddleware: Middleware = async function (
context: MiddlewareContext,
@@ -22,13 +21,13 @@ export const ErrorHandlingMiddleware: Middleware = async function (
if (json.type == "dev.hilla.exception.EndpointException" || json.type == "com.vaadin.hilla.exception.EndpointException") {
addToast({
title: getReasonPhrase(response.status),
title: "Error",
description: json.message,
color: "danger"
})
} else {
addToast({
title: getReasonPhrase(response.status),
title: "Error",
description: `${endpoint}.${method}`,
color: "danger"
})
@@ -13,8 +13,8 @@ import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
const menuItems: MenuItem[] = [
{
title: "Libraries",
url: "libraries",
title: "Games",
url: "games",
icon: <GameControllerIcon/>
},
{
@@ -33,8 +33,8 @@ const menuItems: MenuItem[] = [
icon: <UsersIcon/>
},
{
title: "SSO",
url: "sso",
title: "Security",
url: "security",
icon: <LockKeyIcon/>
},
{
@@ -0,0 +1,127 @@
import {useNavigate, useParams} from "react-router";
import React, {useEffect} from "react";
import {addToast, Button} from "@heroui/react";
import {ArrowLeftIcon, CheckIcon} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
import {collectionState} from "Frontend/state/CollectionState";
import {Form, Formik} from "formik";
import * as Yup from "yup";
import Input from "Frontend/components/general/input/Input";
import Section from "Frontend/components/general/Section";
import {deepDiff} from "Frontend/util/utils";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import CollectionUpdateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionUpdateDto";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
import CollectionHeader from "Frontend/components/general/covers/CollectionHeader";
import CollectionGamesTable from "Frontend/components/general/modals/CollectionGamesTable";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
export default function CollectionManagementView() {
const {collectionId} = useParams();
const navigate = useNavigate();
const [collectionSaved, setCollectionSaved] = React.useState(false);
const collections = useSnapshot(collectionState);
// Parse and validate collectionId early
const collectionIdNum = collectionId ? parseInt(collectionId) : null;
// Early return if invalid collection ID
useEffect(() => {
if (!collectionIdNum || (collections.isLoaded && !collections.state[collectionIdNum])) {
navigate("/administration/games");
}
}, [collections, collectionIdNum, navigate]);
// If collectionId is invalid, return null (will redirect via useEffect)
if (!collectionIdNum) {
return null;
}
// At this point, collectionIdNum is guaranteed to be a number
const collection = collections.state[collectionIdNum] as CollectionAdminDto;
async function handleSubmit(values: CollectionUpdateDto): Promise<void> {
const changed = deepDiff(collection, values) as CollectionUpdateDto;
if (Object.keys(changed).length === 0) return;
changed.id = collection.id;
await CollectionEndpoint.updateCollection(changed);
setCollectionSaved(true);
setTimeout(() => setCollectionSaved(false), 2000);
}
async function deleteCollection(): Promise<void> {
try {
await CollectionEndpoint.deleteCollection(collection.id);
addToast({
title: "Collection deleted",
description: `Collection ${collection.name} deleted!`,
color: "success"
});
navigate("/administration/games");
} catch (e) {
addToast({
title: "Error deleting collection",
description: `Collection ${collection.name} could not be deleted!`,
color: "warning"
});
}
}
return collection && (
<div className="flex flex-col gap-4">
<div className="flex flex-row gap-4 items-center">
<Button isIconOnly variant="light" onPress={() => history.back()}>
<ArrowLeftIcon/>
</Button>
<h1 className="text-2xl font-bold">Manage Collection</h1>
</div>
<CollectionHeader collection={collection} className="h-32"/>
<Formik
initialValues={collection}
onSubmit={handleSubmit}
enableReinitialize={true}
validationSchema={Yup.object({
name: Yup.string()
.required("Collection name is required")
.max(255, "Collection name must be 255 characters or less")
})}
>
{(formik) => (
<Form>
<div className="flex flex-row grow justify-between mb-4">
<h1 className="text-2xl font-bold">Edit collection details</h1>
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || collectionSaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : collectionSaved ? <CheckIcon/> : "Save"}
</Button>
</div>
<Input label="Collection name" name="name"/>
<TextAreaInput label="Collection description" name="description"/>
<CheckboxInput label="Display on homepage" name="metadata.displayOnHomepage" className="mb-4"/>
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage games in collection</h1>
<CollectionGamesTable collectionId={collectionIdNum}/>
</div>
<Section title="Danger zone"/>
<Button color="danger" onPress={deleteCollection}>
Delete collection
</Button>
</Form>
)}
</Formik>
</div>
);
}
@@ -0,0 +1,32 @@
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import React, {useEffect} from "react";
import {useNavigate, useParams} from "react-router";
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import {collectionState} from "Frontend/state/CollectionState";
export default function CollectionView() {
const {collectionId} = useParams();
const navigate = useNavigate();
const collections = useSnapshot(collectionState);
const games = collectionId ? useSnapshot(gameState).gamesByCollectionId[parseInt(collectionId!)] || [] : [];
useEffect(() => {
window.scrollTo(0, 0)
}, [])
useEffect(() => {
if (collections.isLoaded && (!collectionId || !collections.state[parseInt(collectionId)])) {
navigate("/", {replace: true});
}
document.title = collections.state[parseInt(collectionId!)]?.name || "Gameyfin";
}, [collectionId, collections]);
return (
<div className="flex flex-col gap-6">
<p className="text-4xl font-bold text-center">{collections.state[parseInt(collectionId!)]?.name}</p>
<CoverGrid games={games}/>
{games.length === 0 && <p className="text-center text-gray-500">This collection is empty.</p>}
</div>
);
}
+42 -19
View File
@@ -24,8 +24,9 @@ import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMe
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import {GameAdminDto} from "Frontend/dtos/GameDtos";
import ChipList from "Frontend/components/general/ChipList";
import {collectionState} from "Frontend/state/CollectionState";
import {GameMetadataAdminDto} from "Frontend/dtos/GameDtos";
export default function GameView() {
const {gameId} = useParams();
@@ -37,7 +38,8 @@ export default function GameView() {
const matchGameModal = useDisclosure();
const state = useSnapshot(gameState);
const game = gameId ? state.state[parseInt(gameId)] as GameAdminDto : undefined;
const game = gameId ? state.state[parseInt(gameId)] : undefined;
const collections = useSnapshot(collectionState).state;
const [downloadOptions, setDownloadOptions] = useState<Record<string, ComboButtonOption>>();
@@ -69,7 +71,7 @@ export default function GameView() {
await GameEndpoint.updateGame(
{
id: game.id,
metadata: {matchConfirmed: !game.metadata.matchConfirmed}
metadata: {matchConfirmed: !(game.metadata as GameMetadataAdminDto).matchConfirmed}
} as GameUpdateDto
)
}
@@ -87,17 +89,17 @@ export default function GameView() {
return game && (
<div className="flex flex-col gap-4">
<div className="overflow-hidden relative rounded-t-lg">
{game.headerId ? (
{game.header?.id ? (
<img
className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
alt="Game header"
src={`/images/header/${game.headerId}`}
src={`/images/header/${game.header?.id}`}
/>
) : game.imageIds && game.imageIds.length > 0 ? (
) : game.images && game.images.length > 0 ? (
<img
className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
alt="Game screenshot"
src={`/images/screenshot/${game.imageIds[0]}`}
src={`/images/screenshot/${game.images[0].id}`}
/>
) : (
<div className="w-full h-96 bg-secondary relative"/>
@@ -137,7 +139,7 @@ export default function GameView() {
<div className="flex flex-row items-center gap-8">
{isAdmin(auth) && <div className="flex flex-row gap-2">
<Button isIconOnly onPress={toggleMatchConfirmed}>
{game.metadata.matchConfirmed ?
{(game.metadata as GameMetadataAdminDto).matchConfirmed ?
<Tooltip content="Unconfirm match">
<CheckCircleIcon weight="fill" className="fill-success"/>
</Tooltip> :
@@ -220,7 +222,7 @@ export default function GameView() {
color="foreground" underline="hover">
{dev}
</Link>
{index !== game.developers!!.length - 1 && <p>/</p>}
{index !== game.developers!.length - 1 && <p>/</p>}
</>
)
: <Tooltip content="Missing data" color="foreground" placement="right">
@@ -295,6 +297,25 @@ export default function GameView() {
}
</td>
</tr>
{game.collectionIds.length > 0 &&
<tr>
<td className="text-default-500 w-0 min-w-32">Collections</td>
<td className="flex flex-row gap-1">
{[...game.collectionIds]
.map((collectionId) => collections[collectionId])
.sort((a, b) => a.id - b.id)
.map((collection, index) =>
<>
<Link key={collection.id} href={`/collection/${collection.id}`}
color="foreground" underline="hover">
{collection.name}
</Link>
{index !== game.collectionIds!.length - 1 && <p>/</p>}
</>
)}
</td>
</tr>
}
</tbody>
</table>
</div>
@@ -302,22 +323,24 @@ export default function GameView() {
<div className="flex flex-col gap-4">
<p className="text-default-500">Media</p>
<ImageCarousel
imageUrls={game.imageIds?.map(id => `/images/screenshot/${id}`)}
imageUrls={game.images?.map(image => `/images/screenshot/${image.id}`)}
videosUrls={game.videoUrls}
className="-mx-24"
/>
</div>
</div>
</div>
<EditGameMetadataModal game={game}
isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal path={game.metadata.path!!}
libraryId={game.libraryId}
replaceGameId={game.id}
initialSearchTerm={game.title}
isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
{isAdmin(auth) && <>
<EditGameMetadataModal game={game}
isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal path={(game.metadata as GameMetadataAdminDto).path!}
libraryId={game.libraryId}
replaceGameId={game.id}
initialSearchTerm={game.title}
isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
</>}
</div>
);
}
+62 -10
View File
@@ -1,26 +1,78 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {CoverRow} from "Frontend/components/general/covers/CoverRow";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
import {gameState} from "Frontend/state/GameState";
import {useNavigate} from "react-router";
import React, {useEffect, useState} from "react";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {collectionState} from "Frontend/state/CollectionState";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import {StartPageDisplayCard} from "Frontend/components/general/cards/StartPageDisplayCard";
import {Link} from "@heroui/react";
import {CaretRightIcon} from "@phosphor-icons/react";
export default function HomeView() {
const navigate = useNavigate();
const librariesState = useSnapshot(libraryState);
const collectionsState = useSnapshot(collectionState);
const gamesState = useSnapshot(gameState);
const recentlyAddedGames = gamesState.recentlyAdded as GameDto[];
const gamesByLibrary = gamesState.gamesByLibraryId as Record<number, GameDto[]>;
const gamesByLibrary = gamesState.gamesByLibraryId;
const gamesByCollection = gamesState.gamesByCollectionId;
const [filteredAndSortedLibraries, setFilteredAndSortedLibraries] = useState<LibraryDto[]>([]);
const [filteredAndSortedCollections, setFilteredAndSortedCollections] = useState<CollectionDto[]>([]);
useEffect(() => {
const libraries = librariesState.sorted
.filter(library => library.metadata!.displayOnHomepage)
.filter(library =>
gamesByLibrary[library.id] && gamesByLibrary[library.id].length > 0
);
setFilteredAndSortedLibraries(libraries);
const collections = collectionsState.sorted
.filter(collection => collection.metadata!.displayOnHomepage)
.filter(collection =>
gamesByCollection[collection.id] && gamesByCollection[collection.id].length > 0
);
setFilteredAndSortedCollections(collections);
}, [librariesState.sorted, collectionsState.sorted, gamesByLibrary, gamesByCollection]);
return (
<div className="w-full">
<div className="flex flex-col gap-2">
<CoverRow title="Recently added" games={recentlyAddedGames}
onPressShowMore={() => navigate("/recently-added")}/>
{librariesState.libraries.map((library) => (
<div className="flex flex-col gap-4">
{(filteredAndSortedLibraries.length + filteredAndSortedCollections.length > 0) &&
<div className="flex flex-col gap-2">
<Link href="/search" className="flex flex-row gap-1 w-fit items-baseline" color="foreground"
underline="hover">
<p className="text-2xl font-bold mb-4">Your games</p>
<CaretRightIcon weight="bold" size={16}/>
</Link>
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{filteredAndSortedLibraries.length > 0 &&
filteredAndSortedLibraries.map((library: LibraryDto) => (
<StartPageDisplayCard key={library.id} item={library}/>
))
}
{filteredAndSortedCollections.length > 0 &&
filteredAndSortedCollections.map((collection: CollectionDto) => (
<StartPageDisplayCard key={collection.id} item={collection}/>
))
}
</div>
</div>
}
{filteredAndSortedLibraries.map((library) => (
<CoverRow key={library.id} title={library.name}
games={gamesByLibrary[library.id] || []}
onPressShowMore={() => navigate("/library/" + library.id)}
link={"/library/" + library.id}
/>
))}
{filteredAndSortedCollections.map((collection) => (
<CoverRow key={collection.id} title={collection.name}
games={gamesByCollection[collection.id] || []}
link={"/collection/" + collection.id}
/>
))}
</div>
@@ -19,18 +19,18 @@ export default function LibraryManagementView() {
useEffect(() => {
if (state.isLoaded && (!libraryId || !state.state[parseInt(libraryId)])) {
navigate("/administration/libraries");
navigate("/administration/games");
}
}, [state, libraryId]);
return libraryId && state.state[parseInt(libraryId)] && <div className="flex flex-col gap-4">
<div className="flex flex-row gap-4 items-center">
<Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}>
<Button isIconOnly variant="light" onPress={() => history.back()}>
<ArrowLeftIcon/>
</Button>
<h1 className="text-2xl font-bold">Manage library</h1>
</div>
<LibraryHeader library={state.state[parseInt(libraryId)] as LibraryAdminDto} className="h-32"/>
<LibraryHeader library={state.state[parseInt(libraryId)]} className="h-32"/>
<Tabs color="primary" fullWidth
selectedKey={hash.length > 0 ? hash : "#details"}
onSelectionChange={(newKey) => navigate(newKey.toString(), {replace: true})}>
+8 -4
View File
@@ -4,25 +4,29 @@ import {gameState} from "Frontend/state/GameState";
import React, {useEffect} from "react";
import {useNavigate, useParams} from "react-router";
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
export default function LibraryView() {
const {libraryId} = useParams();
const navigate = useNavigate();
const libraries = useSnapshot(libraryState);
const games = (libraryId ? useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!!)] || [] : []) as GameDto[];
const games = useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!)] || [];
useEffect(() => {
window.scrollTo(0, 0)
}, [])
useEffect(() => {
if (libraries.isLoaded && (!libraryId || !libraries.state[parseInt(libraryId)])) {
navigate("/", {replace: true});
}
document.title = libraries.state[parseInt(libraryId!!)]?.name || "Gameyfin";
document.title = libraries.state[parseInt(libraryId!)]?.name || "Gameyfin";
}, [libraryId, libraries]);
return (
<div className="flex flex-col gap-6">
<p className="text-4xl font-bold text-center">{libraries.state[parseInt(libraryId!!)]?.name}</p>
<p className="text-4xl font-bold text-center">{libraries.state[parseInt(libraryId!)]?.name}</p>
<CoverGrid games={games}/>
{games.length === 0 && <p className="text-center text-gray-500">This library is empty.</p>}
</div>
);
}
@@ -1,16 +0,0 @@
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React from "react";
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
export default function RecentlyAddedView() {
const games = useSnapshot(gameState).recentlyAdded as GameDto[];
return (
<div className="flex flex-col gap-4">
<p className="text-4xl font-bold text-center">Recently added</p>
<CoverGrid games={games}/>
</div>
);
}
+6 -4
View File
@@ -13,19 +13,18 @@ import {useSearchParams} from "react-router";
import React, {useEffect, useMemo, useState} from "react";
import {Fzf} from "fzf";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import {compoundRating} from "Frontend/util/utils";
export default function SearchView() {
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
const knownDevelopers = useSnapshot(gameState).knownDevelopers as Set<string>;
const games = useSnapshot(gameState).sortedAlphabetically;
const knownDevelopers = useSnapshot(gameState).knownDevelopers;
const knownGenres = useSnapshot(gameState).knownGenres;
const knownThemes = useSnapshot(gameState).knownThemes;
const knownFeatures = useSnapshot(gameState).knownFeatures;
const knownPerspectives = useSnapshot(gameState).knownPerspectives;
const knownKeywords = useSnapshot(gameState).knownKeywords;
const libraries = useSnapshot(libraryState).libraries as LibraryDto[];
const libraries = useSnapshot(libraryState).libraries;
const [searchParams, setSearchParams] = useSearchParams();
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
@@ -46,6 +45,9 @@ export default function SearchView() {
// Load initial filter values from URL parameters on component mount
useEffect(() => {
// Scroll to top on load
window.scrollTo(0, 0)
// Get all parameters from the URL
const term = searchParams.get("term") || "";
const libs = searchParams.getAll("lib");
@@ -0,0 +1,62 @@
package org.gameyfin.app.collections
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.collections.dto.*
import org.gameyfin.app.collections.extensions.toAdminDto
import org.gameyfin.app.collections.extensions.toDto
import org.gameyfin.app.collections.extensions.toUserDto
import org.gameyfin.app.core.Role
import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.core.security.isCurrentUserAdmin
import reactor.core.publisher.Flux
@Endpoint
@DynamicPublicAccess
@AnonymousAllowed
class CollectionEndpoint(
private val collectionService: CollectionService
) {
fun subscribeToCollectionEvents(): Flux<out List<CollectionEvent>> {
return if (isCurrentUserAdmin()) {
CollectionService.subscribeAdmin()
} else {
CollectionService.subscribeUser()
}
}
fun getAll(): List<CollectionDto> = collectionService.getAll()
fun getById(id: Long): CollectionDto = collectionService.getById(id).toDto()
@RolesAllowed(Role.Names.ADMIN)
fun createCollection(dto: CollectionCreateDto) = collectionService.create(dto)
@RolesAllowed(Role.Names.ADMIN)
fun updateCollection(dto: CollectionUpdateDto) = collectionService.update(dto)
@RolesAllowed(Role.Names.ADMIN)
fun updateCollections(collections: List<CollectionUpdateDto>) = collectionService.update(collections)
@RolesAllowed(Role.Names.ADMIN)
fun addGameToCollection(collectionId: Long, gameId: Long) =
collectionService.addGame(collectionId, gameId)
@RolesAllowed(Role.Names.ADMIN)
fun removeGameFromCollection(collectionId: Long, gameId: Long) =
collectionService.removeGame(collectionId, gameId)
@RolesAllowed(Role.Names.ADMIN)
fun deleteCollection(collectionId: Long) = collectionService.delete(collectionId)
/* Unused endpoints for Hilla to generate typescript classes */
@Suppress("Unused", "FunctionName")
@RolesAllowed(Role.Names.ADMIN)
fun _getAdminDto(id: Long): CollectionAdminDto = collectionService.getById(id).toAdminDto()
@Suppress("Unused", "FunctionName")
@RolesAllowed(Role.Names.ADMIN)
fun _getUserDto(id: Long): CollectionUserDto = collectionService.getById(id).toUserDto()
}
@@ -0,0 +1,158 @@
package org.gameyfin.app.collections
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.collections.dto.*
import org.gameyfin.app.collections.entities.Collection
import org.gameyfin.app.collections.entities.CollectionMetadata
import org.gameyfin.app.collections.extensions.toDto
import org.gameyfin.app.collections.extensions.toEntity
import org.gameyfin.app.collections.repositories.CollectionRepository
import org.gameyfin.app.games.GameService
import org.gameyfin.app.games.entities.Game
import org.springframework.data.repository.findByIdOrNull
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 CollectionService(
private val collectionRepository: CollectionRepository,
private val gameService: GameService
) {
companion object {
private val log = KotlinLogging.logger {}
private val collectionUserEvents =
Sinks.many().multicast().onBackpressureBuffer<CollectionUserEvent>(1024, false)
private val collectionAdminEvents =
Sinks.many().multicast().onBackpressureBuffer<CollectionAdminEvent>(1024, false)
fun subscribeUser(): Flux<List<CollectionUserEvent>> {
log.debug { "New user subscription for collectionUserEvents" }
return collectionUserEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe {
log.debug { "Subscriber added to user collectionUserEvents [${collectionUserEvents.currentSubscriberCount()}]" }
}
.doFinally {
log.debug { "Subscriber removed from user collectionUserEvents with signal type $it [${collectionUserEvents.currentSubscriberCount()}]" }
}
}
fun subscribeAdmin(): Flux<List<CollectionAdminEvent>> {
log.debug { "New admin subscription for collectionAdminEvents" }
return collectionAdminEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe {
log.debug { "Subscriber added to admin collectionAdminEvents [${collectionAdminEvents.currentSubscriberCount()}]" }
}
.doFinally {
log.debug { "Subscriber removed from admin collectionAdminEvents with signal type $it [${collectionAdminEvents.currentSubscriberCount()}]" }
}
}
fun emitUser(event: CollectionUserEvent) {
collectionUserEvents.tryEmitNext(event)
}
fun emitAdmin(event: CollectionAdminEvent) {
collectionAdminEvents.tryEmitNext(event)
}
}
fun getAll(): List<CollectionDto> = collectionRepository.findAll().map { it.toDto() }
fun getById(id: Long): Collection = collectionRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("Collection with id $id not found")
@Transactional
fun create(dto: CollectionCreateDto) {
if (collectionRepository.findByName(dto.name) != null) {
throw IllegalArgumentException("Collection with name '${dto.name}' already exists")
}
val entity = dto.toEntity()
dto.gameIds?.let { ids ->
ids.distinct().forEach { gameId ->
val game = gameService.getById(gameId)
entity.addGame(game)
}
}
collectionRepository.save(entity)
}
@Transactional
fun update(dto: CollectionUpdateDto): CollectionDto {
val collection = getById(dto.id)
dto.name?.let { newName ->
if (newName != collection.name && collectionRepository.findByName(newName) != null) {
throw IllegalArgumentException("Collection with name '$newName' already exists")
}
collection.name = newName
}
dto.description?.let { collection.description = it }
dto.gameIds?.let { ids ->
// Replace entire set of games
val newGames: MutableList<Game> = mutableListOf()
ids.distinct().forEach { gameId ->
val game = gameService.getById(gameId)
newGames.add(game)
}
// Remove old backrefs
collection.games.forEach { it.collections.remove(collection) }
collection.games.clear()
newGames.forEach { collection.addGame(it) }
}
dto.metadata?.let {
collection.metadata = CollectionMetadata(
it.displayOnHomepage ?: collection.metadata.displayOnHomepage,
it.displayOrder ?: collection.metadata.displayOrder,
collection.metadata.gamesAddedAt
)
}
val saved = collectionRepository.save(collection)
return saved.toDto()
}
/**
* Updates multiple collections in the repository.
*/
@Transactional
fun update(collections: List<CollectionUpdateDto>) {
collections.forEach { update(it) }
}
@Transactional
fun addGame(collectionId: Long, gameId: Long): CollectionDto {
val collection = getById(collectionId)
val game = gameService.getById(gameId)
collection.addGame(game)
gameService.update(game)
val saved = collectionRepository.save(collection)
return saved.toDto()
}
@Transactional
fun removeGame(collectionId: Long, gameId: Long): CollectionDto {
val collection = getById(collectionId)
val game = gameService.getById(gameId)
collection.removeGame(game)
gameService.update(game)
val saved = collectionRepository.save(collection)
return saved.toDto()
}
fun delete(collectionId: Long) {
collectionRepository.deleteById(collectionId)
}
}
@@ -0,0 +1,62 @@
package org.gameyfin.app.collections.dto
import com.fasterxml.jackson.annotation.JsonInclude
import org.gameyfin.pluginapi.gamemetadata.Platform
import java.time.Instant
interface CollectionDto {
val id: Long
val createdAt: Instant
val updatedAt: Instant
val name: String
val description: String?
val gameIds: List<Long>?
val metadata: CollectionMetadataDto?
}
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CollectionUserDto(
override val id: Long,
override val createdAt: Instant,
override val updatedAt: Instant,
override val name: String,
override val description: String?,
override val gameIds: List<Long> = emptyList(),
override val metadata: CollectionMetadataDto?,
) : CollectionDto
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CollectionAdminDto(
override val id: Long,
override val createdAt: Instant,
override val updatedAt: Instant,
override val name: String,
override val description: String?,
override val gameIds: List<Long> = emptyList(),
override val metadata: CollectionMetadataDto?,
val stats: CollectionStatsDto?,
) : CollectionDto
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CollectionStatsDto(
val gamesCount: Int,
val downloadCount: Int,
val gamePlatforms: Set<Platform>
)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CollectionCreateDto(
val name: String,
val description: String? = null,
val gameIds: List<Long>? = null
)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CollectionUpdateDto(
val id: Long,
val name: String? = null,
val description: String? = null,
val gameIds: List<Long>? = null,
val metadata: CollectionMetadataUpdateDto? = null
)
@@ -0,0 +1,22 @@
package org.gameyfin.app.collections.dto
sealed interface CollectionEvent {
val type: String
}
sealed class CollectionUserEvent : CollectionEvent {
data class Created(val collection: CollectionUserDto, override val type: String = "created") : CollectionUserEvent()
data class Updated(val collection: CollectionUserDto, override val type: String = "updated") : CollectionUserEvent()
data class Deleted(val collectionId: Long, override val type: String = "deleted") : CollectionUserEvent()
}
sealed class CollectionAdminEvent : CollectionEvent {
data class Created(val collection: CollectionAdminDto, override val type: String = "created") :
CollectionAdminEvent()
data class Updated(val collection: CollectionAdminDto, override val type: String = "updated") :
CollectionAdminEvent()
data class Deleted(val collectionId: Long, override val type: String = "deleted") : CollectionAdminEvent()
}
@@ -0,0 +1,14 @@
package org.gameyfin.app.collections.dto
import java.time.Instant
data class CollectionMetadataDto(
val displayOnHomepage: Boolean,
val displayOrder: Int,
val gamesAddedAt: Map<Long, Instant>
)
data class CollectionMetadataUpdateDto(
val displayOnHomepage: Boolean?,
val displayOrder: Int?
)
@@ -0,0 +1,59 @@
package org.gameyfin.app.collections.entities
import jakarta.persistence.*
import org.gameyfin.app.games.entities.Game
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp
import java.time.Instant
@Entity
@EntityListeners(CollectionEntityListener::class)
class Collection(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@CreationTimestamp
@Column(nullable = false, updatable = false)
var createdAt: Instant? = null,
@UpdateTimestamp
@Column(nullable = false)
var updatedAt: Instant? = null,
@Column(nullable = false, unique = true)
var name: String,
@Lob
var description: String? = null,
@ManyToMany(fetch = FetchType.EAGER)
var games: MutableSet<Game> = mutableSetOf(),
@Embedded
var metadata: CollectionMetadata = CollectionMetadata()
) {
fun addGame(game: Game) {
games.add(game)
if (!game.collections.contains(this)) {
game.collections.add(this)
}
// Track when the game was added
game.id?.let { gameId ->
metadata.gamesAddedAt[gameId] = Instant.now()
}
// Force update to trigger @PostUpdate callback
updatedAt = Instant.now()
}
fun removeGame(game: Game) {
games.remove(game)
game.collections.remove(this)
// Remove the timestamp tracking for this game
game.id?.let { gameId ->
metadata.gamesAddedAt.remove(gameId)
}
// Force update to trigger @PostUpdate callback
updatedAt = Instant.now()
}
}
@@ -0,0 +1,37 @@
package org.gameyfin.app.collections.entities
import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate
import org.gameyfin.app.collections.CollectionService
import org.gameyfin.app.collections.dto.CollectionAdminEvent
import org.gameyfin.app.collections.dto.CollectionUserEvent
import org.gameyfin.app.collections.extensions.toAdminDto
import org.gameyfin.app.collections.extensions.toUserDto
import org.gameyfin.app.core.events.CollectionCreatedEvent
import org.gameyfin.app.core.events.CollectionDeletedEvent
import org.gameyfin.app.core.events.CollectionUpdatedEvent
import org.gameyfin.app.util.EventPublisherHolder
class CollectionEntityListener {
@PostPersist
fun created(collection: Collection) {
CollectionService.emitUser(CollectionUserEvent.Created(collection.toUserDto()))
CollectionService.emitAdmin(CollectionAdminEvent.Created(collection.toAdminDto()))
EventPublisherHolder.publish(CollectionCreatedEvent(this, collection))
}
@PostUpdate
fun updated(collection: Collection) {
CollectionService.emitUser(CollectionUserEvent.Updated(collection.toUserDto()))
CollectionService.emitAdmin(CollectionAdminEvent.Updated(collection.toAdminDto()))
EventPublisherHolder.publish(CollectionUpdatedEvent(this, collection))
}
@PostRemove
fun deleted(collection: Collection) {
CollectionService.emitUser(CollectionUserEvent.Deleted(collection.id!!))
CollectionService.emitAdmin(CollectionAdminEvent.Deleted(collection.id!!))
EventPublisherHolder.publish(CollectionDeletedEvent(this, collection))
}
}
@@ -0,0 +1,15 @@
package org.gameyfin.app.collections.entities
import jakarta.persistence.ElementCollection
import jakarta.persistence.Embeddable
import jakarta.persistence.FetchType
import java.time.Instant
@Embeddable
class CollectionMetadata(
val displayOnHomepage: Boolean = true,
val displayOrder: Int = -1,
@ElementCollection(fetch = FetchType.EAGER)
val gamesAddedAt: MutableMap<Long, Instant> = mutableMapOf()
)
@@ -0,0 +1,54 @@
package org.gameyfin.app.collections.extensions
import org.gameyfin.app.collections.dto.*
import org.gameyfin.app.collections.entities.Collection
import org.gameyfin.app.collections.entities.CollectionMetadata
import org.gameyfin.app.core.security.isCurrentUserAdmin
fun Collection.toDto(): CollectionDto = if (isCurrentUserAdmin()) this.toAdminDto() else this.toUserDto()
fun Collection.toAdminDto(): CollectionAdminDto = CollectionAdminDto(
id = id!!,
createdAt = createdAt!!,
updatedAt = updatedAt!!,
name = name,
description = description,
gameIds = games.mapNotNull { it.id },
metadata = this.metadata.toDto(),
stats = CollectionStatsDto(
gamesCount = games.size,
downloadCount = games.sumOf { it.metadata.downloadCount },
gamePlatforms = games.flatMap { it.platforms }.toSet()
)
)
fun Collection.toUserDto(): CollectionUserDto = CollectionUserDto(
id = id!!,
createdAt = createdAt!!,
updatedAt = updatedAt!!,
name = name,
description = description,
gameIds = games.mapNotNull { it.id },
metadata = this.metadata.toDto()
)
fun CollectionCreateDto.toEntity(): Collection = Collection(
name = name,
description = description
)
fun CollectionMetadata.toDto(): CollectionMetadataDto {
return CollectionMetadataDto(
displayOnHomepage = this.displayOnHomepage,
displayOrder = this.displayOrder,
gamesAddedAt = this.gamesAddedAt.toMap()
)
}
fun CollectionMetadataDto.toEntity(): CollectionMetadata {
return CollectionMetadata(
displayOnHomepage = this.displayOnHomepage,
displayOrder = this.displayOrder,
gamesAddedAt = this.gamesAddedAt.toMutableMap()
)
}
@@ -0,0 +1,9 @@
package org.gameyfin.app.collections.repositories
import org.gameyfin.app.collections.entities.Collection
import org.springframework.data.jpa.repository.JpaRepository
interface CollectionRepository : JpaRepository<Collection, Long> {
fun findByName(name: String): Collection?
}
@@ -15,20 +15,23 @@ sealed class ConfigProperties<T : Serializable>(
val step: Number? = null
) {
/** Libraries */
sealed class Libraries {
/** Security */
sealed class Security {
data object AllowPublicAccess : ConfigProperties<Boolean>(
Boolean::class,
"library.allow-public-access",
"security.allow-public-access",
"Allow access to Gameyfin without login",
false
)
}
/** Libraries */
sealed class Libraries {
sealed class Scan {
data object EnableFilesystemWatcher : ConfigProperties<Boolean>(
Boolean::class,
"library.scan.enable-filesystem-watcher",
"Enable automatic library scanning using file system watchers (coming soon™)",
"Enable automatic library scanning using file system watchers",
false
)
@@ -189,13 +192,6 @@ sealed class ConfigProperties<T : Serializable>(
MatchUsersBy.entries
)
data object AutoRegisterNewUsers : ConfigProperties<Boolean>(
Boolean::class,
"sso.oidc.auto-register-new-users",
"Automatically create new users after registration",
true
)
data object RolesClaim : ConfigProperties<String>(
String::class,
"sso.oidc.roles-claim",
@@ -1,5 +1,7 @@
package org.gameyfin.app.config
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.config.dto.ConfigEntryDto
import org.gameyfin.app.config.dto.ConfigUpdateDto
@@ -15,7 +17,8 @@ import kotlin.time.toJavaDuration
@Service
class ConfigService(
private val appConfigRepository: ConfigRepository
private val appConfigRepository: ConfigRepository,
private val objectMapper: ObjectMapper
) {
companion object {
private val log = KotlinLogging.logger {}
@@ -50,7 +53,7 @@ class ConfigService(
val appConfig = appConfigRepository.findByIdOrNull(configProperty.key)
return if (appConfig != null) {
getValue(appConfig.value, configProperty)
deserializeValue(appConfig.value, configProperty)
} else {
configProperty.default ?: return null
}
@@ -101,6 +104,18 @@ class ConfigService(
}
}
/**
* Set the value for a specified key in a type-safe way.
*
* @param configProperty: The target config property
* @param value: Value to set the config property to
* @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
*/
fun <T : Serializable> set(configProperty: ConfigProperties<T>, value: T) {
return set(configProperty.key, value)
}
/**
* Set the value for a specified key.
* Checks if the value can be cast to the type defined for the config property.
@@ -117,16 +132,12 @@ class ConfigService(
var configEntry = appConfigRepository.findByIdOrNull(key)
val parsedValue =
if (value.javaClass.isArray) {
(value as Array<Serializable>).joinToString(",")
} else
value.toString()
val serializedValue = serializeValue(value, key)
if (configEntry == null) {
configEntry = ConfigEntry(configProperty.key, parsedValue)
configEntry = ConfigEntry(configProperty.key, serializedValue)
} else {
configEntry.value = parsedValue
configEntry.value = serializedValue
}
appConfigRepository.save(configEntry)
@@ -149,17 +160,6 @@ class ConfigService(
emit(update)
}
/**
* Set the value for a specified key in a type-safe way.
*
* @param configProperty: The target config property
* @param value: Value to set the config property to
* @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
*/
fun <T : Serializable> set(configProperty: ConfigProperties<T>, value: T) {
return set(configProperty.key, value)
}
/**
* Remove a config property from the database.
* This will also cause it to reset to its default value.
@@ -175,41 +175,45 @@ class ConfigService(
}
/**
* Get the value of the config property in a type-safe way.
* Deserialize a value from the database to its proper type.
*
* @param value: The serialized value from the database
* @param configProperty: The config property containing type information
* @return The deserialized value
*/
@Suppress("UNCHECKED_CAST")
private fun <T : Serializable> getValue(value: Serializable, configProperty: ConfigProperties<T>): T {
val value = value.toString()
return when {
configProperty.type == String::class -> value as T
configProperty.type == Boolean::class -> value.toBoolean() as T
configProperty.type == Int::class -> value.toFloat().toInt() as T
configProperty.type == Float::class -> value.toFloat() as T
private fun <T : Serializable> deserializeValue(value: Serializable, configProperty: ConfigProperties<T>): T {
return try {
val typeReference = objectMapper.typeFactory.constructType(configProperty.type.java)
objectMapper.readValue(value.toString(), typeReference) as T
} catch (e: JsonProcessingException) {
throw IllegalArgumentException(
"Failed to deserialize value '$value' for key '${configProperty.key}' to type ${configProperty.type.simpleName}: ${e.message}",
e
)
} catch (e: Exception) {
throw IllegalArgumentException(
"Failed to deserialize value '$value' for key '${configProperty.key}' to type ${configProperty.type.simpleName}: ${e.message}",
e
)
}
}
configProperty.type.java.isEnum -> {
val enumConstants = configProperty.type.java.enumConstants
enumConstants.firstOrNull { it.toString() == value }
?: throw IllegalArgumentException("Unknown enum value '$value' for key ${configProperty.key}")
}
configProperty.type.java.isArray -> {
val componentType = configProperty.type.java.componentType
// Remove the brackets and split the string by commas
val elements = value
.removeSurrounding("[", "]")
.split(",")
.filter { it.isNotBlank() }
when (componentType) {
String::class.java -> elements.toTypedArray() as T
Boolean::class.java -> elements.map { it.toBoolean() }.toTypedArray() as T
Int::class.java -> elements.map { it.toInt() }.toTypedArray() as T
Float::class.java -> elements.map { it.toFloat() }.toTypedArray() as T
else -> throw IllegalArgumentException("Unsupported array type: ${componentType.name}")
}
}
else -> throw IllegalArgumentException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}")
/**
* Serialize a value to be stored in the database.
*
* @param value: The value to serialize
* @param key: The config key (for error messages)
* @return The serialized value as a string
*/
private fun <T : Serializable> serializeValue(value: T, key: String): String {
return try {
objectMapper.writeValueAsString(value)
} catch (e: JsonProcessingException) {
throw IllegalArgumentException(
"Failed to serialize value for key '$key': ${e.message}",
e
)
}
}
@@ -4,6 +4,7 @@ import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate
import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.core.events.LibraryFilesystemWatcherConfigUpdatedEvent
import org.gameyfin.app.core.events.LibraryScanScheduleUpdatedEvent
import org.gameyfin.app.util.EventPublisherHolder
@@ -19,7 +20,12 @@ class ConfigEntryEntityListener {
}
ConfigProperties.Libraries.Scan.EnableFilesystemWatcher.key -> {
TODO()
EventPublisherHolder.publish(
LibraryFilesystemWatcherConfigUpdatedEvent(
this,
configEntry.value.toBoolean()
)
)
}
}
}
@@ -27,7 +27,7 @@ class DynamicAccessInterceptor(
clazz.isAnnotationPresent(DynamicPublicAccess::class.java)
if (hasDynamicPublicAccess) {
if (request.userPrincipal != null || config.get(ConfigProperties.Libraries.AllowPublicAccess) == true) {
if (request.userPrincipal != null || config.get(ConfigProperties.Security.AllowPublicAccess) == true) {
return true
}
response.status = HttpServletResponse.SC_UNAUTHORIZED
@@ -1,5 +1,6 @@
package org.gameyfin.app.core.events
import org.gameyfin.app.collections.entities.Collection
import org.gameyfin.app.core.token.Token
import org.gameyfin.app.core.token.TokenType
import org.gameyfin.app.games.entities.Game
@@ -24,6 +25,7 @@ class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.Password
ApplicationEvent(source)
class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source)
class LibraryFilesystemWatcherConfigUpdatedEvent(source: Any, val isEnabled: Boolean) : 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)
@@ -34,4 +36,8 @@ class GameDeletedEvent(source: Any, val game: Game) : ApplicationEvent(source)
class LibraryCreatedEvent(source: Any, val library: Library) : ApplicationEvent(source)
class LibraryUpdatedEvent(source: Any, val currentState: Library) : ApplicationEvent(source)
class LibraryDeletedEvent(source: Any, val library: Library) : ApplicationEvent(source)
class LibraryDeletedEvent(source: Any, val library: Library) : ApplicationEvent(source)
class CollectionCreatedEvent(source: Any, val collection: Collection) : ApplicationEvent(source)
class CollectionUpdatedEvent(source: Any, val currentState: Collection) : ApplicationEvent(source)
class CollectionDeletedEvent(source: Any, val collection: Collection) : ApplicationEvent(source)
@@ -0,0 +1,41 @@
package org.gameyfin.app.core.exceptions
import com.vaadin.hilla.exception.EndpointException
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.springframework.stereotype.Component
/**
* Aspect that intercepts all Vaadin Hilla endpoint method calls.
* Catches all exceptions thrown from endpoint methods, logs them with full stack trace,
* and re-throws them as EndpointException to be displayed nicely in the frontend.
*/
@Aspect
@Component
class EndpointExceptionHandler {
companion object {
private val log = KotlinLogging.logger {}
}
@Around("@within(com.vaadin.hilla.Endpoint)")
@Throws(Throwable::class)
fun handleEndpointException(joinPoint: ProceedingJoinPoint): Any? {
return try {
joinPoint.proceed()
} catch (ex: EndpointException) {
// If it's already an EndpointException, just log and re-throw
log.error(ex) { "Endpoint exception: ${ex.message}" }
throw ex
} catch (ex: Exception) {
// Log the original exception with full stack trace
log.error(ex) { "Exception in endpoint method ${joinPoint.signature.declaringType.simpleName}.${joinPoint.signature.name}: ${ex.message}" }
// Re-throw as EndpointException with the original message but no stack trace
throw EndpointException(ex.message ?: "An error occurred")
}
}
}
@@ -3,7 +3,7 @@ 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.media.Image
import org.gameyfin.app.users.entities.User
import org.gameyfin.app.util.EventPublisherHolder
import org.gameyfin.pluginapi.gamemetadata.Platform
@@ -20,7 +20,7 @@ class DynamicPublicAccessAuthorizationManager(
): AuthorizationDecision {
val auth = authentication?.get()
val allow = (auth?.isAuthenticated == true && auth.principal != "anonymousUser") ||
config.get(ConfigProperties.Libraries.AllowPublicAccess) == true
config.get(ConfigProperties.Security.AllowPublicAccess) == true
return AuthorizationDecision(allow)
}
@@ -23,7 +23,9 @@ import org.gameyfin.app.games.entities.*
import org.gameyfin.app.games.extensions.toDtos
import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.media.Image
import org.gameyfin.app.media.ImageService
import org.gameyfin.app.media.ImageType
import org.gameyfin.app.users.UserService
import org.gameyfin.pluginapi.gamemetadata.*
import org.springframework.data.repository.findByIdOrNull
@@ -34,6 +36,7 @@ import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import java.nio.file.Path
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.concurrent.Executors
@@ -236,7 +239,7 @@ class GameService(
}
@Transactional
fun update(game: Game): Game? {
fun updateMetadata(game: Game): Game? {
var wasGameUpdated = false
val game = getById(game.id!!)
@@ -447,6 +450,11 @@ class GameService(
return if (wasGameUpdated) game else null
}
fun update(game: Game): Game {
game.updatedAt = Instant.now()
return gameRepository.save(game)
}
fun delete(gameId: Long) {
gameRepository.deleteById(gameId)
}
@@ -680,7 +688,7 @@ class GameService(
.toMap()
if (metadataResults.isEmpty()) {
log.info { "Could not identify game at path '$path'" }
log.debug { "Could not identify game at path '$path'" }
return null
}
@@ -1,6 +1,7 @@
package org.gameyfin.app.games.dto
import com.fasterxml.jackson.annotation.JsonInclude
import org.gameyfin.app.media.ImageDto
import org.gameyfin.pluginapi.gamemetadata.*
import java.time.Instant
import java.time.LocalDate
@@ -10,10 +11,11 @@ sealed interface GameDto {
val createdAt: Instant
val updatedAt: Instant
val libraryId: Long
val collectionIds: List<Long>
val title: String
val platforms: List<Platform>
val coverId: Long?
val headerId: Long?
val cover: ImageDto?
val header: ImageDto?
val comment: String?
val summary: String?
val release: LocalDate?
@@ -26,7 +28,7 @@ sealed interface GameDto {
val keywords: List<String>?
val features: List<GameFeature>?
val perspectives: List<PlayerPerspective>?
val imageIds: List<Long>?
val images: List<ImageDto>?
val videoUrls: List<String>?
val metadata: GameMetadataDto
}
@@ -37,10 +39,11 @@ data class GameUserDto(
override val createdAt: Instant,
override val updatedAt: Instant,
override val libraryId: Long,
override val collectionIds: List<Long>,
override val title: String,
override val platforms: List<Platform>,
override val coverId: Long?,
override val headerId: Long?,
override val cover: ImageDto?,
override val header: ImageDto?,
override val comment: String?,
override val summary: String?,
override val release: LocalDate?,
@@ -53,7 +56,7 @@ data class GameUserDto(
override val keywords: List<String>?,
override val features: List<GameFeature>?,
override val perspectives: List<PlayerPerspective>?,
override val imageIds: List<Long>?,
override val images: List<ImageDto>?,
override val videoUrls: List<String>?,
override val metadata: GameMetadataUserDto
) : GameDto
@@ -64,10 +67,11 @@ data class GameAdminDto(
override val createdAt: Instant,
override val updatedAt: Instant,
override val libraryId: Long,
override val collectionIds: List<Long>,
override val title: String,
override val platforms: List<Platform>,
override val coverId: Long?,
override val headerId: Long?,
override val cover: ImageDto?,
override val header: ImageDto?,
override val comment: String?,
override val summary: String?,
override val release: LocalDate?,
@@ -80,7 +84,7 @@ data class GameAdminDto(
override val keywords: List<String>?,
override val features: List<GameFeature>?,
override val perspectives: List<PlayerPerspective>?,
override val imageIds: List<Long>?,
override val images: List<ImageDto>?,
override val videoUrls: List<String>?,
override val metadata: GameMetadataAdminDto
) : GameDto
@@ -2,7 +2,9 @@ package org.gameyfin.app.games.entities
import jakarta.persistence.*
import jakarta.persistence.CascadeType.*
import org.gameyfin.app.collections.entities.Collection
import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.media.Image
import org.gameyfin.pluginapi.gamemetadata.*
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp
@@ -79,6 +81,9 @@ class Game(
@ElementCollection
var videoUrls: List<URI> = emptyList(),
@ManyToMany(mappedBy = "games", fetch = FetchType.EAGER)
var collections: MutableList<Collection> = mutableListOf(),
@Embedded
var metadata: GameMetadata
) {
@@ -3,6 +3,7 @@ package org.gameyfin.app.games.extensions
import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.games.dto.*
import org.gameyfin.app.games.entities.*
import org.gameyfin.app.media.toDto
import java.time.ZoneOffset
@@ -28,10 +29,11 @@ fun Game.toAdminDto(): GameAdminDto {
createdAt = createdAt!!,
updatedAt = updatedAt!!,
libraryId = this.library.id!!,
collectionIds = this.collections.mapNotNull { it.id },
title = title!!,
platforms = this.platforms,
coverId = this.coverImage?.id,
headerId = this.headerImage?.id,
cover = this.coverImage?.toDto(),
header = this.headerImage?.toDto(),
comment = this.comment,
summary = this.summary,
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
@@ -44,7 +46,7 @@ fun Game.toAdminDto(): GameAdminDto {
keywords = this.keywords.toList(),
features = this.features,
perspectives = this.perspectives,
imageIds = this.images.mapNotNull { it.id },
images = this.images.map { it.toDto() },
videoUrls = this.videoUrls.map { it.toString() },
metadata = this.metadata.toAdminDto()
)
@@ -56,10 +58,11 @@ fun Game.toUserDto(): GameUserDto {
createdAt = createdAt!!,
updatedAt = updatedAt!!,
libraryId = this.library.id!!,
collectionIds = this.collections.mapNotNull { it.id },
title = title!!,
platforms = this.platforms,
coverId = this.coverImage?.id,
headerId = this.headerImage?.id,
cover = this.coverImage?.toDto(),
header = this.headerImage?.toDto(),
comment = this.comment,
summary = this.summary,
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
@@ -72,7 +75,7 @@ fun Game.toUserDto(): GameUserDto {
keywords = this.keywords.toList(),
features = this.features,
perspectives = this.perspectives,
imageIds = this.images.mapNotNull { it.id },
images = this.images.map { it.toDto() },
videoUrls = this.videoUrls.map { it.toString() },
metadata = this.metadata.toUserDto()
)
@@ -1,6 +1,6 @@
package org.gameyfin.app.games.repositories
import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.media.Image
import org.springframework.content.commons.store.ContentStore
import org.springframework.stereotype.Repository
@@ -1,6 +1,6 @@
package org.gameyfin.app.games.repositories
import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.media.Image
import org.springframework.data.jpa.repository.JpaRepository
interface ImageRepository : JpaRepository<Image, Long> {
@@ -46,6 +46,9 @@ class LibraryEndpoint(
@RolesAllowed(Role.Names.ADMIN)
fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library)
@RolesAllowed(Role.Names.ADMIN)
fun updateLibraries(libraries: Collection<LibraryUpdateDto>) = libraryService.update(libraries)
@RolesAllowed(Role.Names.ADMIN)
fun deleteLibrary(libraryId: Long) = libraryService.delete(libraryId)
}
@@ -102,60 +102,37 @@ class LibraryScanService(
emit(progress)
try {
val scanResult = filesystemService.scanLibraryForGamefiles(library)
val newPaths = scanResult.newPaths
val removedGamePaths = scanResult.removedGamePaths.map { it.toString() }
val removedIgnoredPaths = scanResult.removedIgnoredPaths
// Get plugin-generated (system) ignored paths to re-scan
val pluginIgnoredPathsToRescan = library.ignoredPaths
.filter { it.getType() == IgnoredPathSourceType.PLUGIN }
.map { Path.of(it.path) }
progress.currentStep = LibraryScanStep(
description = "Processing new games",
current = 0,
total = newPaths.size + pluginIgnoredPathsToRescan.size
)
emit(progress)
val scanData = performFilesystemScan(library)
// 1. Process each new game independently (including re-scanned plugin ignored paths)
val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan
val (newUnmatchedPaths, persistedNewGames) = processNewGames(library, allPathsToProcess, progress)
val (newUnmatchedPaths, persistedNewGames) = processNewGamesWithProgress(
library,
scanData.allPathsToProcess,
progress
)
// 2. Update library (removed games/ignored paths, and add persisted new ones)
val (removedGames) = updateLibrary(
library,
removedIgnoredPaths,
scanData.removedIgnoredPaths,
newUnmatchedPaths,
removedGamePaths
scanData.removedGamePaths
)
// 3. Finish scan: persist library changes and report
progress.currentStep = LibraryScanStep(
description = "Finishing up",
current = 0,
total = persistedNewGames.size
)
emit(progress)
finishScanWithProgress(persistedNewGames, library, progress)
finishScanPersisted(persistedNewGames, library, progress)
progress.currentStep = LibraryScanStep(description = "Finished")
progress.finishedAt = Instant.now()
progress.status = LibraryScanStatus.COMPLETED
progress.result = QuickScanResult(
new = persistedNewGames.size,
removed = removedGames.size,
unmatched = newUnmatchedPaths.size
// 4. Send final progress update
completeScan(
progress,
QuickScanResult(
new = persistedNewGames.size,
removed = removedGames.size,
unmatched = newUnmatchedPaths.size
)
)
emit(progress)
} catch (e: Exception) {
log.error { "Error during quick scan for library ${library.id}: ${e.message}" }
log.debug(e) {}
progress.status = LibraryScanStatus.FAILED
progress.finishedAt = Instant.now()
emit(progress)
handleScanError(e, library, progress, "quick scan")
}
}
@@ -170,16 +147,7 @@ class LibraryScanService(
emit(progress)
try {
val scanResult = filesystemService.scanLibraryForGamefiles(library)
val newPaths = scanResult.newPaths
val removedGamePaths = scanResult.removedGamePaths.map { it.toString() }
val removedIgnoredPaths = scanResult.removedIgnoredPaths
// Get plugin-generated (system) ignored paths to re-scan
val pluginIgnoredPathsToRescan = library.ignoredPaths
.filter { it.getType() == IgnoredPathSourceType.PLUGIN }
.map { Path.of(it.path) }
val scanData = performFilesystemScan(library)
// 1. Update existing games (individually)
progress.currentStep = LibraryScanStep(
@@ -192,54 +160,109 @@ class LibraryScanService(
val (updatedGames) = updateExistingGames(library.games, progress)
// 2. Process new games (individually, including re-scanned plugin ignored paths)
val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan
progress.currentStep = LibraryScanStep(
description = "Processing new games",
current = 0,
total = allPathsToProcess.size
val (newUnmatchedPaths, persistedNewGames) = processNewGamesWithProgress(
library,
scanData.allPathsToProcess,
progress
)
emit(progress)
val (newUnmatchedPaths, persistedNewGames) = processNewGames(library, allPathsToProcess, progress)
val (removedGames) = updateLibrary(
library,
removedIgnoredPaths,
scanData.removedIgnoredPaths,
newUnmatchedPaths,
removedGamePaths
scanData.removedGamePaths
)
// 3. Finish scan
progress.currentStep = LibraryScanStep(
description = "Finishing up",
current = 0,
total = persistedNewGames.size
)
emit(progress)
finishScanPersisted(persistedNewGames, library, progress)
finishScanWithProgress(persistedNewGames, library, progress)
// 4. Send final progress update
progress.currentStep = LibraryScanStep(description = "Finished")
progress.finishedAt = Instant.now()
progress.status = LibraryScanStatus.COMPLETED
progress.result = FullScanResult(
new = persistedNewGames.size,
removed = removedGames.size,
unmatched = newUnmatchedPaths.size,
updated = updatedGames.size
completeScan(
progress,
FullScanResult(
new = persistedNewGames.size,
removed = removedGames.size,
unmatched = newUnmatchedPaths.size,
updated = updatedGames.size
)
)
emit(progress)
} catch (e: Exception) {
log.error { "Error during full scan for library ${library.id}: ${e.message}" }
log.debug(e) {}
progress.status = LibraryScanStatus.FAILED
progress.finishedAt = Instant.now()
emit(progress)
return
handleScanError(e, library, progress, "full scan")
}
}
private data class FilesystemScanData(
val allPathsToProcess: List<Path>,
val removedGamePaths: List<String>,
val removedIgnoredPaths: List<IgnoredPath>
)
private fun performFilesystemScan(library: Library): FilesystemScanData {
val scanResult = filesystemService.scanLibraryForGamefiles(library)
val newPaths = scanResult.newPaths
val removedGamePaths = scanResult.removedGamePaths.map { it.toString() }
val removedIgnoredPaths = scanResult.removedIgnoredPaths
// Get plugin-generated (system) ignored paths to re-scan
val pluginIgnoredPathsToRescan = library.ignoredPaths
.filter { it.getType() == IgnoredPathSourceType.PLUGIN }
.map { Path.of(it.path) }
val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan
return FilesystemScanData(
allPathsToProcess = allPathsToProcess,
removedGamePaths = removedGamePaths,
removedIgnoredPaths = removedIgnoredPaths
)
}
private fun processNewGamesWithProgress(
library: Library,
gamePaths: List<Path>,
progress: LibraryScanProgress
): MatchNewGamesResult {
progress.currentStep = LibraryScanStep(
description = "Processing new games",
current = 0,
total = gamePaths.size
)
emit(progress)
return processNewGames(library, gamePaths, progress)
}
private fun finishScanWithProgress(
persistedNewGames: List<Game>,
library: Library,
progress: LibraryScanProgress
) {
progress.currentStep = LibraryScanStep(
description = "Finishing up",
current = 0,
total = persistedNewGames.size
)
emit(progress)
finishScanPersisted(persistedNewGames, library, progress)
}
private fun completeScan(progress: LibraryScanProgress, result: LibraryScanResult) {
progress.currentStep = LibraryScanStep(description = "Finished")
progress.finishedAt = Instant.now()
progress.status = LibraryScanStatus.COMPLETED
progress.result = result
emit(progress)
}
private fun handleScanError(e: Exception, library: Library, progress: LibraryScanProgress, scanType: String) {
log.error { "Error during $scanType for library ${library.id}: ${e.message}" }
log.debug(e) {}
progress.status = LibraryScanStatus.FAILED
progress.finishedAt = Instant.now()
emit(progress)
}
private fun processNewGames(
library: Library,
gamePaths: List<Path>,
@@ -5,6 +5,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.libraries.dto.*
import org.gameyfin.app.libraries.entities.DirectoryMapping
import org.gameyfin.app.libraries.entities.IgnoredPathSourceType
import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.libraries.enums.ScanType
import org.gameyfin.app.libraries.extensions.toDtos
@@ -34,7 +35,7 @@ class LibraryService(
private val libraryAdminEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryAdminEvent>(1024, false)
fun subscribeUser(): Flux<List<LibraryUserEvent>> {
log.debug { "New user subscription for libraryEvents" }
log.debug { "New user subscription for libraryUserEvents" }
return libraryUserEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe {
@@ -46,7 +47,7 @@ class LibraryService(
}
fun subscribeAdmin(): Flux<List<LibraryAdminEvent>> {
log.debug { "New admin subscription for libraryEvents" }
log.debug { "New admin subscription for libraryAdminEvents" }
return libraryAdminEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe {
@@ -166,15 +167,16 @@ class LibraryService(
library.platforms.addAll(it)
}
// Only allow updating USER sourced ignored paths; preserve PLUGIN sourced ones
libraryUpdateDto.ignoredPaths
?.filter { it.sourceType == IgnoredPathSourceTypeDto.USER } // Only USER source type is supported for updates
?.let { dtos ->
// Get current user for USER source type paths
val currentUser = getCurrentAuth()?.let { auth -> userService.getByUsername(auth.name) }
library.ignoredPaths.clear()
// Remove existing USER-sourced ignored paths, keep PLUGIN-sourced ones intact
library.ignoredPaths.removeIf { it.getType() == IgnoredPathSourceType.USER }
// Check for existing paths and reuse them if they exist
// Recreate user-sourced paths (reuse existing entity if same path already present globally)
val pathsToAdd = dtos.map { dto ->
val existingPath = ignoredPathRepository.findByPath(dto.path)
existingPath ?: dto.toEntity(currentUser)
@@ -183,10 +185,21 @@ class LibraryService(
library.ignoredPaths.addAll(pathsToAdd)
}
libraryUpdateDto.metadata?.let {
library.metadata = it.toEntity()
}
library.updatedAt = Instant.now()
libraryRepository.save(library)
}
/**
* Updates multiple libraries in the repository.
*/
fun update(libraries: Collection<LibraryUpdateDto>) {
libraries.forEach { update(it) }
}
/**
* Deletes a library from the repository.
*
@@ -0,0 +1,363 @@
package org.gameyfin.app.libraries
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService
import org.gameyfin.app.core.events.LibraryCreatedEvent
import org.gameyfin.app.core.events.LibraryDeletedEvent
import org.gameyfin.app.core.events.LibraryFilesystemWatcherConfigUpdatedEvent
import org.gameyfin.app.core.events.LibraryUpdatedEvent
import org.gameyfin.app.core.filesystem.FilesystemService
import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.libraries.enums.ScanType
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
import java.nio.file.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.io.path.isDirectory
/**
* Service that monitors library directories for file system changes and automatically
* updates games and libraries when files are added, removed, or modified.
*/
@Service
class LibraryWatcherService(
private val libraryRepository: LibraryRepository,
private val libraryScanService: LibraryScanService,
private val gameRepository: GameRepository,
private val filesystemService: FilesystemService,
private val configService: ConfigService
) {
companion object {
private val log = KotlinLogging.logger {}
}
data class LibraryWatchInfo(
val libraryId: Long,
val path: Path
)
private var watchService: WatchService? = null
private val watchKeys = ConcurrentHashMap<WatchKey, LibraryWatchInfo>()
private val libraryWatchers = ConcurrentHashMap<Long, MutableList<WatchKey>>()
private var executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "library-watcher-thread").apply { isDaemon = true }
}
private val running = AtomicBoolean(false)
@PostConstruct
fun start() {
// Check if filesystem watcher is enabled in config
val isEnabled = configService.get(ConfigProperties.Libraries.Scan.EnableFilesystemWatcher) ?: false
if (!isEnabled) {
log.debug { "Library Watcher Service is disabled in configuration" }
return
}
log.debug { "Starting Library Watcher Service" }
// Create a new watch service if needed
if (watchService == null) {
watchService = FileSystems.getDefault().newWatchService()
}
// Recreate executor if it was previously shut down
if (executor.isShutdown) {
executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "library-watcher-thread").apply { isDaemon = true }
}
}
running.set(true)
// Start watching all existing libraries
val libraries = libraryRepository.findAll()
libraries.forEach { library ->
startWatchingLibrary(library)
}
// Start the watch service thread
executor.submit {
watchForChanges()
}
log.info { "Library Watcher Service started, monitoring ${libraries.size} libraries" }
}
@PreDestroy
fun stop() {
log.debug { "Stopping Library Watcher Service" }
running.set(false)
// Close all watch keys
watchKeys.keys.forEach { it.cancel() }
watchKeys.clear()
libraryWatchers.clear()
// Shutdown executor
executor.shutdown()
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow()
}
} catch (_: InterruptedException) {
executor.shutdownNow()
Thread.currentThread().interrupt()
}
// Close watch service
watchService?.close()
watchService = null
log.info { "Library Watcher Service stopped" }
}
@Async
@EventListener(LibraryCreatedEvent::class)
fun onLibraryCreated(event: LibraryCreatedEvent) {
if (!running.get()) {
log.debug { "Library created event received but watcher is not running, skipping" }
return
}
log.debug { "Library created event received for library ${event.library.id}" }
startWatchingLibrary(event.library)
}
@Async
@EventListener(LibraryUpdatedEvent::class)
fun onLibraryUpdated(event: LibraryUpdatedEvent) {
if (!running.get()) {
log.debug { "Library updated event received but watcher is not running, skipping" }
return
}
log.debug { "Library updated event received for library ${event.currentState.id}" }
// Stop watching the old directories
stopWatchingLibrary(event.currentState.id!!)
// Start watching the new directories
startWatchingLibrary(event.currentState)
}
@Async
@EventListener(LibraryDeletedEvent::class)
fun onLibraryDeleted(event: LibraryDeletedEvent) {
if (!running.get()) {
log.debug { "Library deleted event received but watcher is not running, skipping" }
return
}
log.debug { "Library deleted event received for library ${event.library.id}" }
stopWatchingLibrary(event.library.id!!)
}
@Async
@EventListener(LibraryFilesystemWatcherConfigUpdatedEvent::class)
fun onFilesystemWatcherConfigUpdated(event: LibraryFilesystemWatcherConfigUpdatedEvent) {
log.debug { "Filesystem watcher configuration updated" }
if (event.isEnabled && !running.get()) {
// Configuration changed to enabled and watcher is not running - start it
log.debug { "Filesystem watcher enabled, starting watchers" }
start()
} else if (!event.isEnabled && running.get()) {
// Configuration changed to disabled and watcher is running - stop it
log.debug { "Filesystem watcher disabled, stopping watchers" }
stop()
}
}
private fun startWatchingLibrary(library: Library) {
val libraryId = library.id ?: return
log.debug { "Starting to watch library '${library.name}' (ID: $libraryId)" }
library.directories.forEach { directoryMapping ->
try {
val path = Paths.get(directoryMapping.internalPath)
if (!path.isDirectory()) {
log.warn { "Path is not a directory: $path" }
return@forEach
}
// Register the directory with the watch service
val service = watchService
if (service == null) {
log.warn { "Watch service is not initialized, cannot watch directory: $path" }
return@forEach
}
val watchKey = path.register(
service,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY
)
val watchInfo = LibraryWatchInfo(libraryId, path)
watchKeys[watchKey] = watchInfo
libraryWatchers.computeIfAbsent(libraryId) { mutableListOf() }.add(watchKey)
log.debug { "Registered watcher for directory: $path in library $libraryId" }
} catch (e: Exception) {
log.error(e) { "Failed to register watcher for directory: ${directoryMapping.internalPath}" }
}
}
}
private fun stopWatchingLibrary(libraryId: Long) {
log.debug { "Stopping watchers for library $libraryId" }
libraryWatchers[libraryId]?.forEach { watchKey ->
watchKey.cancel()
watchKeys.remove(watchKey)
}
libraryWatchers.remove(libraryId)
log.debug { "Stopped all watchers for library $libraryId" }
}
private fun watchForChanges() {
log.debug { "Watch service thread started" }
while (running.get()) {
try {
val watchKey = watchService?.poll(1, TimeUnit.SECONDS) ?: continue
val watchInfo = watchKeys[watchKey] ?: continue
val events = watchKey.pollEvents()
if (events.isEmpty()) {
watchKey.reset()
continue
}
log.debug { "Detected ${events.size} file system events in library ${watchInfo.libraryId}" }
// Group events by type
val hasCreates = events.any { it.kind() == StandardWatchEventKinds.ENTRY_CREATE }
val hasDeletes = events.any { it.kind() == StandardWatchEventKinds.ENTRY_DELETE }
val hasModifies = events.any { it.kind() == StandardWatchEventKinds.ENTRY_MODIFY }
// Process the events
processFileSystemEvents(watchInfo, events, hasCreates, hasDeletes, hasModifies)
// Reset the watch key
if (!watchKey.reset()) {
log.warn { "Watch key no longer valid for path: ${watchInfo.path}" }
watchKeys.remove(watchKey)
libraryWatchers[watchInfo.libraryId]?.remove(watchKey)
}
} catch (_: InterruptedException) {
log.debug { "Watch service thread interrupted" }
Thread.currentThread().interrupt()
break
} catch (e: Exception) {
log.error(e) { "Error processing file system events" }
}
}
log.debug { "Watch service thread stopped" }
}
private fun processFileSystemEvents(
watchInfo: LibraryWatchInfo,
events: List<WatchEvent<*>>,
hasCreates: Boolean,
hasDeletes: Boolean,
hasModifies: Boolean
) {
try {
val library = libraryRepository.findById(watchInfo.libraryId).orElse(null)
if (library == null) {
log.warn { "Library ${watchInfo.libraryId} not found, stopping watcher" }
stopWatchingLibrary(watchInfo.libraryId)
return
}
if (events.isEmpty()) {
log.debug { "No relevant game file changes detected" }
return
}
log.debug {
"Processing ${events.size} relevant file changes in library '${library.name}' " +
"(creates: $hasCreates, deletes: $hasDeletes, modifies: $hasModifies)"
}
// Handle creates (new games)
if (hasCreates) {
handleCreates(library, events.filter {
it.kind() == StandardWatchEventKinds.ENTRY_CREATE
})
}
// Handle deletes (removed games)
if (hasDeletes) {
handleDeletes(library, events.filter {
it.kind() == StandardWatchEventKinds.ENTRY_DELETE
})
}
// Handle modifies (changed file sizes)
if (hasModifies) {
handleModifies(library, watchInfo, events.filter {
it.kind() == StandardWatchEventKinds.ENTRY_MODIFY
})
}
} catch (e: Exception) {
log.error(e) { "Error processing file system events for library ${watchInfo.libraryId}" }
}
}
private fun handleCreates(library: Library, events: List<WatchEvent<*>>) {
log.debug { "Handling ${events.size} create events for library ${library.id}" }
// Trigger a quick scan to add new games
// The scan service will handle the actual game creation
libraryScanService.triggerScan(ScanType.QUICK, listOf(library.id!!))
}
private fun handleDeletes(library: Library, events: List<WatchEvent<*>>) {
log.debug { "Handling ${events.size} delete events for library ${library.id}" }
// Trigger a quick scan to remove deleted games
// The scan service will handle the actual game deletion
libraryScanService.triggerScan(ScanType.QUICK, listOf(library.id!!))
}
private fun handleModifies(library: Library, watchInfo: LibraryWatchInfo, events: List<WatchEvent<*>>) {
log.debug { "Handling ${events.size} modify events for library ${library.id}" }
events.forEach { event ->
@Suppress("UNCHECKED_CAST")
val watchEvent = event as WatchEvent<Path>
val filename = watchEvent.context()
val fullPath = watchInfo.path.resolve(filename)
// Find games that match this path and update their file size
val gamesToUpdate = library.games.filter { game ->
game.metadata.path == fullPath.toString()
}
if (gamesToUpdate.isNotEmpty()) {
log.debug { "Updating file size for ${gamesToUpdate.size} games: $fullPath" }
gamesToUpdate.forEach { game ->
val newFileSize = filesystemService.calculateFileSize(game.metadata.path)
if (game.metadata.fileSize != newFileSize) {
game.metadata.fileSize = newFileSize
gameRepository.save(game)
log.debug { "Updated file size for game '${game.title}' from ${game.metadata.fileSize} to $newFileSize bytes" }
}
}
}
}
}
}
@@ -2,27 +2,34 @@ package org.gameyfin.app.libraries.dto
import com.fasterxml.jackson.annotation.JsonInclude
import org.gameyfin.pluginapi.gamemetadata.Platform
import java.time.Instant
interface LibraryDto {
val id: Long
val name: String
val games: List<Long>?
val createdAt: Instant?
val gameIds: List<Long>?
val metadata: LibraryMetadataDto?
}
@JsonInclude(JsonInclude.Include.NON_NULL)
data class LibraryUserDto(
override val id: Long,
override val name: String,
override val games: List<Long>?
override val createdAt: Instant?,
override val gameIds: List<Long>?,
override val metadata: LibraryMetadataDto?
) : LibraryDto
@JsonInclude(JsonInclude.Include.NON_NULL)
data class LibraryAdminDto(
override val id: Long,
override val name: String,
override val createdAt: Instant?,
val directories: List<DirectoryMappingDto>,
val platforms: List<Platform>,
override val games: List<Long>?,
override val gameIds: List<Long>?,
val stats: LibraryStatsDto?,
val ignoredPaths: List<IgnoredPathDto>?
val ignoredPaths: List<IgnoredPathDto>?,
override val metadata: LibraryMetadataDto?
) : LibraryDto
@@ -0,0 +1,6 @@
package org.gameyfin.app.libraries.dto
data class LibraryMetadataDto(
val displayOnHomepage: Boolean,
val displayOrder: Int
)
@@ -7,5 +7,6 @@ data class LibraryUpdateDto(
val name: String? = null,
val directories: List<DirectoryMappingDto>? = null,
val platforms: List<Platform>? = null,
val ignoredPaths: List<IgnoredPathDto>? = null
val ignoredPaths: List<IgnoredPathDto>? = null,
val metadata: LibraryMetadataDto? = null
)
@@ -35,5 +35,8 @@ class Library(
var games: MutableList<Game> = ArrayList(),
@OneToMany(fetch = FetchType.EAGER, orphanRemoval = true, cascade = [CascadeType.ALL])
var ignoredPaths: MutableList<IgnoredPath> = ArrayList()
var ignoredPaths: MutableList<IgnoredPath> = ArrayList(),
@Embedded
var metadata: LibraryMetadata = LibraryMetadata()
)
@@ -0,0 +1,9 @@
package org.gameyfin.app.libraries.entities
import jakarta.persistence.Embeddable
@Embeddable
class LibraryMetadata(
val displayOnHomepage: Boolean = true,
val displayOrder: Int = -1
)
@@ -3,6 +3,7 @@ package org.gameyfin.app.libraries.extensions
import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.libraries.dto.*
import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.libraries.entities.LibraryMetadata
fun Library.toDto(): LibraryDto {
@@ -25,7 +26,9 @@ fun Library.toUserDto(): LibraryUserDto {
return LibraryUserDto(
id = this.id!!,
name = this.name,
games = this.games.mapNotNull { it.id }
createdAt = this.createdAt!!,
gameIds = this.games.mapNotNull { it.id },
metadata = this.metadata.toDto()
)
}
@@ -33,13 +36,29 @@ fun Library.toAdminDto(): LibraryAdminDto {
return LibraryAdminDto(
id = this.id!!,
name = this.name,
createdAt = this.createdAt!!,
directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
platforms = this.platforms,
games = this.games.mapNotNull { it.id },
gameIds = this.games.mapNotNull { it.id },
stats = LibraryStatsDto(
gamesCount = this.games.size,
downloadedGamesCount = this.games.sumOf { it.metadata.downloadCount }
),
ignoredPaths = this.ignoredPaths.toDtos()
ignoredPaths = this.ignoredPaths.toDtos(),
metadata = this.metadata.toDto()
)
}
fun LibraryMetadata.toDto(): LibraryMetadataDto {
return LibraryMetadataDto(
displayOnHomepage = this.displayOnHomepage,
displayOrder = this.displayOrder
)
}
fun LibraryMetadataDto.toEntity(): LibraryMetadata {
return LibraryMetadata(
displayOnHomepage = this.displayOnHomepage,
displayOrder = this.displayOrder
)
}
@@ -51,7 +51,7 @@ class LibraryGameProcessor(
// Note: GameService.update will load and save the managed entity inside this same transaction
var updated: Game? = null
try {
updated = gameService.update(game)
updated = gameService.updateMetadata(game)
if (updated != null) {
// Download any images now associated with the game
downloadImagesForGame(updated)
@@ -1,4 +1,4 @@
package org.gameyfin.app.games.entities
package org.gameyfin.app.media
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
@@ -25,7 +25,9 @@ class Image(
var contentLength: Long? = null,
@MimeType
var mimeType: String? = null
var mimeType: String? = null,
var blurhash: String? = null
)
enum class ImageType {
@@ -0,0 +1,7 @@
package org.gameyfin.app.media
data class ImageDto(
val id: Long,
val type: ImageType,
val blurhash: String?
)
@@ -8,8 +8,6 @@ import org.gameyfin.app.core.Utils
import org.gameyfin.app.core.annotations.DynamicPublicAccess
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.ImageType
import org.gameyfin.app.users.UserService
import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.InputStreamResource
@@ -0,0 +1,13 @@
package org.gameyfin.app.media
fun Image.toDto(): ImageDto = ImageDto(
id = this.id!!,
type = this.type,
blurhash = this.blurhash
)
fun ImageDto.toEntity(): Image = Image(
id = this.id,
type = this.type,
blurhash = this.blurhash
)
@@ -1,13 +1,12 @@
package org.gameyfin.app.media
import com.vanniktech.blurhash.BlurHash
import org.apache.tika.Tika
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.ImageType
import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.games.repositories.ImageContentStore
import org.gameyfin.app.games.repositories.ImageRepository
@@ -18,8 +17,12 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.net.URI
import javax.imageio.ImageIO
@Service
class ImageService(
@@ -30,6 +33,37 @@ class ImageService(
) {
companion object {
private val tika = Tika()
/**
* Scale down image for faster blurhash calculation.
* Blurhash doesn't need full resolution - 100px width is plenty for a good blur.
*/
fun scaleImageForBlurhash(original: BufferedImage, maxWidth: Int = 100): BufferedImage {
val originalWidth = original.width
val originalHeight = original.height
// If image is already small enough, return as-is
if (originalWidth <= maxWidth) {
return original
}
val scale = maxWidth.toDouble() / originalWidth
val targetWidth = maxWidth
val targetHeight = (originalHeight * scale).toInt()
val scaled = BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB)
val g2d = scaled.createGraphics()
// Use fast scaling for blurhash - quality doesn't matter much for a blur
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED)
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF)
g2d.drawImage(original, 0, 0, targetWidth, targetHeight, null)
g2d.dispose()
return scaled
}
}
@TransactionalEventListener(
@@ -126,7 +160,19 @@ class ImageService(
// If no existing image or existing image has no valid content, download it
TikaInputStream.get { URI.create(image.originalUrl).toURL().openStream() }.use { input ->
image.mimeType = tika.detect(input)
imageContentStore.setContent(image, input)
// Read the input stream into a byte array so we can use it twice
val imageBytes = input.readBytes()
// Calculate blurhash
ByteArrayInputStream(imageBytes).use { blurhashStream ->
image.blurhash = calculateBlurhash(blurhashStream)
}
// Store content
ByteArrayInputStream(imageBytes).use { contentStream ->
imageContentStore.setContent(image, contentStream)
}
}
// Save or update the image to ensure it's persisted
@@ -139,8 +185,22 @@ class ImageService(
fun createFromInputStream(type: ImageType, content: InputStream, mimeType: String): Image {
val image = Image(type = type, mimeType = mimeType)
imageRepository.save(image)
return imageContentStore.setContent(image, content)
// Read the input stream into a byte array so we can use it twice
val imageBytes = content.readBytes()
// Calculate blurhash
ByteArrayInputStream(imageBytes).use { blurhashStream ->
image.blurhash = calculateBlurhash(blurhashStream)
}
// Store content
ByteArrayInputStream(imageBytes).use { contentStream ->
imageContentStore.setContent(image, contentStream)
}
// Save with blurhash
return imageRepository.save(image)
}
fun getImage(id: Long): Image? {
@@ -165,12 +225,51 @@ class ImageService(
fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image {
mimeType?.let { image.mimeType = it }
imageRepository.save(image)
return imageContentStore.setContent(image, content)
// Read the input stream into a byte array so we can use it twice
val imageBytes = content.readBytes()
// Calculate blurhash
ByteArrayInputStream(imageBytes).use { blurhashStream ->
image.blurhash = calculateBlurhash(blurhashStream)
}
// Store content
ByteArrayInputStream(imageBytes).use { contentStream ->
imageContentStore.setContent(image, contentStream)
}
// Save with blurhash
return imageRepository.save(image)
}
private fun imageHasValidContent(image: Image): Boolean {
val imageContent = imageContentStore.getContent(image)
return imageContent != null && image.contentLength != null && image.contentLength!! > 0
}
}
private fun calculateBlurhash(inputStream: InputStream): String? {
return try {
val originalImage = ImageIO.read(inputStream)
if (originalImage != null) {
// Scale down for much faster processing
val scaledImage = scaleImageForBlurhash(originalImage)
return if (scaledImage.width > scaledImage.height) {
// Landscape
BlurHash.encode(scaledImage, componentX = 4, componentY = 3)
} else if (scaledImage.width < scaledImage.height) {
// Portrait
BlurHash.encode(scaledImage, componentX = 3, componentY = 4)
} else {
// Square
BlurHash.encode(scaledImage, componentX = 3, componentY = 3)
}
} else {
null
}
} catch (_: Exception) {
null
}
}
}
@@ -48,9 +48,9 @@ class PlatformService(
private val metadataPlugins: List<GameMetadataProvider>
get() = pluginManager.getExtensions(GameMetadataProvider::class.java)
private lateinit var _availablePlatforms: Set<Platform>
private lateinit var _platformsInUseByGames: Set<Platform>
private lateinit var _platformsInUseByLibraries: Set<Platform>
private var _availablePlatforms: Set<Platform> = emptySet()
private var _platformsInUseByGames: Set<Platform> = emptySet()
private var _platformsInUseByLibraries: Set<Platform> = emptySet()
val availablePlatforms: Set<Platform>
get() = _availablePlatforms
@@ -63,7 +63,7 @@ class PlatformService(
@EventListener(ApplicationReadyEvent::class)
fun initialize() {
log.info { "Initializing platform caches at startup" }
log.debug { "Initializing platform caches at startup" }
calculateAvailablePlatforms()
calculatePlatformsInUseByGames()
calculatePlatformsInUseByLibraries()
@@ -10,7 +10,7 @@ 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.media.Image
import org.gameyfin.app.media.ImageService
import org.gameyfin.app.users.dto.ExtendedUserInfoDto
import org.gameyfin.app.users.dto.UserRegistrationDto
@@ -3,7 +3,7 @@ package org.gameyfin.app.users.entities
import jakarta.persistence.*
import org.gameyfin.app.core.Role
import org.gameyfin.app.core.security.EncryptionConverter
import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.media.Image
import org.springframework.security.oauth2.core.oidc.user.OidcUser

Some files were not shown because too many files have changed in this diff Show More