Release 2.3.2 (#831)

* Optimize performance of web UI while downloads are active

* chore: bump version to v2.3.2-preview

* Fix test

* Fix GameCover not refreshing until reload

* Bump actions/upload-artifact from 4 to 6 (#829)

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  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>

* Bump actions/download-artifact from 5 to 7 (#830)

Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  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 login redirect issue when behind NPM (#832)

---------

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-17 11:05:04 +01:00 committed by GitHub
parent 400c4d1c61
commit 386374f39c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 238 additions and 68 deletions

View File

@ -34,7 +34,7 @@ jobs:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: Upload build outputs
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: build-outputs
path: |
@ -51,7 +51,7 @@ jobs:
uses: actions/checkout@v6
- name: Download build outputs
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: build-outputs
path: .

View File

@ -64,7 +64,7 @@ jobs:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: Upload build outputs
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: build-outputs
path: |
@ -83,7 +83,7 @@ jobs:
fetch-depth: 0
- name: Download build outputs
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: build-outputs
path: .

View File

@ -50,7 +50,7 @@ jobs:
jq ".version = \"$RELEASE_VERSION\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
- name: Upload modified files
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: modified-files
path: |
@ -71,7 +71,7 @@ jobs:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: modified-files
@ -95,7 +95,7 @@ jobs:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: Upload build outputs
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: build-outputs
path: |
@ -114,12 +114,12 @@ jobs:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: modified-files
- name: Download build outputs
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: build-outputs
path: .
@ -158,7 +158,7 @@ jobs:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: modified-files
@ -189,7 +189,7 @@ jobs:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: modified-files

View File

@ -22,6 +22,19 @@ const GameCoverComponent = ({game, size = 300, radius = "sm", interactive = fals
const [isImageLoaded, setIsImageLoaded] = useState(isCached);
const [blurhashUrl, setBlurhashUrl] = useState<string | undefined>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
const prevCoverIdRef = useRef<number | undefined>(game.cover?.id);
// Reset state when cover ID changes
useEffect(() => {
const currentCoverId = game.cover?.id;
if (prevCoverIdRef.current !== currentCoverId) {
prevCoverIdRef.current = currentCoverId;
const newIsCached = currentCoverId ? loadedImagesCache.has(currentCoverId) : false;
setIsImageLoaded(newIsCached);
setBlurhashUrl(undefined);
setShouldLoad(!lazy);
}
}, [game.cover?.id, lazy]);
// Generate blurhash placeholder image
useEffect(() => {
@ -116,9 +129,10 @@ const GameCoverComponent = ({game, size = 300, radius = "sm", interactive = fals
};
// Memoize the component to prevent unnecessary re-renders
// Only re-render if the game ID, size, radius, interactive, or lazy props change
// Only re-render if the game ID, cover ID, size, radius, interactive, or lazy props change
export const GameCover = memo(GameCoverComponent, (prevProps, nextProps) => {
return prevProps.game.id === nextProps.game.id &&
prevProps.game.cover?.id === nextProps.game.cover?.id &&
prevProps.size === nextProps.size &&
prevProps.radius === nextProps.radius &&
prevProps.interactive === nextProps.interactive &&

View File

@ -135,9 +135,6 @@ export default function MainLayout() {
radius="full"
isIconOnly
className="gradient-primary"
/* This is hacky but works since "/loginredirect" is not configured and returns 401 for not logged-in users.
This triggers Hilla to redirect to the correct login page (integrated or SSO) automatically.
Otherwise, SSO login would not be possible if we redirect to "/login" directly */
onPress={() => window.location.href = "/loginredirect"}>
<SignInIcon fill="text-background/80"/>
</Button>

View File

@ -0,0 +1,50 @@
package org.gameyfin.app.core.config
import io.github.oshai.kotlinlogging.KotlinLogging
import org.apache.coyote.ProtocolHandler
import org.apache.coyote.http11.AbstractHttp11Protocol
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Tomcat configuration to optimize for concurrent connections
* and prevent download operations from blocking the server.
*/
@Configuration
class TomcatConfig {
companion object {
private val log = KotlinLogging.logger { }
}
@Bean
fun protocolHandlerCustomizer(): TomcatProtocolHandlerCustomizer<*> {
return TomcatProtocolHandlerCustomizer { protocolHandler: ProtocolHandler ->
if (protocolHandler is AbstractHttp11Protocol<*>) {
// Increase max connections to handle more concurrent users
protocolHandler.maxConnections = 10000
// Increase max threads to handle more concurrent requests
protocolHandler.maxThreads = 200
// Set minimum spare threads
protocolHandler.minSpareThreads = 10
// Set connection timeout (20 seconds)
protocolHandler.connectionTimeout = 20000
// Keep alive settings to reuse connections
protocolHandler.keepAliveTimeout = 60000
protocolHandler.maxKeepAliveRequests = 100
log.debug {
"Configured Tomcat connector: maxConnections=${protocolHandler.maxConnections}, " +
"maxThreads=${protocolHandler.maxThreads}, " +
"minSpareThreads=${protocolHandler.minSpareThreads}"
}
}
}
}
}

View File

@ -11,7 +11,10 @@ import org.gameyfin.pluginapi.download.FileDownload
import org.gameyfin.pluginapi.download.LinkDownload
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.context.request.async.DeferredResult
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import java.util.concurrent.Executor
import java.util.concurrent.Executors
@RestController
@RequestMapping("/download")
@ -19,40 +22,57 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
@AnonymousAllowed
class DownloadEndpoint(
private val downloadService: DownloadService,
private val gameService: GameService
private val gameService: GameService,
) {
private val downloadExecutor: Executor = Executors.newVirtualThreadPerTaskExecutor()
@GetMapping("/{gameId}")
fun downloadGame(
@PathVariable gameId: Long,
@RequestParam provider: String,
request: HttpServletRequest
): ResponseEntity<StreamingResponseBody> {
val game = gameService.getById(gameId)
gameService.incrementDownloadCount(game)
val sessionId = request.session.id
val remoteIp = request.getRemoteIp(LookupPolicy.IPV4_PREFERRED)
): DeferredResult<ResponseEntity<StreamingResponseBody>> {
val deferredResult = DeferredResult<ResponseEntity<StreamingResponseBody>>()
return when (val download = downloadService.getDownload(game.metadata.path, provider)) {
is FileDownload -> {
val responseBuilder = ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"${game.title}.${download.fileExtension}\"")
downloadExecutor.execute {
try {
val game = gameService.getById(gameId)
gameService.incrementDownloadCount(game)
val sessionId = request.session.id
val remoteIp = request.getRemoteIp(LookupPolicy.IPV4_PREFERRED)
responseBuilder.body(StreamingResponseBody { outputStream ->
downloadService.processDownload(
download.data,
outputStream,
game,
getCurrentAuth()?.name,
sessionId,
remoteIp
)
})
}
val result = when (val download = downloadService.getDownload(game.metadata.path, provider)) {
is FileDownload -> {
val responseBuilder = ResponseEntity.ok()
.header(
"Content-Disposition",
"attachment; filename=\"${game.title}.${download.fileExtension}\""
)
is LinkDownload -> {
TODO("Handle download link")
responseBuilder.body(StreamingResponseBody { outputStream ->
downloadService.processDownload(
download.data,
outputStream,
game,
getCurrentAuth()?.name,
sessionId,
remoteIp
)
})
}
is LinkDownload -> {
TODO("Handle download link")
}
}
deferredResult.setResult(result)
} catch (e: Exception) {
deferredResult.setErrorResult(e)
}
}
return deferredResult
}
}

View File

@ -0,0 +1,45 @@
package org.gameyfin.app.core.security
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
/**
* Controller to handle login redirects properly for both SSO and direct login.
* This replaces the previous hack of using a non-existent endpoint that returns 401.
*/
@Controller
class LoginRedirectController(
private val config: ConfigService
) {
@GetMapping("/loginredirect")
fun loginRedirect(request: HttpServletRequest, response: HttpServletResponse) {
val continueParam = request.getParameter("continue")
val directParam = request.getParameter("direct")
// Check if SSO is enabled
val isSsoEnabled = config.get(ConfigProperties.SSO.OIDC.Enabled) == true
if (isSsoEnabled && directParam != "1") {
// Redirect to SSO provider with continue parameter if present
val ssoUrl = "/oauth2/authorization/${SecurityConfig.SSO_PROVIDER_KEY}"
if (!continueParam.isNullOrBlank()) {
response.sendRedirect("$ssoUrl?continue=$continueParam")
} else {
response.sendRedirect(ssoUrl)
}
} else {
// Redirect to direct login page with continue parameter if present
if (!continueParam.isNullOrBlank()) {
response.sendRedirect("/login?continue=$continueParam")
} else {
response.sendRedirect("/login")
}
}
}
}

View File

@ -47,6 +47,7 @@ class SecurityConfig(
// Gameyfin static resources and public endpoints
.requestMatchers(
"/login",
"/loginredirect",
"/setup",
"/reset-password",
"/accept-invitation",
@ -85,7 +86,9 @@ class SecurityConfig(
}
// Use custom success handler to handle user registration
http.oauth2Login { oauth2Login -> oauth2Login.successHandler(ssoAuthenticationSuccessHandler) }
http.oauth2Login { oauth2Login ->
oauth2Login.successHandler(ssoAuthenticationSuccessHandler)
}
// Prevent unnecessary redirects
http.logout { logout -> logout.logoutSuccessHandler((HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))) }

View File

@ -81,7 +81,15 @@ class SsoAuthenticationSuccessHandler(
UsernamePasswordAuthenticationToken(authentication.principal, authentication.credentials, mappedAuthorities)
SecurityContextHolder.getContext().authentication = newAuth
response.sendRedirect("/")
// Get the continue parameter from the request to redirect back to the original page
val continueUrl = request.getParameter("continue")
val redirectUrl = if (!continueUrl.isNullOrBlank() && continueUrl.startsWith("/")) {
continueUrl
} else {
"/"
}
response.sendRedirect(redirectUrl)
return
}
}

View File

@ -17,6 +17,10 @@ server:
tracking-modes: cookie
timeout: 24h
forward-headers-strategy: framework
tomcat:
remoteip:
protocol-header: X-Forwarded-Proto
remote-ip-header: X-Forwarded-For
management:
server:

View File

@ -12,13 +12,14 @@ import org.gameyfin.pluginapi.download.FileDownload
import org.gameyfin.pluginapi.download.LinkDownload
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus
import org.springframework.web.context.request.async.DeferredResult
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
@ -51,6 +52,35 @@ class DownloadEndpointTest {
clearAllMocks()
}
/**
* Helper method to wait for DeferredResult to complete and get the result.
* Handles async processing with timeout.
*/
private fun <T> awaitDeferredResult(deferredResult: DeferredResult<T>, timeoutSeconds: Long = 5): T {
val latch = CountDownLatch(1)
var result: T? = null
var error: Throwable? = null
deferredResult.setResultHandler { value ->
@Suppress("UNCHECKED_CAST")
result = value as T
latch.countDown()
}
deferredResult.onError { throwable ->
error = throwable
latch.countDown()
}
val completed = latch.await(timeoutSeconds, TimeUnit.SECONDS)
if (!completed) {
throw AssertionError("DeferredResult did not complete within $timeoutSeconds seconds")
}
error?.let { throw AssertionError("DeferredResult completed with error", it) }
return result ?: throw AssertionError("DeferredResult completed but result is null")
}
@Test
fun `downloadGame should return file download with correct headers`() {
val gameId = 1L
@ -73,13 +103,13 @@ class DownloadEndpointTest {
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
val response = endpoint.downloadGame(gameId, provider, request)
val deferredResult = endpoint.downloadGame(gameId, provider, request)
val response = awaitDeferredResult(deferredResult)
assertEquals(HttpStatus.OK, response.statusCode)
assertNotNull(response.body)
assertTrue(response.headers.containsKey("Content-Disposition"))
assertTrue(response.headers["Content-Disposition"]!![0].contains("Test Game.zip"))
// Content-Length may or may not be present depending on whether the path exists as a file
verify(exactly = 1) { gameService.getById(gameId) }
verify(exactly = 1) { gameService.incrementDownloadCount(game) }
@ -108,7 +138,8 @@ class DownloadEndpointTest {
every { downloadService.getDownload(dirPath, provider) } returns fileDownload
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
val response = endpoint.downloadGame(gameId, provider, request)
val deferredResult = endpoint.downloadGame(gameId, provider, request)
val response = awaitDeferredResult(deferredResult)
assertEquals(HttpStatus.OK, response.statusCode)
assertTrue(response.headers.containsKey("Content-Disposition"))
@ -136,7 +167,8 @@ class DownloadEndpointTest {
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
val response = endpoint.downloadGame(gameId, provider, request)
val deferredResult = endpoint.downloadGame(gameId, provider, request)
val response = awaitDeferredResult(deferredResult)
assertEquals(HttpStatus.OK, response.statusCode)
assertFalse(response.headers.containsKey("Content-Length"))
@ -162,7 +194,8 @@ class DownloadEndpointTest {
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
val response = endpoint.downloadGame(gameId, provider, request)
val deferredResult = endpoint.downloadGame(gameId, provider, request)
val response = awaitDeferredResult(deferredResult)
assertEquals(HttpStatus.OK, response.statusCode)
assertFalse(response.headers.containsKey("Content-Length"))
@ -191,7 +224,8 @@ class DownloadEndpointTest {
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
val response = endpoint.downloadGame(gameId, provider, request)
val deferredResult = endpoint.downloadGame(gameId, provider, request)
val response = awaitDeferredResult(deferredResult)
assertEquals(HttpStatus.OK, response.statusCode)
@ -231,7 +265,8 @@ class DownloadEndpointTest {
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
val response = endpoint.downloadGame(gameId, provider, request)
val deferredResult = endpoint.downloadGame(gameId, provider, request)
val response = awaitDeferredResult(deferredResult)
assertEquals(HttpStatus.OK, response.statusCode)
@ -270,7 +305,8 @@ class DownloadEndpointTest {
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
endpoint.downloadGame(gameId, provider, request)
val deferredResult = endpoint.downloadGame(gameId, provider, request)
awaitDeferredResult(deferredResult)
verify(exactly = 1) { gameService.incrementDownloadCount(game) }
}
@ -293,8 +329,10 @@ class DownloadEndpointTest {
every { gameService.incrementDownloadCount(game) } just Runs
every { downloadService.getDownload(gamePath, provider) } returns linkDownload
assertThrows(NotImplementedError::class.java) {
endpoint.downloadGame(gameId, provider, request)
val deferredResult = endpoint.downloadGame(gameId, provider, request)
assertThrows(AssertionError::class.java) {
awaitDeferredResult(deferredResult)
}
}
@ -318,7 +356,8 @@ class DownloadEndpointTest {
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
val response = endpoint.downloadGame(gameId, provider, request)
val deferredResult = endpoint.downloadGame(gameId, provider, request)
val response = awaitDeferredResult(deferredResult)
assertEquals(HttpStatus.OK, response.statusCode)
val contentDisposition = response.headers["Content-Disposition"]!![0]
@ -350,7 +389,8 @@ class DownloadEndpointTest {
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
val response = endpoint.downloadGame(gameId, provider, request)
val deferredResult = endpoint.downloadGame(gameId, provider, request)
val response = awaitDeferredResult(deferredResult)
val contentDisposition = response.headers["Content-Disposition"]!![0]
assertTrue(
@ -360,18 +400,6 @@ class DownloadEndpointTest {
}
}
@Test
fun `downloadGame should propagate service exceptions`() {
val gameId = 1L
val provider = "TestProvider"
every { gameService.getById(gameId) } throws RuntimeException("Game not found")
assertThrows(RuntimeException::class.java) {
endpoint.downloadGame(gameId, provider, request)
}
}
@Test
fun `downloadGame should handle session without id`() {
val gameId = 1L
@ -394,7 +422,8 @@ class DownloadEndpointTest {
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
val response = endpoint.downloadGame(gameId, provider, request)
val deferredResult = endpoint.downloadGame(gameId, provider, request)
val response = awaitDeferredResult(deferredResult)
assertEquals(HttpStatus.OK, response.statusCode)
}

View File

@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
import java.nio.file.Files
group = "org.gameyfin"
version = "2.3.1"
version = "2.3.2-preview"
allprojects {
repositories {