Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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)

Expand Down
11 changes: 6 additions & 5 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ command exits when the download completes or fails.
|---|---|
| `--speed-limit <value>` | Limit download speed (e.g., `500k`, `1m`, `10m`) |
| `--priority <level>` | Set download priority: `low`, `normal`, `high`, `urgent` |
| `--max-concurrent <n>` | Max simultaneous downloads (default: 3) |
| `--max-concurrent <n>` | Max simultaneous downloads (default: 4) |
| `-H`, `--header <header>` | Send a request header, as `'Name: value'`; repeatable |
| `--user-agent <value>` | Send this `User-Agent` instead of `Ketch/<version>` |
| `--referer <url>` | Send this `Referer` |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
5 changes: 3 additions & 2 deletions cli/src/main/kotlin/com/linroid/ketch/cli/DownloadArgs.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String, String> = emptyMap(),
) : DownloadArgs
}
Expand All @@ -45,7 +46,7 @@ internal fun parseDownloadArgs(args: List<String>): DownloadArgs {
var destination: String? = null
var speedLimit = SpeedLimit.Unlimited
var priority = DownloadPriority.NORMAL
var maxConcurrent = 3
var maxConcurrent = DownloadConfig.Default.maxConcurrentDownloads
val headers = LinkedHashMap<String, String>()
// Header names ignore case, so a later option replaces an earlier one however it is spelled.
fun setHeader(name: String, value: String) {
Expand Down
4 changes: 0 additions & 4 deletions cli/src/main/kotlin/com/linroid/ketch/cli/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
11 changes: 11 additions & 0 deletions cli/src/test/kotlin/com/linroid/ketch/cli/DownloadArgsTest.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<DownloadArgs.Download>(parsed).maxConcurrent,
)
}

@Test
fun `help flags print usage instead of downloading`() {
assertEquals(DownloadArgs.Help, parseDownloadArgs(listOf("--help")))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down
9 changes: 6 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
```

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" }
Expand All @@ -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()
}
}
Loading