mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +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:
@@ -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"))
|
||||||
|
|||||||
Generated
+4874
-932
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+119
-71
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+4
-14
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+20
-9
@@ -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
|
||||||
|
|||||||
+40
-98
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+3
-1
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user