diff --git a/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/util/RowContentTest.kt b/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/util/RowContentTest.kt index 040b23085..ae352cf4c 100644 --- a/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/util/RowContentTest.kt +++ b/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/util/RowContentTest.kt @@ -124,8 +124,12 @@ class RowContentTest { @Test fun rowContent_queued_explainsWait() = runTest { val running = listOf(DownloadRequest("https://a.com/1"), DownloadRequest("https://b.com/2")) + val fullQueue = context.copy( + config = DownloadConfig(maxConcurrentDownloads = 2), + running = running, + ) - val waiting = rowContent(request, DownloadState.Queued, now, context.copy(running = running)) + val waiting = rowContent(request, DownloadState.Queued, now, fullQueue) val unknown = rowContent(request, DownloadState.Queued, now, context.copy(config = null)) assertEquals("Waiting for a free slot (2 of 2 in use)", waiting.detail.load()) @@ -146,7 +150,11 @@ class RowContentTest { @Test fun rowContent_queuedWithPositionThree_saysTwoAhead() = runTest { val running = listOf(DownloadRequest("https://a.com/1"), DownloadRequest("https://b.com/2")) - val capable = context.copy(running = running, features = KetchFeatures.ALL) + val capable = context.copy( + config = DownloadConfig(maxConcurrentDownloads = 2), + running = running, + features = KetchFeatures.ALL, + ) val content = rowContent(request, DownloadState.Queued, now, capable, queuePosition = 3) diff --git a/cli/README.md b/cli/README.md index 5e54a306b..384f6e066 100644 --- a/cli/README.md +++ b/cli/README.md @@ -91,7 +91,7 @@ command exits when the download completes or fails. |---|---| | `--speed-limit ` | Limit download speed (e.g., `500k`, `1m`, `10m`) | | `--priority ` | Set download priority: `low`, `normal`, `high`, `urgent` | -| `--max-concurrent ` | Max simultaneous downloads (default: 3) | +| `--max-concurrent ` | Max simultaneous downloads (default: 4) | | `-H`, `--header
` | Send a request header, as `'Name: value'`; repeatable | | `--user-agent ` | Send this `User-Agent` instead of `Ketch/` | | `--referer ` | Send this `Referer` | @@ -468,8 +468,9 @@ port = 8642 # defaultDirectory = "~/Downloads" # ~ is your home folder # speedLimit = "unlimited" # "unlimited", "10m" (MB/s), "500k" (KB/s) maxConnectionsPerDownload = 4 -maxConcurrentDownloads = 2 -maxConnectionsPerHost = 8 +# Queue limits count downloads, not individual connections; 0 means unlimited. +maxConcurrentDownloads = 4 +maxConnectionsPerHost = 16 # Advanced settings (defaults are usually fine): # retryCount = 3 @@ -519,8 +520,8 @@ maxConnectionsPerHost = 8 | `defaultDirectory` | string | `~/Downloads` | Default save directory; a leading `~` is your home folder | | `speedLimit` | string | `"unlimited"` | Global speed limit (`"500k"`, `"10m"`, or bytes) | | `maxConnectionsPerDownload` | int | `4` | Connections (segments) per HTTP or FTP download | -| `maxConcurrentDownloads` | int | `2` | Max simultaneous downloads (`0` = unlimited) | -| `maxConnectionsPerHost` | int | `8` | Max simultaneous downloads per host (`0` = unlimited) | +| `maxConcurrentDownloads` | int | `4` | Max simultaneous downloads (`0` = unlimited) | +| `maxConnectionsPerHost` | int | `16` | Max simultaneous downloads per host (`0` = unlimited) | | `retryCount` | int | `3` | Max automatic retries after a retryable failure | | `retryDelayMs` | long | `1000` | Base delay between retries (exponential backoff) | | `progressIntervalMs` | long | `200` | Progress update throttle interval | diff --git a/cli/src/main/kotlin/com/linroid/ketch/cli/DownloadArgs.kt b/cli/src/main/kotlin/com/linroid/ketch/cli/DownloadArgs.kt index 3b45c8bf9..0a31756b5 100644 --- a/cli/src/main/kotlin/com/linroid/ketch/cli/DownloadArgs.kt +++ b/cli/src/main/kotlin/com/linroid/ketch/cli/DownloadArgs.kt @@ -1,6 +1,7 @@ package com.linroid.ketch.cli import com.linroid.ketch.api.Destination +import com.linroid.ketch.api.DownloadConfig import com.linroid.ketch.api.DownloadPriority import com.linroid.ketch.api.DownloadRequest import com.linroid.ketch.api.SpeedLimit @@ -21,7 +22,7 @@ internal sealed interface DownloadArgs { val destination: String?, val speedLimit: SpeedLimit = SpeedLimit.Unlimited, val priority: DownloadPriority = DownloadPriority.NORMAL, - val maxConcurrent: Int = 3, + val maxConcurrent: Int = DownloadConfig.Default.maxConcurrentDownloads, val headers: Map = emptyMap(), ) : DownloadArgs } @@ -45,7 +46,7 @@ internal fun parseDownloadArgs(args: List): DownloadArgs { var destination: String? = null var speedLimit = SpeedLimit.Unlimited var priority = DownloadPriority.NORMAL - var maxConcurrent = 3 + var maxConcurrent = DownloadConfig.Default.maxConcurrentDownloads val headers = LinkedHashMap() // Header names ignore case, so a later option replaces an earlier one however it is spelled. fun setHeader(name: String, value: String) { diff --git a/cli/src/main/kotlin/com/linroid/ketch/cli/Main.kt b/cli/src/main/kotlin/com/linroid/ketch/cli/Main.kt index 8ef79ae4e..6bb0310e0 100644 --- a/cli/src/main/kotlin/com/linroid/ketch/cli/Main.kt +++ b/cli/src/main/kotlin/com/linroid/ketch/cli/Main.kt @@ -116,10 +116,6 @@ private fun runDownload(args: DownloadArgs.Download) { println() val config = DownloadConfig( - maxConnectionsPerDownload = 4, - retryCount = 3, - retryDelayMs = 1000, - progressIntervalMs = 200, maxConcurrentDownloads = args.maxConcurrent, ) diff --git a/cli/src/test/kotlin/com/linroid/ketch/cli/DownloadArgsTest.kt b/cli/src/test/kotlin/com/linroid/ketch/cli/DownloadArgsTest.kt index 284691a39..2c7addcab 100644 --- a/cli/src/test/kotlin/com/linroid/ketch/cli/DownloadArgsTest.kt +++ b/cli/src/test/kotlin/com/linroid/ketch/cli/DownloadArgsTest.kt @@ -1,6 +1,7 @@ package com.linroid.ketch.cli import com.linroid.ketch.api.Destination +import com.linroid.ketch.api.DownloadConfig import com.linroid.ketch.api.DownloadPriority import com.linroid.ketch.api.SpeedLimit import java.io.File @@ -10,6 +11,16 @@ import kotlin.test.assertIs class DownloadArgsTest { + @Test + fun parseDownloadArgs_withoutQueueOverride_usesSharedDownloadDefault() { + val parsed = parseDownloadArgs(listOf("https://example.com/a.zip")) + + assertEquals( + DownloadConfig.Default.maxConcurrentDownloads, + assertIs(parsed).maxConcurrent, + ) + } + @Test fun `help flags print usage instead of downloading`() { assertEquals(DownloadArgs.Help, parseDownloadArgs(listOf("--help"))) diff --git a/config/src/jvmMain/kotlin/com/linroid/ketch/config/DefaultConfig.kt b/config/src/jvmMain/kotlin/com/linroid/ketch/config/DefaultConfig.kt index bd07b03b2..b5da733b7 100644 --- a/config/src/jvmMain/kotlin/com/linroid/ketch/config/DefaultConfig.kt +++ b/config/src/jvmMain/kotlin/com/linroid/ketch/config/DefaultConfig.kt @@ -35,8 +35,9 @@ port = 8642 # defaultDirectory = "~/Downloads" # ~ is your home folder # speedLimit = "unlimited" # "unlimited", "10m" (MB/s), "500k" (KB/s) maxConnectionsPerDownload = 4 -maxConcurrentDownloads = 2 -maxConnectionsPerHost = 8 +# Queue limits count downloads, not individual connections; 0 means unlimited. +maxConcurrentDownloads = 4 +maxConnectionsPerHost = 16 # Advanced settings (defaults are usually fine): # retryCount = 3 diff --git a/config/src/jvmTest/kotlin/com/linroid/ketch/config/DefaultConfigTest.kt b/config/src/jvmTest/kotlin/com/linroid/ketch/config/DefaultConfigTest.kt index 6781f9e81..5e35e18ce 100644 --- a/config/src/jvmTest/kotlin/com/linroid/ketch/config/DefaultConfigTest.kt +++ b/config/src/jvmTest/kotlin/com/linroid/ketch/config/DefaultConfigTest.kt @@ -1,5 +1,6 @@ package com.linroid.ketch.config +import com.linroid.ketch.api.DownloadConfig import java.io.File import kotlin.io.path.createTempDirectory import kotlin.test.Test @@ -13,6 +14,7 @@ class DefaultConfigTest { KetchConfig.serializer(), DEFAULT_CONFIG_CONTENT, ) + assertEquals(DownloadConfig.Default, decoded.download) assertEquals(TorrentSettings(), decoded.torrent) assertEquals(8642, decoded.server.port) } @@ -29,6 +31,10 @@ class DefaultConfigTest { val home = System.getProperty("user.home").trimEnd('/', '\\') assertEquals("$home/Downloads", config.download.defaultDirectory) + assertEquals( + DownloadConfig.Default.copy(defaultDirectory = "$home/Downloads"), + config.download, + ) // Ktor's CORS allowHost rejects a scheme in the host, failing server startup assertEquals(listOf("localhost:3000"), config.server.corsAllowedHosts) assertEquals(listOf("$home/Media"), config.server.allowedDirectories) diff --git a/docs/api.md b/docs/api.md index 2cea4f99b..94ea35d77 100644 --- a/docs/api.md +++ b/docs/api.md @@ -235,8 +235,8 @@ DownloadConfig( saveIntervalMs = 5000, // how often segment progress is persisted bufferSize = 8192, // FTP read buffer size speedLimit = SpeedLimit.kbps(500), // global speed limit (default: Unlimited) - maxConcurrentDownloads = 2, // max simultaneous downloads (0 = unlimited) - maxConnectionsPerHost = 8, // max simultaneous downloads per host (0 = unlimited) + maxConcurrentDownloads = 4, // max simultaneous downloads (0 = unlimited) + maxConnectionsPerHost = 16, // max simultaneous downloads per host (0 = unlimited) ) ``` @@ -252,7 +252,10 @@ downloads that start or resume afterwards; pause and resume a running download t A changed `defaultDirectory` that does not exist or is not a folder is rejected with `IllegalArgumentException`, and the previous configuration stays in effect. -The per-host limit counts downloads by URL host (case-insensitive, ignoring user info and port). +The per-host limit counts downloads by URL host (case-insensitive, ignoring user info and port), +not their individual connections. With the defaults, the global limit of four downloads applies +before the per-host limit of sixteen. Explicit values in an existing configuration are kept; +omitted fields use the current defaults. Magnet links, `torrent:` identifiers and local files are not counted. `maxConnectionsPerDownload` and `DownloadRequest.connections` split HTTP(S) and FTP(S) diff --git a/library/api/src/commonMain/kotlin/com/linroid/ketch/api/DownloadConfig.kt b/library/api/src/commonMain/kotlin/com/linroid/ketch/api/DownloadConfig.kt index b1294812b..26be75005 100644 --- a/library/api/src/commonMain/kotlin/com/linroid/ketch/api/DownloadConfig.kt +++ b/library/api/src/commonMain/kotlin/com/linroid/ketch/api/DownloadConfig.kt @@ -5,6 +5,9 @@ import kotlinx.serialization.Serializable /** * Global download configuration. * + * Defaults allow four downloads at once, at most sixteen per host, and four connections per + * HTTP(S) or FTP(S) download. The per-host limit counts tasks, not their individual connections. + * * Can be replaced at runtime with [KetchApi.updateConfig]. [speedLimit], * [maxConcurrentDownloads] and [maxConnectionsPerHost] apply immediately. Every other field * is read when a download starts or resumes, so a running download keeps the values it @@ -45,14 +48,14 @@ import kotlinx.serialization.Serializable data class DownloadConfig( val defaultDirectory: String? = null, val retryCount: Int = 3, - val retryDelayMs: Long = 1000, + val retryDelayMs: Long = 1_000, val progressIntervalMs: Long = 200, - val saveIntervalMs: Long = 5000, - val bufferSize: Int = 8192, + val saveIntervalMs: Long = 5_000, + val bufferSize: Int = 8 * 1024, val speedLimit: SpeedLimit = SpeedLimit.Unlimited, - val maxConcurrentDownloads: Int = 2, + val maxConcurrentDownloads: Int = 4, val maxConnectionsPerDownload: Int = 4, - val maxConnectionsPerHost: Int = 8, + val maxConnectionsPerHost: Int = 16, ) { init { require(retryCount >= 0) { "retryCount must be non-negative" } @@ -66,6 +69,7 @@ data class DownloadConfig( } companion object { - val Default = DownloadConfig() + /** Default settings shared by the engine and its clients. */ + val Default: DownloadConfig = DownloadConfig() } }