Release 2.3.3 (#839)

* chore: bump version to v2.3.3-preview

* Optimiziation for multiple parallel and long-running downloads (#838)

* Add missing Content-Type header to downloads (#837)

Conditionally add Content-Length header to downloads
Only calculate fileSize if file is not a directory in DirectDownloadPlugin

* Update dependencies, add Google Guava

* Refactor and optimize download bandwidth monitoring and throttling

* Update .jar layer extraction command in Dockerfile

* Fix Dockerfile.ubuntu

* Furhter performance and tracking improvements for downloads

* Fix tests

* Update HeroUI version

* Encode filenames in Content-Disposition header according to RFC 6266 (#841)

* Encode filenames in Content-Disposition header with UTF-8 according to RFC 6266

* Fix tests
This commit is contained in:
Simon 2025-12-22 11:34:39 +01:00 committed by GitHub
parent abc12f146b
commit 005a1611ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 5148 additions and 1144 deletions

View File

@ -59,6 +59,7 @@ dependencies {
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17")
implementation("org.flywaydb:flyway-core")
implementation("commons-io:commons-io:2.18.0")
implementation("com.google.guava:guava:33.5.0-jre")
// SSO
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
@ -67,7 +68,7 @@ dependencies {
// Notifications
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("ch.digitalfondue.mjml4j:mjml4j:1.0.3")
implementation("ch.digitalfondue.mjml4j:mjml4j:1.1.4")
// Plugins
implementation(project(":plugin-api"))

5806
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
{
"name": "gameyfin",
"version": "2.3.2",
"version": "2.3.3-preview",
"type": "module",
"dependencies": {
"@heroui/react": "^2.8.5",
"@phosphor-icons/react": "^2.1.7",
"@heroui/react": "^2.8.7",
"@phosphor-icons/react": "^2.1.10",
"@polymer/polymer": "3.5.2",
"@react-stately/data": "^3.12.2",
"@react-types/shared": "^3.28.0",
@ -267,6 +267,6 @@
"workbox-precaching": "7.3.0"
},
"disableUsageStatistics": true,
"hash": "d06c4b56ae3a7bc3c4356d3669fc1ed559d83e5285df4e8b3e99bff46869f939"
"hash": "760523c518e07bbe0567ae5d1b281ccf90326b285b5feb3c0f269c52ec774f88"
}
}
}

View File

@ -1,28 +1,52 @@
package org.gameyfin.app.core.download.bandwidth
import com.google.common.util.concurrent.RateLimiter
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.LockSupport
import java.util.concurrent.atomic.AtomicLong
/**
* Tracks bandwidth usage for a single session across all their downloads.
* Thread-safe for concurrent downloads.
* Thread-safe for concurrent downloads using Google Guava's RateLimiter.
*/
@Suppress("UnstableApiUsage")
class SessionBandwidthTracker(
val sessionId: String,
@Volatile private var maxBytesPerSecond: Long
) {
// Guava RateLimiter for thread-safe bandwidth throttling
// Only created when bandwidth limiting is enabled (maxBytesPerSecond > 0)
private var rateLimiter: RateLimiter? = if (maxBytesPerSecond > 0) {
RateLimiter.create(maxBytesPerSecond.toDouble())
} else {
null
}
// Total bytes transferred for the lifetime of this session (for UI display)
@Volatile
var totalBytesTransferred: Long = 0
private set
private val totalBytesTransferredAtomic = AtomicLong(0)
var totalBytesTransferred: Long
get() = totalBytesTransferredAtomic.get()
private set(value) {
totalBytesTransferredAtomic.set(value)
}
// Bytes used for throttling calculation (resets when all downloads complete)
@Volatile
private var bytesWritten: Long = 0
// For monitoring: bytes written in the current measurement window (lock-free)
private val bytesWrittenAtomic = AtomicLong(0)
// For monitoring: start time of the current measurement window
@Volatile
private var monitoringWindowStart: Long = System.nanoTime()
// For smoothing the monitoring window transitions
private val previousWindowBytesAtomic = AtomicLong(0)
@Volatile
private var previousWindowStart: Long = System.nanoTime()
@Volatile
private var previousWindowEnd: Long = System.nanoTime()
// Timestamp of when the session first started (for UI display only)
@Volatile
var startTime: Long = System.nanoTime()
private set
@ -33,6 +57,10 @@ class SessionBandwidthTracker(
val activeDownloads = AtomicInteger(0)
// Maximum monitoring window duration before resetting statistics (10 seconds)
private val monitoringWindowNanos = 10_000_000_000L
@Volatile
var username: String? = null
private set
@ -54,6 +82,18 @@ class SessionBandwidthTracker(
*/
fun updateLimit(newLimit: Long) {
maxBytesPerSecond = newLimit
if (newLimit > 0) {
// Create or update RateLimiter
val limiter = rateLimiter
if (limiter != null) {
limiter.rate = newLimit.toDouble()
} else {
rateLimiter = RateLimiter.create(newLimit.toDouble())
}
} else {
// Unlimited bandwidth - don't need RateLimiter
rateLimiter = null
}
}
/**
@ -121,89 +161,96 @@ class SessionBandwidthTracker(
// Add new measurement at the front
bandwidthHistory.addLast(currentRate)
// Remove oldest measurement if we exceed the max size
// Remove the oldest measurement if we exceed the max size
if (bandwidthHistory.size > maxHistorySize) {
bandwidthHistory.removeFirst()
}
}
/**
* Record bytes written without throttling (used for monitoring-only mode)
* Update monitoring statistics for bytes transferred.
* This is lock-free for maximum performance during high-bandwidth transfers.
* Uses a sliding window approach to avoid hard resets every 10 seconds.
*/
@Synchronized
fun recordBytes(bytes: Long) {
// If this is the first write after being idle, reset the timer
if (bytesWritten == 0L) {
startTime = System.nanoTime()
private fun updateMonitoringStatistics(bytes: Long) {
val currentTime = System.nanoTime()
// Check if we need to rotate monitoring window (lock-free check, occasional race is acceptable)
val monitoringElapsed = currentTime - monitoringWindowStart
if (monitoringElapsed > monitoringWindowNanos) {
// Use synchronized only for the rotation operation (infrequent)
synchronized(this) {
// Double-check after acquiring lock
val elapsed = currentTime - monitoringWindowStart
if (elapsed > monitoringWindowNanos) {
// Rotate windows: current -> previous, then reset current
previousWindowBytesAtomic.set(bytesWrittenAtomic.get())
previousWindowStart = monitoringWindowStart
previousWindowEnd = currentTime
bytesWrittenAtomic.set(0)
monitoringWindowStart = currentTime
}
}
}
bytesWritten += bytes
totalBytesTransferred += bytes
lastActivityTime = System.nanoTime()
// Lock-free atomic operations for high-performance byte counting
bytesWrittenAtomic.addAndGet(bytes)
totalBytesTransferredAtomic.addAndGet(bytes)
lastActivityTime = currentTime
}
/**
* Record bytes written without throttling (used for monitoring-only mode).
*/
fun recordBytes(bytes: Long) {
updateMonitoringStatistics(bytes)
}
/**
* Throttle the current thread based on session-wide bandwidth usage.
* This is called by each download stream, but they all share the same bandwidth quota.
* Uses Guava's RateLimiter which is thread-safe and implements a token bucket algorithm.
*/
@Synchronized
fun throttle(bytes: Long) {
// Skip throttling if no limit is set (0 or negative means unlimited)
if (maxBytesPerSecond <= 0) {
// If this is the first write after being idle, reset the timer
if (bytesWritten == 0L) {
startTime = System.nanoTime()
}
bytesWritten += bytes
totalBytesTransferred += bytes
lastActivityTime = System.nanoTime()
return
}
updateMonitoringStatistics(bytes)
// If this is the first write after being idle, reset the timer
if (bytesWritten == 0L) {
startTime = System.nanoTime()
}
bytesWritten += bytes
totalBytesTransferred += bytes
// Calculate elapsed time BEFORE updating lastActivityTime
val currentTime = System.nanoTime()
val elapsedNanos = currentTime - startTime
val elapsedSeconds = elapsedNanos / 1_000_000_000.0
// Calculate how many bytes we should have written by now
val expectedBytes = (elapsedSeconds * maxBytesPerSecond).toLong()
// If we've written more than expected, sleep to catch up
if (bytesWritten > expectedBytes) {
val bytesAhead = bytesWritten - expectedBytes
val sleepTimeNanos = (bytesAhead * 1_000_000_000.0 / maxBytesPerSecond).toLong()
if (sleepTimeNanos > 0) {
// Use LockSupport.parkNanos for virtual thread compatibility
LockSupport.parkNanos(sleepTimeNanos)
// Check if interrupted
if (Thread.interrupted()) {
Thread.currentThread().interrupt()
}
}
}
// Update last activity time after throttling
lastActivityTime = System.nanoTime()
// Only throttle if RateLimiter exists (bandwidth limit is set)
rateLimiter?.acquire(bytes.toInt())
}
/**
* Get current transfer rate in bytes per second
* Get current transfer rate in bytes per second based on monitoring window.
* Uses a sliding window approach that smoothly transitions between measurement periods.
*/
fun getCurrentBytesPerSecond(): Long {
val elapsedNanos = System.nanoTime() - startTime
val elapsedSeconds = elapsedNanos / 1_000_000_000.0
return if (elapsedSeconds > 0) {
(bytesWritten / elapsedSeconds).toLong()
val currentTime = System.nanoTime()
val currentWindowElapsed = currentTime - monitoringWindowStart
val currentWindowSeconds = currentWindowElapsed / 1_000_000_000.0
// If current window is very young (< 1 second), blend with previous window for stability
if (currentWindowSeconds < 1.0 && previousWindowEnd > previousWindowStart) {
val previousWindowDuration = (previousWindowEnd - previousWindowStart) / 1_000_000_000.0
val previousRate = if (previousWindowDuration > 0) {
previousWindowBytesAtomic.get() / previousWindowDuration
} else {
0.0
}
val currentRate = if (currentWindowSeconds > 0) {
bytesWrittenAtomic.get() / currentWindowSeconds
} else {
0.0
}
// Weighted blend: newer window gets more weight as it ages
val weight = currentWindowSeconds // 0.0 to 1.0 over first second
return ((previousRate * (1.0 - weight)) + (currentRate * weight)).toLong()
}
// Normal case: current window is mature enough
return if (currentWindowSeconds > 0) {
(bytesWrittenAtomic.get() / currentWindowSeconds).toLong()
} else {
0L
}
@ -211,10 +258,11 @@ class SessionBandwidthTracker(
/**
* Reset the tracker (useful if we want to restart bandwidth calculation)
* Note: This only resets the throttling calculation, not the total bytes transferred
* Note: This only resets the monitoring calculation, not the total bytes transferred
*/
fun reset() {
bytesWritten = 0
bytesWrittenAtomic.set(0)
monitoringWindowStart = System.nanoTime()
startTime = System.nanoTime()
lastActivityTime = System.nanoTime()
// totalBytesTransferred is intentionally NOT reset - we want to keep this for UI display

View File

@ -4,7 +4,7 @@ import java.io.OutputStream
/**
* An OutputStream wrapper that tracks bandwidth usage without throttling.
* Used when bandwidth limiting is disabled but we still want real-time statistics.
* Used when bandwidth limiting is disabled, but we still want real-time statistics.
*
* @param outputStream The underlying output stream to write to
* @param sessionTracker The session-wide bandwidth tracker

View File

@ -20,9 +20,6 @@ class SessionThrottledOutputStream(
private val remoteIp: String? = null
) : OutputStream() {
// Buffer size for optimal I/O performance
private val optimalBufferSize = 64 * 1024
init {
sessionTracker.downloadStarted(gameId, username, remoteIp)
}
@ -37,17 +34,10 @@ class SessionThrottledOutputStream(
}
override fun write(b: ByteArray, off: Int, len: Int) {
// Write in chunks to maintain accurate throttling across concurrent downloads
var remaining = len
var offset = off
while (remaining > 0) {
val chunkSize = minOf(remaining, optimalBufferSize)
sessionTracker.throttle(chunkSize.toLong())
outputStream.write(b, offset, chunkSize)
remaining -= chunkSize
offset += chunkSize
}
// Throttle first, then write - this provides smoother bandwidth control
// by acquiring permits before the actual write operation
sessionTracker.throttle(len.toLong())
outputStream.write(b, off, len)
}
override fun flush() {

View File

@ -13,6 +13,8 @@ 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.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.concurrent.Executor
import java.util.concurrent.Executors
@ -44,11 +46,29 @@ class DownloadEndpoint(
val result = when (val download = downloadService.getDownload(game.metadata.path, provider)) {
is FileDownload -> {
val baseFilename = game.title?.replace("[\\\\/:*?\"<>|]".toRegex(), "") // Remove common invalid filename chars
?: "download"
val filename = if (download.fileExtension != null) {
"$baseFilename.${download.fileExtension}"
} else {
baseFilename
}
val responseBuilder = ResponseEntity.ok()
.header(
"Content-Disposition",
"attachment; filename=\"${game.title}.${download.fileExtension}\""
createContentDispositionHeader(filename)
)
.header(
"Content-Type",
"application/octet-stream"
)
val downloadSize = download.size
if(downloadSize != null) {
responseBuilder.contentLength(downloadSize)
}
responseBuilder.body(StreamingResponseBody { outputStream ->
downloadService.processDownload(
@ -75,4 +95,51 @@ class DownloadEndpoint(
return deferredResult
}
/**
* Converts a string to a safe ASCII fallback filename by replacing non-ASCII characters.
* Characters with code points > 127 and common invalid chars for filenames are removed, and if the result is empty or only whitespace,
* returns "download" as a fallback.
*/
private fun String.safeDownloadFileName(): String {
val asciiOnly = filter { it.code in 0..255 } // Printable ASCII only
.trim()
return asciiOnly.ifBlank { "download" }
}
/**
* URL-encodes a string according to RFC 5987.
*/
private fun String.encodeRfc5987(): String {
return URLEncoder.encode(this, StandardCharsets.UTF_8)
.replace("+", "%20") // URLEncoder uses + for space, but RFC 5987 requires %20
}
/**
* Creates a Content-Disposition header value with both ASCII fallback and RFC 5987 Unicode support.
*
* Example output:
* attachment; filename="Game_Title.zip"; filename*=UTF-8''Game%E2%84%A2%20Title.zip
*
* @param filename The original filename (may contain Unicode characters)
* @param disposition The disposition type (default: "attachment")
* @return A properly formatted Content-Disposition header value
*/
private fun createContentDispositionHeader(filename: String, disposition: String = "attachment"): String {
val asciiFallback = filename.safeDownloadFileName()
val encodedFilename = filename.encodeRfc5987()
return buildString {
append(disposition)
append("; filename=\"")
append(asciiFallback)
append("\"")
// Only add filename* if there are non-ASCII characters
if (filename != asciiFallback) {
append("; filename*=utf-8''")
append(encodedFilename)
}
}
}
}

View File

@ -187,16 +187,20 @@ class SessionBandwidthTrackerTest {
val maxBytesPerSecond = 1_000L
tracker = SessionBandwidthTracker("test-session", maxBytesPerSecond)
// Need to use the rate limiter more so first request doesn't use burst
tracker.throttle(1_000) // Use up initial burst
val thread = Thread {
tracker.throttle(10_000) // This should trigger throttling
tracker.throttle(10_000) // This should trigger throttling for ~10 seconds
}
thread.start()
Thread.sleep(50)
Thread.sleep(100)
thread.interrupt()
thread.join(1000)
thread.join(2000)
assertTrue(thread.isInterrupted)
// Thread should have completed (either via interruption or normal completion)
assertFalse(thread.isAlive)
}
@Test
@ -205,14 +209,21 @@ class SessionBandwidthTrackerTest {
tracker = SessionBandwidthTracker("test-session", maxBytesPerSecond)
val startTime = System.nanoTime()
tracker.throttle(50_000) // Write half of limit
tracker.throttle(50_000) // Write half of limit - RateLimiter allows first burst immediately
val elapsedNanos = System.nanoTime() - startTime
// Should take approximately 0.5 seconds (50KB at 100KB/s)
// Allow some margin for timing precision
// RateLimiter allows the first request to go through immediately (burst)
// So this should complete very quickly
val elapsedSeconds = elapsedNanos / 1_000_000_000.0
assertTrue(elapsedSeconds >= 0.4, "Expected at least 0.4 seconds but was $elapsedSeconds")
assertTrue(elapsedSeconds < 0.7, "Expected less than 0.7 seconds but was $elapsedSeconds")
assertTrue(elapsedSeconds < 0.1, "Expected less than 0.1 seconds but was $elapsedSeconds")
// However, the second request should be throttled
val startTime2 = System.nanoTime()
tracker.throttle(50_000) // Another 50KB - this will be throttled
val elapsedNanos2 = System.nanoTime() - startTime2
val elapsedSeconds2 = elapsedNanos2 / 1_000_000_000.0
assertTrue(elapsedSeconds2 >= 0.4, "Expected at least 0.4 seconds but was $elapsedSeconds2")
assertTrue(elapsedSeconds2 < 0.7, "Expected less than 0.7 seconds but was $elapsedSeconds2")
}
@Test

View File

@ -96,7 +96,7 @@ class SessionThrottledOutputStreamTest {
val data = "Hello World".toByteArray()
throttledStream.write(data)
// Should be called at least once (may be chunked)
// Should be called at least once
verify(atLeast = 1) { sessionTracker.throttle(any()) }
assertEquals("Hello World", underlyingOutputStream.toString())
}
@ -116,7 +116,7 @@ class SessionThrottledOutputStreamTest {
}
@Test
fun `write empty byte array should not throttle`() {
fun `write empty byte array should call throttle`() {
throttledStream = SessionThrottledOutputStream(
underlyingOutputStream,
sessionTracker
@ -124,12 +124,12 @@ class SessionThrottledOutputStreamTest {
throttledStream.write(byteArrayOf())
verify(exactly = 0) { sessionTracker.throttle(any()) }
verify(exactly = 1) { sessionTracker.throttle(0) }
assertEquals("", underlyingOutputStream.toString())
}
@Test
fun `write byte array with zero length should not throttle`() {
fun `write byte array with zero length should throttle with zero bytes`() {
throttledStream = SessionThrottledOutputStream(
underlyingOutputStream,
sessionTracker
@ -138,43 +138,24 @@ class SessionThrottledOutputStreamTest {
val data = "Hello".toByteArray()
throttledStream.write(data, 0, 0)
verify(exactly = 0) { sessionTracker.throttle(any()) }
verify(exactly = 1) { sessionTracker.throttle(0) }
assertEquals("", underlyingOutputStream.toString())
}
@Test
fun `write large byte array should be chunked`() {
fun `write should throttle exact byte count without chunking`() {
throttledStream = SessionThrottledOutputStream(
underlyingOutputStream,
sessionTracker
)
val data = ByteArray(200 * 1024) { it.toByte() } // 200 KB
// Write exactly 1024 KB
val data = ByteArray(1024 * 1024) { 0 }
throttledStream.write(data)
// With 64KB chunks, 200KB should require at least 3 chunks
verify(atLeast = 3) { sessionTracker.throttle(any()) }
assertEquals(200 * 1024, underlyingOutputStream.size())
}
@Test
fun `write should respect optimal buffer size for chunking`() {
throttledStream = SessionThrottledOutputStream(
underlyingOutputStream,
sessionTracker
)
// Write exactly 128 KB (2 * 64KB chunks)
val data = ByteArray(128 * 1024) { 0 }
throttledStream.write(data)
// Should be throttled in at least 2 chunks
verify(atLeast = 2) { sessionTracker.throttle(any()) }
// Each chunk should be <= 64KB
val slots = mutableListOf<Long>()
verify(atLeast = 2) { sessionTracker.throttle(capture(slots)) }
assertTrue(slots.all { it <= 64 * 1024 })
// Should be throttled exactly once for the entire write
verify(exactly = 1) { sessionTracker.throttle(1024L * 1024) }
assertEquals(1024 * 1024, underlyingOutputStream.size())
}
@Test
@ -251,8 +232,23 @@ class SessionThrottledOutputStreamTest {
@Test
fun `should work with real tracker and throttle bandwidth`() {
val bytesPerSecond = 10_000L // 10 KB/s
val bytesPerSecond = 100_000L // 100 KB/s
val realTracker = SessionBandwidthTracker("test-session", bytesPerSecond)
// Consume the initial burst from RateLimiter (RateLimiter allows up to 1 second of burst)
// We need to consume more than the burst capacity before throttling kicks in
val burstData = ByteArray(200_000) { 0 } // 200 KB = 2 seconds worth at 100 KB/s
val burstStream = SessionThrottledOutputStream(
ByteArrayOutputStream(),
realTracker,
gameId = 999L,
username = "testuser",
remoteIp = "127.0.0.1"
)
burstStream.write(burstData)
burstStream.close()
// Now create the actual test stream - this should be properly throttled
throttledStream = SessionThrottledOutputStream(
underlyingOutputStream,
realTracker,
@ -264,14 +260,14 @@ class SessionThrottledOutputStreamTest {
assertEquals(1, realTracker.activeDownloads.get())
val startTime = System.nanoTime()
val data = ByteArray(20_000) { it.toByte() } // 20 KB
val data = ByteArray(200_000) { it.toByte() } // 200 KB
throttledStream.write(data)
val elapsed = (System.nanoTime() - startTime) / 1_000_000_000.0
// Should take at least 1.5 seconds to transfer 20 KB at 10 KB/s
// Using 1.3 to account for timing variations
assertTrue(elapsed >= 1.3, "Expected at least 1.3 seconds but was $elapsed")
assertEquals(20_000L, realTracker.totalBytesTransferred)
// Should take at least 1.8 seconds to transfer 200 KB at 100 KB/s
// Using 1.7 to account for timing variations
assertTrue(elapsed >= 1.7, "Expected at least 1.7 seconds but was $elapsed")
assertEquals(400_000L, realTracker.totalBytesTransferred)
throttledStream.close()
assertEquals(0, realTracker.activeDownloads.get())
@ -368,27 +364,6 @@ class SessionThrottledOutputStreamTest {
verify(exactly = 3) { sessionTracker.throttle(1) }
}
@Test
fun `should chunk large writes correctly`() {
throttledStream = SessionThrottledOutputStream(
underlyingOutputStream,
sessionTracker
)
// Write exactly 3 chunks worth (192 KB)
val data = ByteArray(192 * 1024) { 0 }
throttledStream.write(data)
val capturedSizes = mutableListOf<Long>()
verify(atLeast = 3) { sessionTracker.throttle(capture(capturedSizes)) }
// Verify total bytes throttled equals data size
assertEquals(data.size.toLong(), capturedSizes.sum())
// Verify all chunks are <= 64KB
assertTrue(capturedSizes.all { it <= 64 * 1024 })
}
@Test
fun `should handle small writes without chunking`() {
throttledStream = SessionThrottledOutputStream(
@ -410,15 +385,13 @@ class SessionThrottledOutputStreamTest {
sessionTracker
)
// Write large data with offset/length that spans multiple chunks
val data = ByteArray(200 * 1024) { it.toByte() }
throttledStream.write(data, 10000, 150000)
// Write large data with offset/length
val data = ByteArray(1200 * 1024) { it.toByte() }
throttledStream.write(data, 10000, 800000)
// Should write exactly 150000 bytes
val capturedSizes = mutableListOf<Long>()
verify(atLeast = 2) { sessionTracker.throttle(capture(capturedSizes)) }
assertEquals(150000L, capturedSizes.sum())
assertEquals(150000, underlyingOutputStream.size())
// Should throttle exactly once for the specified length
verify(exactly = 1) { sessionTracker.throttle(800000L) }
assertEquals(800000, underlyingOutputStream.size())
}
@Test
@ -450,39 +423,8 @@ class SessionThrottledOutputStreamTest {
throttledStream.write("EF".toByteArray())
assertEquals("ABCDEF", underlyingOutputStream.toString())
verify(atLeast = 4) { sessionTracker.throttle(any()) }
}
@Test
fun `should handle exact chunk boundary`() {
throttledStream = SessionThrottledOutputStream(
underlyingOutputStream,
sessionTracker
)
// Write exactly 64KB (one chunk)
val data = ByteArray(64 * 1024) { 0 }
throttledStream.write(data)
verify(exactly = 1) { sessionTracker.throttle(64L * 1024) }
assertEquals(64 * 1024, underlyingOutputStream.size())
}
@Test
fun `should handle chunk boundary plus one byte`() {
throttledStream = SessionThrottledOutputStream(
underlyingOutputStream,
sessionTracker
)
// Write 64KB + 1 byte (should be 2 chunks)
val data = ByteArray(64 * 1024 + 1) { 0 }
throttledStream.write(data)
val capturedSizes = mutableListOf<Long>()
verify(exactly = 2) { sessionTracker.throttle(capture(capturedSizes)) }
assertEquals(64L * 1024, capturedSizes[0])
assertEquals(1L, capturedSizes[1])
verify(exactly = 2) { sessionTracker.throttle(1) } // Two single bytes
verify(exactly = 2) { sessionTracker.throttle(2) } // Two 2-byte arrays
}
}

View File

@ -337,7 +337,7 @@ class DownloadEndpointTest {
}
@Test
fun `downloadGame should handle special characters in filename`() {
fun `downloadGame should remove common invalid chars from filename`() {
val gameId = 1L
val provider = "TestProvider"
val gamePath = "/path/to/game"
@ -361,7 +361,7 @@ class DownloadEndpointTest {
assertEquals(HttpStatus.OK, response.statusCode)
val contentDisposition = response.headers["Content-Disposition"]!![0]
assertTrue(contentDisposition.contains("Test: Game (2024) [Edition].zip"))
assertTrue(contentDisposition.contains("Test Game (2024) [Edition].zip")) // ":" should be removed since most filesystems don't allow it
}
@Test

View File

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

View File

@ -4,7 +4,7 @@ FROM eclipse-temurin:21-jre-alpine as builder
WORKDIR /opt/gameyfin
ARG JAR_FILE=./app/build/libs/app.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract
RUN java -Djarmode=tools -jar application.jar extract --layers --launcher --destination extracted
# Pre-collect plugin JARs so final stage can copy them in a single layer
COPY --link ./plugins/ /tmp/plugins/
@ -50,10 +50,10 @@ RUN groupadd -g "$GID" "$USER" && \
COPY --link --chown=${UID}:${GID} --chmod=0755 ./docker/entrypoint.ubuntu.sh /entrypoint.sh
# Copy application layers and plugin jars from builder stage
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/dependencies/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/spring-boot-loader/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/snapshot-dependencies/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/application/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/extracted/dependencies/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/extracted/spring-boot-loader/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/extracted/snapshot-dependencies/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/extracted/application/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/plugins ./plugins
EXPOSE 8080

View File

@ -3,14 +3,15 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
# Plugin versions
# Dependency versions
kotlinVersion=2.2.20
kspVersion=2.2.20-2.0.3
vaadinVersion=24.9.4
springBootVersion=3.5.6
springCloudVersion=2025.0.0
springDependencyManagementVersion=1.1.7
# Dependency versions
guavaVersion=33.5.0-jre
# Plugin dependency versions
pf4jVersion=3.13.0
pf4jKspVersion=2.2.20-1.0.3
# Test framework versions

View File

@ -51,7 +51,9 @@ class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(
return FileDownload(
data = streamContentAsSingleFile(path),
fileExtension = if (path.isDirectory()) "zip" else path.extension,
size = path.fileSize()
size = path.isDirectory().let {
if (it) null else path.fileSize()
}
)
}

View File

@ -1,4 +1,4 @@
Plugin-Version: 1.0.1
Plugin-Version: 1.0.2
Plugin-Class: org.gameyfin.plugins.download.direct.DirectDownloadPlugin
Plugin-Id: org.gameyfin.plugins.download.direct
Plugin-Name: Direct Download