diff --git a/.github/actions/docker-build-push/action.yml b/.github/actions/docker-build-push/action.yml index 7c90618..2170b2c 100644 --- a/.github/actions/docker-build-push/action.yml +++ b/.github/actions/docker-build-push/action.yml @@ -19,7 +19,15 @@ runs: username: ${{ inputs.ghcr_username }} password: ${{ inputs.ghcr_token }} - - name: Build and push Docker image + - name: Prepare Ubuntu tags + id: ubuntu_tags + shell: bash + run: | + TAGS="${{ inputs.tags }}" + UBUNTU_TAGS=$(echo "$TAGS" | awk -F, '{for(i=1;i<=NF;i++){split($i,a,":"); printf "%s:%s-ubuntu", a[1], a[2]; if(i> $GITHUB_OUTPUT + + - name: Build and push Docker image (Alpine) uses: docker/build-push-action@v5 with: context: ${{ inputs.context }} @@ -30,6 +38,17 @@ runs: cache-from: type=gha cache-to: type=gha + - name: Build and push Docker image (Ubuntu) + uses: docker/build-push-action@v5 + with: + context: ${{ inputs.context }} + file: docker/Dockerfile.ubuntu + platforms: ${{ inputs.platforms }} + push: true + tags: ${{ steps.ubuntu_tags.outputs.ubuntu_tags }} + cache-from: type=gha + cache-to: type=gha + inputs: dockerhub_username: required: true diff --git a/.github/workflows/docker-develop.yml b/.github/workflows/docker-develop.yml index 87d5776..2e0c221 100644 --- a/.github/workflows/docker-develop.yml +++ b/.github/workflows/docker-develop.yml @@ -18,10 +18,10 @@ jobs: packages: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/docker-fix.yml b/.github/workflows/docker-fix.yml index a759bc2..87b2b5f 100644 --- a/.github/workflows/docker-fix.yml +++ b/.github/workflows/docker-fix.yml @@ -12,10 +12,10 @@ jobs: packages: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc5e05f..eaf81a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: release_version: ${{ steps.get_version.outputs.release_version }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -64,17 +64,17 @@ jobs: packages: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Download modified files - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: modified-files - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '21' @@ -119,17 +119,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Download modified files - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: modified-files - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '21' @@ -150,12 +150,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Download modified files - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: modified-files diff --git a/app/package.json b/app/package.json index 59d3828..c7e446a 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "gameyfin", - "version": "2.0.0", + "version": "2.0.1", "type": "module", "dependencies": { "@heroui/react": "2.7.9", diff --git a/app/src/main/frontend/components/administration/SsoManagement.tsx b/app/src/main/frontend/components/administration/SsoManagement.tsx index 7db75e9..f873d8b 100644 --- a/app/src/main/frontend/components/administration/SsoManagement.tsx +++ b/app/src/main/frontend/components/administration/SsoManagement.tsx @@ -49,7 +49,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
-
+
@@ -70,6 +70,13 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) { !formik.values.sso.oidc["auto-register-new-users"]}/>
+
+ + +
+
diff --git a/app/src/main/frontend/components/general/cards/UserManagementCard.tsx b/app/src/main/frontend/components/general/cards/UserManagementCard.tsx index 5521e35..3bdc20e 100644 --- a/app/src/main/frontend/components/general/cards/UserManagementCard.tsx +++ b/app/src/main/frontend/components/general/cards/UserManagementCard.tsx @@ -1,6 +1,6 @@ -import {Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react"; +import {Button, Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react"; import {DotsThreeVertical} from "@phosphor-icons/react"; -import {useEffect, useState} from "react"; +import React, {useEffect, useState} from "react"; import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints"; import {AvatarEndpoint} from "Frontend/endpoints/endpoints"; import Avatar from "Frontend/components/general/Avatar"; @@ -108,6 +108,27 @@ export function UserManagementCard({user}: { user: UserInfoDto }) { <> +
+ + + + + + {(item) => ( + + {item.label} + + )} + + +

{user.username}

-

{user.email}

+

{user.email}

{user.roles?.map((role) => ( ))}
- - - - - - - {(item) => ( - - {item.label} - - )} - -
); setDownloadOptions(options); }); - }, []); + }, [gameId]); useEffect(() => { if (state.isLoaded && (!gameId || !state.state[parseInt(gameId)])) { diff --git a/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt b/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt index 23e928e..a65cf0a 100644 --- a/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt +++ b/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt @@ -147,6 +147,20 @@ sealed class ConfigProperties( true ) + data object RolesClaim : ConfigProperties( + String::class, + "sso.oidc.roles-claim", + "JWT claim to extract roles from", + "roles" + ) + + data object OAuthScopes : ConfigProperties>( + Array::class, + "sso.oidc.oauth-scopes", + "OAuth2 scopes to request", + arrayOf("openid", "profile", "email", "roles") + ) + data object ClientId : ConfigProperties( String::class, "sso.oidc.client-id", diff --git a/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityConfig.kt b/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityConfig.kt index 08df09c..3caaa62 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityConfig.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityConfig.kt @@ -98,7 +98,7 @@ class SecurityConfig( val clientRegistration = ClientRegistration.withRegistrationId(SSO_PROVIDER_KEY) .clientId(config.get(ConfigProperties.SSO.OIDC.ClientId)) .clientSecret(config.get(ConfigProperties.SSO.OIDC.ClientSecret)) - .scope("openid", "profile", "email") + .scope(config.get(ConfigProperties.SSO.OIDC.OAuthScopes)?.toList()) .userNameAttributeName("preferred_username") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .issuerUri(config.get(ConfigProperties.SSO.OIDC.IssuerUrl)) diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt index 4a1dc05..3bc44cf 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/Image.kt @@ -1,15 +1,13 @@ package org.gameyfin.app.games.entities -import jakarta.persistence.Entity -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id +import jakarta.persistence.* import org.springframework.content.commons.annotations.ContentId import org.springframework.content.commons.annotations.ContentLength import org.springframework.content.commons.annotations.MimeType import java.net.URL @Entity +@EntityListeners(ImageEntityListener::class) class Image( @Id @GeneratedValue(strategy = GenerationType.AUTO) diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/ImageEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/ImageEntityListener.kt new file mode 100644 index 0000000..0486bfc --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/ImageEntityListener.kt @@ -0,0 +1,27 @@ +package org.gameyfin.app.games.entities + +import jakarta.persistence.PostRemove +import org.gameyfin.app.media.ImageService +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware +import org.springframework.stereotype.Component + +@Component +class ImageEntityListener : ApplicationContextAware { + companion object { + private lateinit var applicationContext: ApplicationContext + } + + override fun setApplicationContext(context: ApplicationContext) { + applicationContext = context + } + + private fun getImageService(): ImageService { + return applicationContext.getBean(ImageService::class.java) + } + + @PostRemove + fun deleted(image: Image) { + getImageService().deleteFile(image) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt index 7ac4798..bbc28e9 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt @@ -4,14 +4,17 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.gameyfin.app.core.filesystem.FilesystemService import org.gameyfin.app.games.GameService import org.gameyfin.app.games.entities.Game -import org.gameyfin.app.libraries.dto.* -import org.gameyfin.app.libraries.entities.Library +import org.gameyfin.app.games.entities.Image +import org.gameyfin.app.libraries.dto.LibraryScanProgress +import org.gameyfin.app.libraries.dto.LibraryScanStatus +import org.gameyfin.app.libraries.dto.LibraryScanStep import org.gameyfin.app.libraries.enums.ScanType import org.gameyfin.app.libraries.scan.* import org.gameyfin.app.media.ImageService import org.springframework.stereotype.Service import reactor.core.publisher.Flux import reactor.core.publisher.Sinks +import java.net.URL import java.nio.file.Path import java.time.Instant import java.util.concurrent.Callable @@ -330,39 +333,56 @@ class LibraryScanService( private fun downloadImages(games: List, progress: LibraryScanProgress): DownloadImagesResult { val completedImageDownload = AtomicInteger(0) - val imageDownloadTasks = games.map { game -> - Callable { + // Collect all images from all games in the batch + val allImages = games.flatMap { game -> + val images = mutableListOf() + game.coverImage?.let { images.add(it) } + game.headerImage?.let { images.add(it) } + images.addAll(game.images) + images + } + + // Deduplicate by originalUrl + val uniqueImages = allImages + .filter { it.originalUrl != null } + .distinctBy { it.originalUrl } + + // Map to track which Image entity was used for download per originalUrl + val downloadedImageMap = ConcurrentHashMap() + + // Download each unique image in parallel + val imageDownloadTasks = uniqueImages.map { image -> + Callable { try { - game.coverImage?.let { - imageService.downloadIfNew(it) - completedImageDownload.andIncrement + imageService.downloadIfNew(image) + image.originalUrl?.let { url -> + downloadedImageMap[url] = image } - - game.headerImage?.let { - imageService.downloadIfNew(it) - completedImageDownload.andIncrement - } - - game.images.map { - imageService.downloadIfNew(it) - completedImageDownload.andIncrement - } - - game } catch (e: Exception) { - log.error { "Error downloading images for game '${game.title}' (${game.id}): ${e.message}" } + log.error { "Error downloading image '${image.originalUrl}': ${e.message}" } log.debug(e) {} - null } finally { - progress.currentStep.current = completedImageDownload.get() + progress.currentStep.current = completedImageDownload.incrementAndGet() emit(progress) } } } + executor.invokeAll(imageDownloadTasks) - val gamesWithImages = executor.invokeAll(imageDownloadTasks).mapNotNull { it.get() } + // After downloads, associate the contentId with all other Image entities in the batch with the same originalUrl + for ((url, downloadedImage) in downloadedImageMap) { + val contentId = downloadedImage.contentId + if (contentId != null) { + allImages.filter { it.originalUrl.toString() == url.toString() && it !== downloadedImage } + .forEach { image -> + imageService.downloadIfNew(image) + progress.currentStep.current = completedImageDownload.incrementAndGet() + emit(progress) + } + } + } - return DownloadImagesResult(gamesWithImages = gamesWithImages) + return DownloadImagesResult(gamesWithImages = games) } private fun calculateFileSizes(games: List, progress: LibraryScanProgress): CalculateFilesizesResult { diff --git a/app/src/main/kotlin/org/gameyfin/app/users/RoleService.kt b/app/src/main/kotlin/org/gameyfin/app/users/RoleService.kt index d5feeef..c729574 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/RoleService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/RoleService.kt @@ -1,5 +1,7 @@ package org.gameyfin.app.users +import org.gameyfin.app.config.ConfigProperties +import org.gameyfin.app.config.ConfigService import org.gameyfin.app.core.Role import org.gameyfin.app.users.entities.User import org.gameyfin.app.users.persistence.UserRepository @@ -11,7 +13,8 @@ import org.springframework.stereotype.Service @Service class RoleService( - private val userRepository: UserRepository + private val userRepository: UserRepository, + private val configService: ConfigService ) { companion object { @@ -66,7 +69,8 @@ class RoleService( .filterIsInstance() .flatMap { oidcUserAuthority -> val userInfo = oidcUserAuthority.userInfo - val roles = userInfo.getClaim>("roles") ?: return@flatMap emptySequence() + val rolesClaim = configService.get(ConfigProperties.SSO.OIDC.RolesClaim) + val roles = userInfo.getClaim>(rolesClaim) ?: return@flatMap emptySequence() roles.asSequence().mapNotNull { if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority( it.replace(SSO_ROLE_PREFIX, INTERNAL_ROLE_PREFIX) diff --git a/build.gradle.kts b/build.gradle.kts index 0fec534..fd5349d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile import java.nio.file.Files group = "org.gameyfin" -version = "2.0.0" +version = "2.0.1" allprojects { repositories { diff --git a/docker/Dockerfile.ubuntu b/docker/Dockerfile.ubuntu new file mode 100644 index 0000000..12d71b0 --- /dev/null +++ b/docker/Dockerfile.ubuntu @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1.4 +FROM eclipse-temurin:21-jre + +MAINTAINER grimsi + +# Install necessary packages +RUN apt-get update && \ + apt-get install -y tini gosu && \ + rm -rf /var/lib/apt/lists/* + +ENV USER=gameyfin + +RUN groupadd gameyfin && \ + useradd -M -g gameyfin gameyfin + +WORKDIR /opt/gameyfin + +# Create necessary directories with appropriate permissions +RUN mkdir -p plugins db data logs && \ + chown -R gameyfin:gameyfin . + +# Copy entrypoint script and set permissions +COPY --chown=gameyfin:gameyfin ./docker/entrypoint.ubuntu.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Copy application jar (not ending with -plain.jar) +COPY --chown=gameyfin:gameyfin ./app/build/libs/ /tmp/app-libs/ +RUN find /tmp/app-libs -type f -name "*.jar" ! -name "*-plain.jar" -exec cp {} gameyfin.jar \; && \ + rm -rf /tmp/app-libs + +# Copy all plugin jars +COPY --chown=gameyfin:gameyfin ./plugins/ /tmp/plugins/ +RUN find /tmp/plugins -type f -path "*/build/libs/*.jar" -exec cp {} plugins/ \; && \ + rm -rf /tmp/plugins + +EXPOSE 8080 + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] + diff --git a/docker/entrypoint.ubuntu.sh b/docker/entrypoint.ubuntu.sh new file mode 100644 index 0000000..6ace8d9 --- /dev/null +++ b/docker/entrypoint.ubuntu.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +if [ -n "$PUID" ] && [ -n "$PGID" ]; then + groupmod -o -g "$PGID" gameyfin + usermod -o -u "$PUID" gameyfin + chown -R gameyfin:gameyfin /opt/gameyfin + exec gosu gameyfin:gameyfin java -jar gameyfin.jar +else + exec gosu gameyfin:gameyfin java -jar gameyfin.jar +fi + diff --git a/plugins/torrentdownload/src/main/kotlin/org/gameyfin/plugins/download/torrent/TorrentDownloadPlugin.kt b/plugins/torrentdownload/src/main/kotlin/org/gameyfin/plugins/download/torrent/TorrentDownloadPlugin.kt index d3cc38d..d65eb46 100644 --- a/plugins/torrentdownload/src/main/kotlin/org/gameyfin/plugins/download/torrent/TorrentDownloadPlugin.kt +++ b/plugins/torrentdownload/src/main/kotlin/org/gameyfin/plugins/download/torrent/TorrentDownloadPlugin.kt @@ -150,7 +150,7 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin private fun getTrackerUri(): URI { val protocol = "http" // No SSL support in ttorrent: https://github.com/mpetazzoni/ttorrent/issues/4 - val host = getHostname().getCanonicalHostName() + val host = getHostname().getHostName() val port = config("trackerPort") val path = "announce" diff --git a/plugins/torrentdownload/src/main/resources/MANIFEST.MF b/plugins/torrentdownload/src/main/resources/MANIFEST.MF index ee430df..c947776 100644 --- a/plugins/torrentdownload/src/main/resources/MANIFEST.MF +++ b/plugins/torrentdownload/src/main/resources/MANIFEST.MF @@ -1,4 +1,4 @@ -Plugin-Version: 1.0.0 +Plugin-Version: 1.0.1 Plugin-Class: org.gameyfin.plugins.download.torrent.TorrentDownloadPlugin Plugin-Id: org.gameyfin.plugins.download.torrent Plugin-Name: Torrent Download