Seeding¶
Seeding is two jobs. First you turn a set of files into a .torrent so the rest of the swarm knows what to ask for. Then you serve the data you already have to peers who want it. This page covers both: building a torrent with CreateTorrent, the upload path through TorrentSession, web seeds, and how to get the v1/v2/hybrid info-hash right.
If you only want to download, see Downloading. If you are starting from a magnet link, see Magnets.
Building a .torrent¶
A .torrent file is a bencoded dictionary: the file list and piece geometry in an info dictionary, plus trackers, web seeds, a comment and so on around it. The SHA-1 of that info dictionary is the torrent's v1 info-hash, and a v2 torrent hashes a merkle file tree with SHA-256 instead.
CreateTorrent builds that structure in pure common code. It lives in the core module, so you can construct a torrent on any target the core supports (Android, iOS, JVM, JS).
Hashing is the caller's job
CreateTorrent deliberately does not read files from disk. The core has no filesystem in commonMain, so it never pretends to have one. You read and hash the bytes yourself, with Hasher for v1 and Hasher256 for v2, then feed the hashes in.
The four steps¶
CreateTorrent builds a torrent in four steps, in this order:
- Describe the files and piece geometry.
- Set torrent properties: trackers, web seeds, DHT nodes, comment.
- Supply the pre-computed piece hashes.
- Generate the bencoded result.
A single-file v1 torrent¶
Use CreateTorrent.Builder to collect the files and the torrent name, then build() to get a CreateTorrent. For a v1 torrent you set one SHA-1 per piece with setHash, then call generateBuffer() for the .torrent bytes.
import io.github.yuroyami.kitetorrent.torrent.CreateTorrent
import io.github.yuroyami.kitetorrent.crypto.Hasher
val pieceLength = 256 * 1024 // a power of two; 256 KiB here
// 1. describe the files. For a single-file torrent, the one file's
// path equals the torrent name.
val creator = CreateTorrent.Builder(name = "ubuntu.iso", pieceLength = pieceLength)
.addFile(relPath = "ubuntu.iso", size = fileBytes.size.toLong())
.build(v1Only = true)
// 2. set properties
creator.setComment("Built with KiteTorrent")
.setCreator("KiteTorrent/0.0.1")
.addTracker("udp://tracker.example.org:1337/announce", tier = 0)
// 3. hash every piece and feed the SHA-1s in
val numPieces = creator.numPieces()
for (index in 0 until numPieces) {
val start = index.toLong() * pieceLength
val end = minOf(start + pieceLength, fileBytes.size.toLong())
val piece = fileBytes.copyOfRange(start.toInt(), end.toInt())
creator.setHash(index, Hasher.hash(piece, length = piece.size))
}
// 4. generate the .torrent bytes
val torrentBytes: ByteArray = creator.generateBuffer()
generateBuffer() is just Bencode.encode(generate()). If you want to add custom keys before encoding, call generate() to get the Entry tree, mutate it, then encode it yourself. For the same input, the bytes are identical to libtorrent's create_torrent output.
Round-trip to verify
Parse what you just produced: TorrentInfo.parse(torrentBytes). The resulting infoHashV1, file list and piece count should match what you fed in. This is the cheapest possible check on a generated torrent.
A multi-file torrent¶
Add more than one file. Paths are relative to the torrent root. The builder stores them as name/<relative path> so they round-trip through TorrentInfo. The leading root component is dropped in the generated files[] list.
val creator = CreateTorrent.Builder(name = "my-release", pieceLength = 512 * 1024)
.addFile("README.md", readme.size.toLong())
.addFile("bin/app", appBinary.size.toLong())
.addFile("docs/guide.pdf", guide.size.toLong())
.build(v1Only = true)
Pieces span the concatenated byte stream across all files in insertion order, so hash the concatenation, not each file separately. creator.pieceSize(index) gives the size of any individual piece (the last one is usually short).
Properties you can set¶
All setters are fluent and return the CreateTorrent, so you can chain them.
| Method | Effect |
|---|---|
addTracker(url, tier) |
Adds a tracker. Lower tiers are tried first. Duplicate URLs are ignored. |
addUrlSeed(url) |
Adds a BEP-19 web seed (url-list). See Web seeds below. |
addHttpSeed(url) |
Adds an old-style HTTP seed (httpseeds). |
addNode(host, port) |
Adds a DHT bootstrap node (nodes). |
setComment(str) |
Sets the free-text comment. |
setCreator(str) |
Sets the created by string. |
setCreationDate(posixSeconds) |
Sets creation date. Omitted by default. |
setPrivate(true) |
Sets the BEP-27 private flag: tracker only, no DHT or PEX. |
addSimilarTorrent(infoHash) / addCollection(name) |
BEP-38 cross-seeding hints. |
No clock in the core
The core has no clock, so KiteTorrent leaves the creation date field out by default. Set it yourself with setCreationDate(...) if you want it, passing a POSIX timestamp in seconds.
v1, v2 and hybrid¶
The flavour of the torrent you produce is decided by which hashes you set and the flags you pass:
BitTorrent v1 (BEP-3). One SHA-1 per piece via setHash. Force it so a stray v2 hash is rejected:
BitTorrent v2 (BEP-52). One SHA-256 per 16 KiB block, per file. CreateTorrent.DEFAULT_BLOCK_SIZE is that 16 KiB. The core folds each file's merkle tree itself:
Both at once (the modern default; v1 clients see a v1 torrent, v2 clients see a v2 one, and they share a swarm). Set both the v1 piece hashes and the v2 block leaves, and pass neither v1Only nor v2Only:
generate() enforces one rule: it produces a flavour only when every hash for that flavour is set. An all-zero hash is the "unset" sentinel and never counts. If neither a complete v1 set nor a complete v2 set is present, generate() throws.
Use Hasher256, not the wrong granularity
v1 hashes one SHA-1 per piece. v2 hashes one SHA-256 per 16 KiB block, and the per-piece and per-file merkle roots are computed for you. If you mix the two up, you produce a torrent whose info-hash no other client agrees with. If you are unsure, parse the output back and compare infoHashV1 and infoHashV2 against a reference client.
After parsing, TorrentInfo tells you what you got: isV1, isV2, isHybrid, plus infoHashV1 and infoHashV2. wireInfoHash() gives the 20-byte hash used on the wire and at the tracker (the v1 hash when present, otherwise the truncated v2 hash).
Serving a torrent¶
Once a torrent's data is on disk, the engine seeds it like any other torrent. There is no separate "seed" call: you add the torrent, get the session to verify what is already there, and it moves to the SEEDING state once it holds every piece.
import io.github.yuroyami.kitetorrent.session.engine.KiteTorrentEngine
import io.github.yuroyami.kitetorrent.session.disk.FileDiskIo
import io.github.yuroyami.kitetorrent.session.engine.TorrentState
import io.github.yuroyami.kitetorrent.session.tracker.HttpTracker
import io.github.yuroyami.kitetorrent.torrent.TorrentInfo
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.Dispatchers
val engine = KiteTorrentEngine(
scope,
httpTracker = HttpTracker(HttpClient(CIO)),
enableDht = true,
)
engine.start()
val torrent = TorrentInfo.parse(torrentBytes)
val session = engine.addTorrent(
torrent,
FileDiskIo(torrent.storage, basePath = "/srv/downloads", dispatcher = Dispatchers.IO),
resume = null,
)
session.onStateChanged = { state ->
if (state == TorrentState.SEEDING) println("Now seeding ${torrent.name}")
}
// Required with FileDiskIo: rehash every piece so the session claims the data on disk.
session.recheck(full = true)
FileDiskIo reports no pieces present
Adding a torrent whose files are already complete does not put the session into SEEDING on its own. addTorrent calls session.start() internally, and with resume = null that runs recheck() with its default full = false. That default only hashes the pieces the disk layer flags as present, and FileDiskIo.checkExistingFiles() returns an all-false array. Zero pieces get claimed, the session stays in DOWNLOADING, and it re-downloads the whole torrent over the files that were already there. There is no exception, no alert and no log entry, and addTorrent gives you no hook to intervene before start() runs.
val session = engine.addTorrent(torrent, disk)
session.recheck(full = true) // FileDiskIo reports nothing present without this
With full = true the recheck hashes the bytes of every piece and claims only the ones whose hash matches the torrent. A truncated or corrupted file is therefore caught, and those pieces are downloaded again instead of being served. InMemoryDiskIo does return a real present-map, so the default full = false behaves as described there.
To skip the rehash on a known-good copy, pass saved resume data instead of null. That path claims pieces from the stored bitfield without touching the disk.
The upload path¶
Seeding peers go through the same PeerConnection machinery as downloading. When a peer connects and is interested, the session may unchoke it and start answering its request messages with piece data read from DiskIo. As the session completes pieces (when seeding from a partial copy, or when a downloader finishes), it sends have messages so peers know what it can serve.
Three pieces of state govern who gets served:
uploadSlotscaps how many peers you upload to at once. Choked peers are not served. Its starting value comes from theunchoke_slots_limitsetting.maxPeerscaps total connections for this torrent.- The choker runs a periodic round (
unchoke_interval) and decides which interested peers fill the upload slots.
session.uploadSlots = 8 // unchoke up to 8 peers
session.maxPeers = 80 // accept up to 80 connections for this torrent
Tit-for-tat choking¶
You cannot upload to everyone at once, so BitTorrent picks who to serve. The rule is reciprocity: you serve the peers that serve you. The session runs a choke round on a timer, and each round:
- Ranks interested peers by their recent transfer rate.
- Unchokes the fastest peers up to
uploadSlots. - Reserves one rotating optimistic slot for a peer chosen at random, so a new peer gets a chance to show its speed.
Every other peer stays choked. When you are seeding you have nothing to download, so "fastest" means the peers that download from you fastest. Your bandwidth then goes to the peers that spread the data best. The optimistic slot rotates on its own interval (optimistic_unchoke_interval).
All of this is driven by SettingsPack. To change the cadence or slot count globally, see Engine settings:
import io.github.yuroyami.kitetorrent.settings.SettingsPack
import io.github.yuroyami.kitetorrent.settings.IntSetting
import io.github.yuroyami.kitetorrent.session.tracker.HttpTracker
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
val settings = SettingsPack()
settings.setInt(IntSetting.UNCHOKE_SLOTS_LIMIT, 8)
settings.setInt(IntSetting.UNCHOKE_INTERVAL, 10) // seconds between rounds
val engine = KiteTorrentEngine(
scope,
httpTracker = HttpTracker(HttpClient(CIO)),
settings = settings,
)
Watching upload progress¶
The session exposes its state and peer count directly:
println("state: ${session.state}") // CHECKING / DOWNLOADING / SEEDING / PAUSED
println("peers: ${session.numPeers()}")
println("complete: ${session.progress() * 100}%")
For per-event detail (a peer connecting, a piece served), drain the engine's alert queue. Alerts like PeerConnectAlert and PeerDisconnectedAlert report connection churn:
for (alert in engine.popAlerts()) {
when (alert) {
is PeerConnectAlert -> println("peer connected")
is PeerDisconnectedAlert -> println("peer left")
}
}
Web seeds (BEP-19)¶
A web seed is a plain HTTP or HTTPS server that hosts the torrent's files at predictable URLs. Downloaders can request byte ranges over HTTP as well as, or instead of, talking to peers. That helps when the swarm has few peers, or when you want one guaranteed source.
You add web seeds when you build the torrent, with addUrlSeed. The URL goes into the torrent's url-list:
val creator = CreateTorrent.Builder("dataset.bin", pieceLength = 1 shl 20)
.addFile("dataset.bin", data.size.toLong())
.build(v1Only = true)
.addUrlSeed("https://cdn.example.org/files/")
.addTracker("udp://tracker.example.org:1337/announce")
The URLs survive parsing: TorrentInfo.parse(...).webSeeds returns the list back. On the download side, the session treats each web seed as a "GetRight" style HTTP source. It fetches the byte ranges that cover the pieces it needs. No extra wiring is required, because the engine reads the torrent's webSeeds automatically when it downloads.
Web seeds are a download source
There is nothing to "turn on" for serving via a web seed: the web seed is the HTTP server hosting your files. KiteTorrent's job is to let downloaders use it. You point addUrlSeed at wherever those files are reachable and host them with any ordinary HTTP server that supports range requests.
Putting it together¶
Build a torrent from a file, write the .torrent out, then seed the same data:
import io.github.yuroyami.kitetorrent.torrent.CreateTorrent
import io.github.yuroyami.kitetorrent.torrent.TorrentInfo
import io.github.yuroyami.kitetorrent.crypto.Hasher
import io.github.yuroyami.kitetorrent.session.engine.KiteTorrentEngine
import io.github.yuroyami.kitetorrent.session.disk.FileDiskIo
import io.github.yuroyami.kitetorrent.session.tracker.HttpTracker
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.Dispatchers
suspend fun seed(scope: CoroutineScope, fileBytes: ByteArray, savePath: String) {
val pieceLength = 256 * 1024
// 1. build a v1 torrent
val creator = CreateTorrent.Builder("payload.bin", pieceLength)
.addFile("payload.bin", fileBytes.size.toLong())
.build(v1Only = true)
.addTracker("udp://tracker.example.org:1337/announce")
.setCreator("KiteTorrent/0.0.1")
for (i in 0 until creator.numPieces()) {
val start = i * pieceLength
val end = minOf(start + pieceLength, fileBytes.size)
val piece = fileBytes.copyOfRange(start, end)
creator.setHash(i, Hasher.hash(piece, length = piece.size))
}
val torrentBytes = creator.generateBuffer() // distribute this .torrent
// 2. seed the data the torrent describes
val engine = KiteTorrentEngine(
scope,
httpTracker = HttpTracker(HttpClient(CIO)),
enableDht = true,
)
engine.start()
val torrent = TorrentInfo.parse(torrentBytes)
val session = engine.addTorrent(
torrent,
FileDiskIo(torrent.storage, basePath = savePath, dispatcher = Dispatchers.IO),
resume = null,
)
session.uploadSlots = 8
// 3. claim the data already on disk; without this the session re-downloads it.
session.recheck(full = true)
}
See also¶
- Downloading: the download path, progress and resume data.
- Magnets: start from a magnet link instead of a
.torrent. - Core toolkit:
Bencode,Hasher,TorrentInfoandFileStoragefrom the pure core. - Engine settings: choking cadence, slot counts, rate limits and connection caps.
- API reference for the full signatures.