mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
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:
@@ -34,7 +34,7 @@ jobs:
|
|||||||
report_paths: '**/build/test-results/test/TEST-*.xml'
|
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||||
|
|
||||||
- name: Upload build outputs
|
- name: Upload build outputs
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: build-outputs
|
name: build-outputs
|
||||||
path: |
|
path: |
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download build outputs
|
- name: Download build outputs
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: build-outputs
|
name: build-outputs
|
||||||
path: .
|
path: .
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
report_paths: '**/build/test-results/test/TEST-*.xml'
|
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||||
|
|
||||||
- name: Upload build outputs
|
- name: Upload build outputs
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: build-outputs
|
name: build-outputs
|
||||||
path: |
|
path: |
|
||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download build outputs
|
- name: Download build outputs
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: build-outputs
|
name: build-outputs
|
||||||
path: .
|
path: .
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
jq ".version = \"$RELEASE_VERSION\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
|
jq ".version = \"$RELEASE_VERSION\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
|
||||||
|
|
||||||
- name: Upload modified files
|
- name: Upload modified files
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: modified-files
|
name: modified-files
|
||||||
path: |
|
path: |
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download modified files
|
- name: Download modified files
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: modified-files
|
name: modified-files
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ jobs:
|
|||||||
report_paths: '**/build/test-results/test/TEST-*.xml'
|
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||||
|
|
||||||
- name: Upload build outputs
|
- name: Upload build outputs
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: build-outputs
|
name: build-outputs
|
||||||
path: |
|
path: |
|
||||||
@@ -114,12 +114,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download modified files
|
- name: Download modified files
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: modified-files
|
name: modified-files
|
||||||
|
|
||||||
- name: Download build outputs
|
- name: Download build outputs
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: build-outputs
|
name: build-outputs
|
||||||
path: .
|
path: .
|
||||||
@@ -158,7 +158,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download modified files
|
- name: Download modified files
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: modified-files
|
name: modified-files
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download modified files
|
- name: Download modified files
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: modified-files
|
name: modified-files
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ const GameCoverComponent = ({game, size = 300, radius = "sm", interactive = fals
|
|||||||
const [isImageLoaded, setIsImageLoaded] = useState(isCached);
|
const [isImageLoaded, setIsImageLoaded] = useState(isCached);
|
||||||
const [blurhashUrl, setBlurhashUrl] = useState<string | undefined>(undefined);
|
const [blurhashUrl, setBlurhashUrl] = useState<string | undefined>(undefined);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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
|
// Generate blurhash placeholder image
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,9 +129,10 @@ const GameCoverComponent = ({game, size = 300, radius = "sm", interactive = fals
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Memoize the component to prevent unnecessary re-renders
|
// 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) => {
|
export const GameCover = memo(GameCoverComponent, (prevProps, nextProps) => {
|
||||||
return prevProps.game.id === nextProps.game.id &&
|
return prevProps.game.id === nextProps.game.id &&
|
||||||
|
prevProps.game.cover?.id === nextProps.game.cover?.id &&
|
||||||
prevProps.size === nextProps.size &&
|
prevProps.size === nextProps.size &&
|
||||||
prevProps.radius === nextProps.radius &&
|
prevProps.radius === nextProps.radius &&
|
||||||
prevProps.interactive === nextProps.interactive &&
|
prevProps.interactive === nextProps.interactive &&
|
||||||
|
|||||||
@@ -135,9 +135,6 @@ export default function MainLayout() {
|
|||||||
radius="full"
|
radius="full"
|
||||||
isIconOnly
|
isIconOnly
|
||||||
className="gradient-primary"
|
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"}>
|
onPress={() => window.location.href = "/loginredirect"}>
|
||||||
<SignInIcon fill="text-background/80"/>
|
<SignInIcon fill="text-background/80"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,7 +11,10 @@ import org.gameyfin.pluginapi.download.FileDownload
|
|||||||
import org.gameyfin.pluginapi.download.LinkDownload
|
import org.gameyfin.pluginapi.download.LinkDownload
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import org.springframework.web.context.request.async.DeferredResult
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/download")
|
@RequestMapping("/download")
|
||||||
@@ -19,40 +22,57 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
|
|||||||
@AnonymousAllowed
|
@AnonymousAllowed
|
||||||
class DownloadEndpoint(
|
class DownloadEndpoint(
|
||||||
private val downloadService: DownloadService,
|
private val downloadService: DownloadService,
|
||||||
private val gameService: GameService
|
private val gameService: GameService,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val downloadExecutor: Executor = Executors.newVirtualThreadPerTaskExecutor()
|
||||||
|
|
||||||
@GetMapping("/{gameId}")
|
@GetMapping("/{gameId}")
|
||||||
fun downloadGame(
|
fun downloadGame(
|
||||||
@PathVariable gameId: Long,
|
@PathVariable gameId: Long,
|
||||||
@RequestParam provider: String,
|
@RequestParam provider: String,
|
||||||
request: HttpServletRequest
|
request: HttpServletRequest
|
||||||
): ResponseEntity<StreamingResponseBody> {
|
): DeferredResult<ResponseEntity<StreamingResponseBody>> {
|
||||||
val game = gameService.getById(gameId)
|
val deferredResult = DeferredResult<ResponseEntity<StreamingResponseBody>>()
|
||||||
gameService.incrementDownloadCount(game)
|
|
||||||
val sessionId = request.session.id
|
|
||||||
val remoteIp = request.getRemoteIp(LookupPolicy.IPV4_PREFERRED)
|
|
||||||
|
|
||||||
return when (val download = downloadService.getDownload(game.metadata.path, provider)) {
|
downloadExecutor.execute {
|
||||||
is FileDownload -> {
|
try {
|
||||||
val responseBuilder = ResponseEntity.ok()
|
val game = gameService.getById(gameId)
|
||||||
.header("Content-Disposition", "attachment; filename=\"${game.title}.${download.fileExtension}\"")
|
gameService.incrementDownloadCount(game)
|
||||||
|
val sessionId = request.session.id
|
||||||
|
val remoteIp = request.getRemoteIp(LookupPolicy.IPV4_PREFERRED)
|
||||||
|
|
||||||
responseBuilder.body(StreamingResponseBody { outputStream ->
|
val result = when (val download = downloadService.getDownload(game.metadata.path, provider)) {
|
||||||
downloadService.processDownload(
|
is FileDownload -> {
|
||||||
download.data,
|
val responseBuilder = ResponseEntity.ok()
|
||||||
outputStream,
|
.header(
|
||||||
game,
|
"Content-Disposition",
|
||||||
getCurrentAuth()?.name,
|
"attachment; filename=\"${game.title}.${download.fileExtension}\""
|
||||||
sessionId,
|
)
|
||||||
remoteIp
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
is LinkDownload -> {
|
responseBuilder.body(StreamingResponseBody { outputStream ->
|
||||||
TODO("Handle download link")
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ class SecurityConfig(
|
|||||||
// Gameyfin static resources and public endpoints
|
// Gameyfin static resources and public endpoints
|
||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
"/login",
|
"/login",
|
||||||
|
"/loginredirect",
|
||||||
"/setup",
|
"/setup",
|
||||||
"/reset-password",
|
"/reset-password",
|
||||||
"/accept-invitation",
|
"/accept-invitation",
|
||||||
@@ -85,7 +86,9 @@ class SecurityConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use custom success handler to handle user registration
|
// Use custom success handler to handle user registration
|
||||||
http.oauth2Login { oauth2Login -> oauth2Login.successHandler(ssoAuthenticationSuccessHandler) }
|
http.oauth2Login { oauth2Login ->
|
||||||
|
oauth2Login.successHandler(ssoAuthenticationSuccessHandler)
|
||||||
|
}
|
||||||
// Prevent unnecessary redirects
|
// Prevent unnecessary redirects
|
||||||
http.logout { logout -> logout.logoutSuccessHandler((HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))) }
|
http.logout { logout -> logout.logoutSuccessHandler((HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))) }
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -81,7 +81,15 @@ class SsoAuthenticationSuccessHandler(
|
|||||||
UsernamePasswordAuthenticationToken(authentication.principal, authentication.credentials, mappedAuthorities)
|
UsernamePasswordAuthenticationToken(authentication.principal, authentication.credentials, mappedAuthorities)
|
||||||
SecurityContextHolder.getContext().authentication = newAuth
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,10 @@ server:
|
|||||||
tracking-modes: cookie
|
tracking-modes: cookie
|
||||||
timeout: 24h
|
timeout: 24h
|
||||||
forward-headers-strategy: framework
|
forward-headers-strategy: framework
|
||||||
|
tomcat:
|
||||||
|
remoteip:
|
||||||
|
protocol-header: X-Forwarded-Proto
|
||||||
|
remote-ip-header: X-Forwarded-For
|
||||||
|
|
||||||
management:
|
management:
|
||||||
server:
|
server:
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ import org.gameyfin.pluginapi.download.FileDownload
|
|||||||
import org.gameyfin.pluginapi.download.LinkDownload
|
import org.gameyfin.pluginapi.download.LinkDownload
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Assertions.assertThrows
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.web.context.request.async.DeferredResult
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
@@ -51,6 +52,35 @@ class DownloadEndpointTest {
|
|||||||
clearAllMocks()
|
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
|
@Test
|
||||||
fun `downloadGame should return file download with correct headers`() {
|
fun `downloadGame should return file download with correct headers`() {
|
||||||
val gameId = 1L
|
val gameId = 1L
|
||||||
@@ -73,13 +103,13 @@ class DownloadEndpointTest {
|
|||||||
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
||||||
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
|
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)
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
assertNotNull(response.body)
|
assertNotNull(response.body)
|
||||||
assertTrue(response.headers.containsKey("Content-Disposition"))
|
assertTrue(response.headers.containsKey("Content-Disposition"))
|
||||||
assertTrue(response.headers["Content-Disposition"]!![0].contains("Test Game.zip"))
|
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.getById(gameId) }
|
||||||
verify(exactly = 1) { gameService.incrementDownloadCount(game) }
|
verify(exactly = 1) { gameService.incrementDownloadCount(game) }
|
||||||
@@ -108,7 +138,8 @@ class DownloadEndpointTest {
|
|||||||
every { downloadService.getDownload(dirPath, provider) } returns fileDownload
|
every { downloadService.getDownload(dirPath, provider) } returns fileDownload
|
||||||
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
|
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)
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
assertTrue(response.headers.containsKey("Content-Disposition"))
|
assertTrue(response.headers.containsKey("Content-Disposition"))
|
||||||
@@ -136,7 +167,8 @@ class DownloadEndpointTest {
|
|||||||
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
||||||
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
|
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)
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
assertFalse(response.headers.containsKey("Content-Length"))
|
assertFalse(response.headers.containsKey("Content-Length"))
|
||||||
@@ -162,7 +194,8 @@ class DownloadEndpointTest {
|
|||||||
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
||||||
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
|
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)
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
assertFalse(response.headers.containsKey("Content-Length"))
|
assertFalse(response.headers.containsKey("Content-Length"))
|
||||||
@@ -191,7 +224,8 @@ class DownloadEndpointTest {
|
|||||||
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
||||||
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
|
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)
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
|
|
||||||
@@ -231,7 +265,8 @@ class DownloadEndpointTest {
|
|||||||
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
||||||
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
|
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)
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
|
|
||||||
@@ -270,7 +305,8 @@ class DownloadEndpointTest {
|
|||||||
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
||||||
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
|
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) }
|
verify(exactly = 1) { gameService.incrementDownloadCount(game) }
|
||||||
}
|
}
|
||||||
@@ -293,8 +329,10 @@ class DownloadEndpointTest {
|
|||||||
every { gameService.incrementDownloadCount(game) } just Runs
|
every { gameService.incrementDownloadCount(game) } just Runs
|
||||||
every { downloadService.getDownload(gamePath, provider) } returns linkDownload
|
every { downloadService.getDownload(gamePath, provider) } returns linkDownload
|
||||||
|
|
||||||
assertThrows(NotImplementedError::class.java) {
|
val deferredResult = endpoint.downloadGame(gameId, provider, request)
|
||||||
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.getDownload(gamePath, provider) } returns fileDownload
|
||||||
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
|
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)
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
val contentDisposition = response.headers["Content-Disposition"]!![0]
|
val contentDisposition = response.headers["Content-Disposition"]!![0]
|
||||||
@@ -350,7 +389,8 @@ class DownloadEndpointTest {
|
|||||||
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
||||||
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
|
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]
|
val contentDisposition = response.headers["Content-Disposition"]!![0]
|
||||||
assertTrue(
|
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
|
@Test
|
||||||
fun `downloadGame should handle session without id`() {
|
fun `downloadGame should handle session without id`() {
|
||||||
val gameId = 1L
|
val gameId = 1L
|
||||||
@@ -394,7 +422,8 @@ class DownloadEndpointTest {
|
|||||||
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
every { downloadService.getDownload(gamePath, provider) } returns fileDownload
|
||||||
every { downloadService.processDownload(any(), any(), any(), any(), any(), any()) } just Runs
|
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)
|
assertEquals(HttpStatus.OK, response.statusCode)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
|||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
|
||||||
group = "org.gameyfin"
|
group = "org.gameyfin"
|
||||||
version = "2.3.1"
|
version = "2.3.2-preview"
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
Reference in New Issue
Block a user