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
15 changed files with 5148 additions and 1144 deletions
+2 -1
View File
@@ -59,6 +59,7 @@ dependencies {
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17") implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17")
implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-core")
implementation("commons-io:commons-io:2.18.0") implementation("commons-io:commons-io:2.18.0")
implementation("com.google.guava:guava:33.5.0-jre")
// SSO // SSO
implementation("org.springframework.boot:spring-boot-starter-oauth2-client") implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
@@ -67,7 +68,7 @@ dependencies {
// Notifications // Notifications
implementation("org.springframework.boot:spring-boot-starter-mail") 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 // Plugins
implementation(project(":plugin-api")) implementation(project(":plugin-api"))
+4874 -932
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,10 +1,10 @@
{ {
"name": "gameyfin", "name": "gameyfin",
"version": "2.3.2", "version": "2.3.3-preview",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@heroui/react": "^2.8.5", "@heroui/react": "^2.8.7",
"@phosphor-icons/react": "^2.1.7", "@phosphor-icons/react": "^2.1.10",
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@react-stately/data": "^3.12.2", "@react-stately/data": "^3.12.2",
"@react-types/shared": "^3.28.0", "@react-types/shared": "^3.28.0",
@@ -267,6 +267,6 @@
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
}, },
"disableUsageStatistics": true, "disableUsageStatistics": true,
"hash": "d06c4b56ae3a7bc3c4356d3669fc1ed559d83e5285df4e8b3e99bff46869f939" "hash": "760523c518e07bbe0567ae5d1b281ccf90326b285b5feb3c0f269c52ec774f88"
} }
} }
@@ -1,28 +1,52 @@
package org.gameyfin.app.core.download.bandwidth package org.gameyfin.app.core.download.bandwidth
import com.google.common.util.concurrent.RateLimiter
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger 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. * 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( class SessionBandwidthTracker(
val sessionId: String, val sessionId: String,
@Volatile private var maxBytesPerSecond: Long @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) // Total bytes transferred for the lifetime of this session (for UI display)
@Volatile private val totalBytesTransferredAtomic = AtomicLong(0)
var totalBytesTransferred: Long = 0 var totalBytesTransferred: Long
private set get() = totalBytesTransferredAtomic.get()
private set(value) {
totalBytesTransferredAtomic.set(value)
}
// Bytes used for throttling calculation (resets when all downloads complete) // For monitoring: bytes written in the current measurement window (lock-free)
@Volatile private val bytesWrittenAtomic = AtomicLong(0)
private var bytesWritten: Long = 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 @Volatile
var startTime: Long = System.nanoTime() var startTime: Long = System.nanoTime()
private set private set
@@ -33,6 +57,10 @@ class SessionBandwidthTracker(
val activeDownloads = AtomicInteger(0) val activeDownloads = AtomicInteger(0)
// Maximum monitoring window duration before resetting statistics (10 seconds)
private val monitoringWindowNanos = 10_000_000_000L
@Volatile @Volatile
var username: String? = null var username: String? = null
private set private set
@@ -54,6 +82,18 @@ class SessionBandwidthTracker(
*/ */
fun updateLimit(newLimit: Long) { fun updateLimit(newLimit: Long) {
maxBytesPerSecond = newLimit 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 // Add new measurement at the front
bandwidthHistory.addLast(currentRate) 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) { if (bandwidthHistory.size > maxHistorySize) {
bandwidthHistory.removeFirst() 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 private fun updateMonitoringStatistics(bytes: Long) {
fun recordBytes(bytes: Long) { val currentTime = System.nanoTime()
// If this is the first write after being idle, reset the timer
if (bytesWritten == 0L) { // Check if we need to rotate monitoring window (lock-free check, occasional race is acceptable)
startTime = System.nanoTime() 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 // Lock-free atomic operations for high-performance byte counting
totalBytesTransferred += bytes bytesWrittenAtomic.addAndGet(bytes)
lastActivityTime = System.nanoTime() 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. * 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. * 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) { fun throttle(bytes: Long) {
// Skip throttling if no limit is set (0 or negative means unlimited) updateMonitoringStatistics(bytes)
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
}
// If this is the first write after being idle, reset the timer // Only throttle if RateLimiter exists (bandwidth limit is set)
if (bytesWritten == 0L) { rateLimiter?.acquire(bytes.toInt())
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()
} }
/** /**
* 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 { fun getCurrentBytesPerSecond(): Long {
val elapsedNanos = System.nanoTime() - startTime val currentTime = System.nanoTime()
val elapsedSeconds = elapsedNanos / 1_000_000_000.0 val currentWindowElapsed = currentTime - monitoringWindowStart
return if (elapsedSeconds > 0) { val currentWindowSeconds = currentWindowElapsed / 1_000_000_000.0
(bytesWritten / elapsedSeconds).toLong()
// 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 { } else {
0L 0L
} }
@@ -211,10 +258,11 @@ class SessionBandwidthTracker(
/** /**
* Reset the tracker (useful if we want to restart bandwidth calculation) * 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() { fun reset() {
bytesWritten = 0 bytesWrittenAtomic.set(0)
monitoringWindowStart = System.nanoTime()
startTime = System.nanoTime() startTime = System.nanoTime()
lastActivityTime = System.nanoTime() lastActivityTime = System.nanoTime()
// totalBytesTransferred is intentionally NOT reset - we want to keep this for UI display // totalBytesTransferred is intentionally NOT reset - we want to keep this for UI display
@@ -4,7 +4,7 @@ import java.io.OutputStream
/** /**
* An OutputStream wrapper that tracks bandwidth usage without throttling. * 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 outputStream The underlying output stream to write to
* @param sessionTracker The session-wide bandwidth tracker * @param sessionTracker The session-wide bandwidth tracker
@@ -20,9 +20,6 @@ class SessionThrottledOutputStream(
private val remoteIp: String? = null private val remoteIp: String? = null
) : OutputStream() { ) : OutputStream() {
// Buffer size for optimal I/O performance
private val optimalBufferSize = 64 * 1024
init { init {
sessionTracker.downloadStarted(gameId, username, remoteIp) sessionTracker.downloadStarted(gameId, username, remoteIp)
} }
@@ -37,17 +34,10 @@ class SessionThrottledOutputStream(
} }
override fun write(b: ByteArray, off: Int, len: Int) { override fun write(b: ByteArray, off: Int, len: Int) {
// Write in chunks to maintain accurate throttling across concurrent downloads // Throttle first, then write - this provides smoother bandwidth control
var remaining = len // by acquiring permits before the actual write operation
var offset = off sessionTracker.throttle(len.toLong())
outputStream.write(b, off, len)
while (remaining > 0) {
val chunkSize = minOf(remaining, optimalBufferSize)
sessionTracker.throttle(chunkSize.toLong())
outputStream.write(b, offset, chunkSize)
remaining -= chunkSize
offset += chunkSize
}
} }
override fun flush() { override fun flush() {
@@ -13,6 +13,8 @@ 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.context.request.async.DeferredResult
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody 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.Executor
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -44,11 +46,29 @@ class DownloadEndpoint(
val result = when (val download = downloadService.getDownload(game.metadata.path, provider)) { val result = when (val download = downloadService.getDownload(game.metadata.path, provider)) {
is FileDownload -> { 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() val responseBuilder = ResponseEntity.ok()
.header( .header(
"Content-Disposition", "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 -> responseBuilder.body(StreamingResponseBody { outputStream ->
downloadService.processDownload( downloadService.processDownload(
@@ -75,4 +95,51 @@ class DownloadEndpoint(
return deferredResult 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)
}
}
}
} }
@@ -187,16 +187,20 @@ class SessionBandwidthTrackerTest {
val maxBytesPerSecond = 1_000L val maxBytesPerSecond = 1_000L
tracker = SessionBandwidthTracker("test-session", maxBytesPerSecond) 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 { 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.start()
Thread.sleep(50) Thread.sleep(100)
thread.interrupt() 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 @Test
@@ -205,14 +209,21 @@ class SessionBandwidthTrackerTest {
tracker = SessionBandwidthTracker("test-session", maxBytesPerSecond) tracker = SessionBandwidthTracker("test-session", maxBytesPerSecond)
val startTime = System.nanoTime() 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 val elapsedNanos = System.nanoTime() - startTime
// Should take approximately 0.5 seconds (50KB at 100KB/s) // RateLimiter allows the first request to go through immediately (burst)
// Allow some margin for timing precision // So this should complete very quickly
val elapsedSeconds = elapsedNanos / 1_000_000_000.0 val elapsedSeconds = elapsedNanos / 1_000_000_000.0
assertTrue(elapsedSeconds >= 0.4, "Expected at least 0.4 seconds but was $elapsedSeconds") assertTrue(elapsedSeconds < 0.1, "Expected less than 0.1 seconds but was $elapsedSeconds")
assertTrue(elapsedSeconds < 0.7, "Expected less than 0.7 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 @Test
@@ -96,7 +96,7 @@ class SessionThrottledOutputStreamTest {
val data = "Hello World".toByteArray() val data = "Hello World".toByteArray()
throttledStream.write(data) throttledStream.write(data)
// Should be called at least once (may be chunked) // Should be called at least once
verify(atLeast = 1) { sessionTracker.throttle(any()) } verify(atLeast = 1) { sessionTracker.throttle(any()) }
assertEquals("Hello World", underlyingOutputStream.toString()) assertEquals("Hello World", underlyingOutputStream.toString())
} }
@@ -116,7 +116,7 @@ class SessionThrottledOutputStreamTest {
} }
@Test @Test
fun `write empty byte array should not throttle`() { fun `write empty byte array should call throttle`() {
throttledStream = SessionThrottledOutputStream( throttledStream = SessionThrottledOutputStream(
underlyingOutputStream, underlyingOutputStream,
sessionTracker sessionTracker
@@ -124,12 +124,12 @@ class SessionThrottledOutputStreamTest {
throttledStream.write(byteArrayOf()) throttledStream.write(byteArrayOf())
verify(exactly = 0) { sessionTracker.throttle(any()) } verify(exactly = 1) { sessionTracker.throttle(0) }
assertEquals("", underlyingOutputStream.toString()) assertEquals("", underlyingOutputStream.toString())
} }
@Test @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( throttledStream = SessionThrottledOutputStream(
underlyingOutputStream, underlyingOutputStream,
sessionTracker sessionTracker
@@ -138,43 +138,24 @@ class SessionThrottledOutputStreamTest {
val data = "Hello".toByteArray() val data = "Hello".toByteArray()
throttledStream.write(data, 0, 0) throttledStream.write(data, 0, 0)
verify(exactly = 0) { sessionTracker.throttle(any()) } verify(exactly = 1) { sessionTracker.throttle(0) }
assertEquals("", underlyingOutputStream.toString()) assertEquals("", underlyingOutputStream.toString())
} }
@Test @Test
fun `write large byte array should be chunked`() { fun `write should throttle exact byte count without chunking`() {
throttledStream = SessionThrottledOutputStream( throttledStream = SessionThrottledOutputStream(
underlyingOutputStream, underlyingOutputStream,
sessionTracker sessionTracker
) )
val data = ByteArray(200 * 1024) { it.toByte() } // 200 KB // Write exactly 1024 KB
val data = ByteArray(1024 * 1024) { 0 }
throttledStream.write(data) throttledStream.write(data)
// With 64KB chunks, 200KB should require at least 3 chunks // Should be throttled exactly once for the entire write
verify(atLeast = 3) { sessionTracker.throttle(any()) } verify(exactly = 1) { sessionTracker.throttle(1024L * 1024) }
assertEquals(200 * 1024, underlyingOutputStream.size()) assertEquals(1024 * 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 })
} }
@Test @Test
@@ -251,8 +232,23 @@ class SessionThrottledOutputStreamTest {
@Test @Test
fun `should work with real tracker and throttle bandwidth`() { 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) 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( throttledStream = SessionThrottledOutputStream(
underlyingOutputStream, underlyingOutputStream,
realTracker, realTracker,
@@ -264,14 +260,14 @@ class SessionThrottledOutputStreamTest {
assertEquals(1, realTracker.activeDownloads.get()) assertEquals(1, realTracker.activeDownloads.get())
val startTime = System.nanoTime() 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) throttledStream.write(data)
val elapsed = (System.nanoTime() - startTime) / 1_000_000_000.0 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 // Should take at least 1.8 seconds to transfer 200 KB at 100 KB/s
// Using 1.3 to account for timing variations // Using 1.7 to account for timing variations
assertTrue(elapsed >= 1.3, "Expected at least 1.3 seconds but was $elapsed") assertTrue(elapsed >= 1.7, "Expected at least 1.7 seconds but was $elapsed")
assertEquals(20_000L, realTracker.totalBytesTransferred) assertEquals(400_000L, realTracker.totalBytesTransferred)
throttledStream.close() throttledStream.close()
assertEquals(0, realTracker.activeDownloads.get()) assertEquals(0, realTracker.activeDownloads.get())
@@ -368,27 +364,6 @@ class SessionThrottledOutputStreamTest {
verify(exactly = 3) { sessionTracker.throttle(1) } 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 @Test
fun `should handle small writes without chunking`() { fun `should handle small writes without chunking`() {
throttledStream = SessionThrottledOutputStream( throttledStream = SessionThrottledOutputStream(
@@ -410,15 +385,13 @@ class SessionThrottledOutputStreamTest {
sessionTracker sessionTracker
) )
// Write large data with offset/length that spans multiple chunks // Write large data with offset/length
val data = ByteArray(200 * 1024) { it.toByte() } val data = ByteArray(1200 * 1024) { it.toByte() }
throttledStream.write(data, 10000, 150000) throttledStream.write(data, 10000, 800000)
// Should write exactly 150000 bytes // Should throttle exactly once for the specified length
val capturedSizes = mutableListOf<Long>() verify(exactly = 1) { sessionTracker.throttle(800000L) }
verify(atLeast = 2) { sessionTracker.throttle(capture(capturedSizes)) } assertEquals(800000, underlyingOutputStream.size())
assertEquals(150000L, capturedSizes.sum())
assertEquals(150000, underlyingOutputStream.size())
} }
@Test @Test
@@ -450,39 +423,8 @@ class SessionThrottledOutputStreamTest {
throttledStream.write("EF".toByteArray()) throttledStream.write("EF".toByteArray())
assertEquals("ABCDEF", underlyingOutputStream.toString()) assertEquals("ABCDEF", underlyingOutputStream.toString())
verify(atLeast = 4) { sessionTracker.throttle(any()) } verify(exactly = 2) { sessionTracker.throttle(1) } // Two single bytes
} verify(exactly = 2) { sessionTracker.throttle(2) } // Two 2-byte arrays
@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])
} }
} }
@@ -337,7 +337,7 @@ class DownloadEndpointTest {
} }
@Test @Test
fun `downloadGame should handle special characters in filename`() { fun `downloadGame should remove common invalid chars from filename`() {
val gameId = 1L val gameId = 1L
val provider = "TestProvider" val provider = "TestProvider"
val gamePath = "/path/to/game" val gamePath = "/path/to/game"
@@ -361,7 +361,7 @@ class DownloadEndpointTest {
assertEquals(HttpStatus.OK, response.statusCode) assertEquals(HttpStatus.OK, response.statusCode)
val contentDisposition = response.headers["Content-Disposition"]!![0] 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 @Test
+1 -1
View File
@@ -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.2" version = "2.3.3-preview"
allprojects { allprojects {
repositories { repositories {
+5 -5
View File
@@ -4,7 +4,7 @@ FROM eclipse-temurin:21-jre-alpine as builder
WORKDIR /opt/gameyfin WORKDIR /opt/gameyfin
ARG JAR_FILE=./app/build/libs/app.jar ARG JAR_FILE=./app/build/libs/app.jar
COPY ${JAR_FILE} application.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 # Pre-collect plugin JARs so final stage can copy them in a single layer
COPY --link ./plugins/ /tmp/plugins/ 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 --link --chown=${UID}:${GID} --chmod=0755 ./docker/entrypoint.ubuntu.sh /entrypoint.sh
# Copy application layers and plugin jars from builder stage # 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/extracted/dependencies/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/spring-boot-loader/ ./ COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/extracted/spring-boot-loader/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/snapshot-dependencies/ ./ COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/extracted/snapshot-dependencies/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/application/ ./ COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/extracted/application/ ./
COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/plugins ./plugins COPY --from=builder --link --chown=${UID}:${GID} /opt/gameyfin/plugins ./plugins
EXPOSE 8080 EXPOSE 8080
+3 -2
View File
@@ -3,14 +3,15 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
# Plugin versions # Dependency versions
kotlinVersion=2.2.20 kotlinVersion=2.2.20
kspVersion=2.2.20-2.0.3 kspVersion=2.2.20-2.0.3
vaadinVersion=24.9.4 vaadinVersion=24.9.4
springBootVersion=3.5.6 springBootVersion=3.5.6
springCloudVersion=2025.0.0 springCloudVersion=2025.0.0
springDependencyManagementVersion=1.1.7 springDependencyManagementVersion=1.1.7
# Dependency versions guavaVersion=33.5.0-jre
# Plugin dependency versions
pf4jVersion=3.13.0 pf4jVersion=3.13.0
pf4jKspVersion=2.2.20-1.0.3 pf4jKspVersion=2.2.20-1.0.3
# Test framework versions # Test framework versions
@@ -51,7 +51,9 @@ class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(
return FileDownload( return FileDownload(
data = streamContentAsSingleFile(path), data = streamContentAsSingleFile(path),
fileExtension = if (path.isDirectory()) "zip" else path.extension, fileExtension = if (path.isDirectory()) "zip" else path.extension,
size = path.fileSize() size = path.isDirectory().let {
if (it) null else path.fileSize()
}
) )
} }
@@ -1,4 +1,4 @@
Plugin-Version: 1.0.1 Plugin-Version: 1.0.2
Plugin-Class: org.gameyfin.plugins.download.direct.DirectDownloadPlugin Plugin-Class: org.gameyfin.plugins.download.direct.DirectDownloadPlugin
Plugin-Id: org.gameyfin.plugins.download.direct Plugin-Id: org.gameyfin.plugins.download.direct
Plugin-Name: Direct Download Plugin-Name: Direct Download