mirror of
https://github.com/grimsi/gameyfin.git
synced 2026-02-06 11:27:07 +00:00
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:
parent
abc12f146b
commit
005a1611ce
@ -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
5806
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user