Release 2.0.1

This commit is contained in:
Simon
2025-09-01 17:03:58 +02:00
committed by GitHub
12 changed files with 109 additions and 40 deletions
+1 -1
View File
@@ -265,4 +265,4 @@
"disableUsageStatistics": true, "disableUsageStatistics": true,
"hash": "962eccc3fa0735d5234901be4f9e384096113c45bec22564a53688096d62aef4" "hash": "962eccc3fa0735d5234901be4f9e384096113c45bec22564a53688096d62aef4"
} }
} }
@@ -49,7 +49,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
<ConfigFormField configElement={getConfig("sso.oidc.enabled")}/> <ConfigFormField configElement={getConfig("sso.oidc.enabled")}/>
<Section title="SSO user handling"/> <Section title="SSO user handling"/>
<div className="flex flex-row items-baseline"> <div className="flex flex-row items-baseline mb-4">
<CheckboxGroup className="flex flex-col flex-1 items-baseline gap-2" <CheckboxGroup className="flex flex-col flex-1 items-baseline gap-2"
value={["auto-register-new-users"]}> value={["auto-register-new-users"]}>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
@@ -70,6 +70,13 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
!formik.values.sso.oidc["auto-register-new-users"]}/> !formik.values.sso.oidc["auto-register-new-users"]}/>
</div> </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"/> <Section title="SSO provider configuration"/>
<ConfigFormField configElement={getConfig("sso.oidc.client-id")} <ConfigFormField configElement={getConfig("sso.oidc.client-id")}
isDisabled={!formik.values.sso.oidc.enabled}/> isDisabled={!formik.values.sso.oidc.enabled}/>
+1 -1
View File
@@ -46,7 +46,7 @@ export default function GameView() {
}, {} as Record<string, ComboButtonOption>); }, {} as Record<string, ComboButtonOption>);
setDownloadOptions(options); setDownloadOptions(options);
}); });
}, []); }, [gameId]);
useEffect(() => { useEffect(() => {
if (state.isLoaded && (!gameId || !state.state[parseInt(gameId)])) { if (state.isLoaded && (!gameId || !state.state[parseInt(gameId)])) {
@@ -147,6 +147,20 @@ sealed class ConfigProperties<T : Serializable>(
true true
) )
data object RolesClaim : ConfigProperties<String>(
String::class,
"sso.oidc.roles-claim",
"JWT claim to extract roles from",
"roles"
)
data object OAuthScopes : ConfigProperties<Array<String>>(
Array<String>::class,
"sso.oidc.oauth-scopes",
"OAuth2 scopes to request",
arrayOf("openid", "profile", "email", "roles")
)
data object ClientId : ConfigProperties<String>( data object ClientId : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.client-id", "sso.oidc.client-id",
@@ -98,7 +98,7 @@ class SecurityConfig(
val clientRegistration = ClientRegistration.withRegistrationId(SSO_PROVIDER_KEY) val clientRegistration = ClientRegistration.withRegistrationId(SSO_PROVIDER_KEY)
.clientId(config.get(ConfigProperties.SSO.OIDC.ClientId)) .clientId(config.get(ConfigProperties.SSO.OIDC.ClientId))
.clientSecret(config.get(ConfigProperties.SSO.OIDC.ClientSecret)) .clientSecret(config.get(ConfigProperties.SSO.OIDC.ClientSecret))
.scope("openid", "profile", "email") .scope(config.get(ConfigProperties.SSO.OIDC.OAuthScopes)?.toList())
.userNameAttributeName("preferred_username") .userNameAttributeName("preferred_username")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.issuerUri(config.get(ConfigProperties.SSO.OIDC.IssuerUrl)) .issuerUri(config.get(ConfigProperties.SSO.OIDC.IssuerUrl))
@@ -12,19 +12,19 @@ import org.gameyfin.app.games.extensions.toUserDto
class GameEntityListener { class GameEntityListener {
@PostPersist @PostPersist
fun created(game: Game) { fun created(game: Game) {
GameService.Companion.emitUser(GameUserEvent.Created(game.toUserDto())) GameService.emitUser(GameUserEvent.Created(game.toUserDto()))
GameService.Companion.emitAdmin(GameAdminEvent.Created(game.toAdminDto())) GameService.emitAdmin(GameAdminEvent.Created(game.toAdminDto()))
} }
@PostUpdate @PostUpdate
fun updated(game: Game) { fun updated(game: Game) {
GameService.Companion.emitUser(GameUserEvent.Updated(game.toUserDto())) GameService.emitUser(GameUserEvent.Updated(game.toUserDto()))
GameService.Companion.emitAdmin(GameAdminEvent.Updated(game.toAdminDto())) GameService.emitAdmin(GameAdminEvent.Updated(game.toAdminDto()))
} }
@PostRemove @PostRemove
fun deleted(game: Game) { fun deleted(game: Game) {
GameService.Companion.emitUser(GameUserEvent.Deleted(game.id!!)) GameService.emitUser(GameUserEvent.Deleted(game.id!!))
GameService.Companion.emitAdmin(GameAdminEvent.Deleted(game.id!!)) GameService.emitAdmin(GameAdminEvent.Deleted(game.id!!))
} }
} }
@@ -1,15 +1,13 @@
package org.gameyfin.app.games.entities package org.gameyfin.app.games.entities
import jakarta.persistence.Entity import jakarta.persistence.*
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import org.springframework.content.commons.annotations.ContentId import org.springframework.content.commons.annotations.ContentId
import org.springframework.content.commons.annotations.ContentLength import org.springframework.content.commons.annotations.ContentLength
import org.springframework.content.commons.annotations.MimeType import org.springframework.content.commons.annotations.MimeType
import java.net.URL import java.net.URL
@Entity @Entity
@EntityListeners(ImageEntityListener::class)
class Image( class Image(
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
@@ -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)
}
}
@@ -4,6 +4,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.core.filesystem.FilesystemService import org.gameyfin.app.core.filesystem.FilesystemService
import org.gameyfin.app.games.GameService import org.gameyfin.app.games.GameService
import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Game
import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.libraries.dto.LibraryScanProgress import org.gameyfin.app.libraries.dto.LibraryScanProgress
import org.gameyfin.app.libraries.dto.LibraryScanStatus import org.gameyfin.app.libraries.dto.LibraryScanStatus
import org.gameyfin.app.libraries.dto.LibraryScanStep import org.gameyfin.app.libraries.dto.LibraryScanStep
@@ -13,6 +14,7 @@ import org.gameyfin.app.media.ImageService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import java.net.URL
import java.nio.file.Path import java.nio.file.Path
import java.time.Instant import java.time.Instant
import java.util.concurrent.Callable import java.util.concurrent.Callable
@@ -331,39 +333,56 @@ class LibraryScanService(
private fun downloadImages(games: List<Game>, progress: LibraryScanProgress): DownloadImagesResult { private fun downloadImages(games: List<Game>, progress: LibraryScanProgress): DownloadImagesResult {
val completedImageDownload = AtomicInteger(0) val completedImageDownload = AtomicInteger(0)
val imageDownloadTasks = games.map { game -> // Collect all images from all games in the batch
Callable<Game?> { val allImages = games.flatMap { game ->
val images = mutableListOf<Image>()
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<URL, Image>()
// Download each unique image in parallel
val imageDownloadTasks = uniqueImages.map { image ->
Callable {
try { try {
game.coverImage?.let { imageService.downloadIfNew(image)
imageService.downloadIfNew(it) image.originalUrl?.let { url ->
completedImageDownload.andIncrement downloadedImageMap[url] = image
} }
game.headerImage?.let {
imageService.downloadIfNew(it)
completedImageDownload.andIncrement
}
game.images.map {
imageService.downloadIfNew(it)
completedImageDownload.andIncrement
}
game
} catch (e: Exception) { } 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) {} log.debug(e) {}
null
} finally { } finally {
progress.currentStep.current = completedImageDownload.get() progress.currentStep.current = completedImageDownload.incrementAndGet()
emit(progress) 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<Game>, progress: LibraryScanProgress): CalculateFilesizesResult { private fun calculateFileSizes(games: List<Game>, progress: LibraryScanProgress): CalculateFilesizesResult {
@@ -1,5 +1,7 @@
package org.gameyfin.app.users 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.core.Role
import org.gameyfin.app.users.entities.User import org.gameyfin.app.users.entities.User
import org.gameyfin.app.users.persistence.UserRepository import org.gameyfin.app.users.persistence.UserRepository
@@ -11,7 +13,8 @@ import org.springframework.stereotype.Service
@Service @Service
class RoleService( class RoleService(
private val userRepository: UserRepository private val userRepository: UserRepository,
private val configService: ConfigService
) { ) {
companion object { companion object {
@@ -66,7 +69,8 @@ class RoleService(
.filterIsInstance<OidcUserAuthority>() .filterIsInstance<OidcUserAuthority>()
.flatMap { oidcUserAuthority -> .flatMap { oidcUserAuthority ->
val userInfo = oidcUserAuthority.userInfo val userInfo = oidcUserAuthority.userInfo
val roles = userInfo.getClaim<List<String>>("roles") ?: return@flatMap emptySequence() val rolesClaim = configService.get(ConfigProperties.SSO.OIDC.RolesClaim)
val roles = userInfo.getClaim<List<String>>(rolesClaim) ?: return@flatMap emptySequence()
roles.asSequence().mapNotNull { roles.asSequence().mapNotNull {
if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority( if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority(
it.replace(SSO_ROLE_PREFIX, INTERNAL_ROLE_PREFIX) it.replace(SSO_ROLE_PREFIX, INTERNAL_ROLE_PREFIX)
@@ -150,7 +150,7 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin
private fun getTrackerUri(): URI { private fun getTrackerUri(): URI {
val protocol = "http" // No SSL support in ttorrent: https://github.com/mpetazzoni/ttorrent/issues/4 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<Int>("trackerPort") val port = config<Int>("trackerPort")
val path = "announce" val path = "announce"
@@ -1,4 +1,4 @@
Plugin-Version: 1.0.0 Plugin-Version: 1.0.1
Plugin-Class: org.gameyfin.plugins.download.torrent.TorrentDownloadPlugin Plugin-Class: org.gameyfin.plugins.download.torrent.TorrentDownloadPlugin
Plugin-Id: org.gameyfin.plugins.download.torrent Plugin-Id: org.gameyfin.plugins.download.torrent
Plugin-Name: Torrent Download Plugin-Name: Torrent Download