downloadx (Dart / Flutter)
An IDM-style download manager for Dart and Flutter — a faithful port of
@downloadx/core.
It downloads files in parallel chunks with random-access disk writes, splits slow chunks dynamically so fast connections never idle, survives process restarts, and recovers from misbehaving servers — with zero third-party dependencies (only the Dart SDK).
Unlike the TypeScript core, the Dart port ships a built-in dart:io
implementation (NativeIo) for both the file system
and the network, so a Flutter or Dart app gets working downloads with no
wiring. The I/O layer is still pluggable — pass your own `DownloadxIo` for
tests (an in-memory mock), web/IndexedDB, or a custom backend such as S3.
Feature parity with the core
Everything the TypeScript core supports is supported here:
- Chunked parallel downloads — byte ranges download concurrently and write directly to their final offsets. No temp-file stitching.
- Dynamic chunk splitting — when a chunk finishes, the slowest/largest
remaining chunk donates its tail to a fresh worker, bounded by
targetChunkCount. - Resume across restarts — an atomically-written
.downloadx.jsonsidecar records the exact chunk layout; resume is validated with ETag → Last-Modified → size, and ranged requests carryIf-Range. - Misbehaving-server recovery — a ranged request answered with
200 OK(server ignoredRange) restarts once as a single full-body download. - Network idle timeout — an attempt is aborted and retried only when no
bytes arrive for
requestTimeoutms. Long downloads run for hours as long as data flows. - Stall auto-recovery — chunks stuck below ~15% of the median speed for ~15 s get their request reissued automatically.
- Speed limiting — token-bucket throttling per download plus an optional manager-wide cap shared by all downloads; both adjustable live.
- Retries done right — exponential backoff with full jitter, and a retryable vs. permanent HTTP status distinction.
- Integrity — optional disk pre-allocation and a final size verification before the part file is renamed into place.
- HLS (m3u8) support — master and media playlists are parsed and segments
downloaded in parallel. A master playlist with multiple quality streams
registers each as a separate idle download so the caller picks the one to
start. Segment concatenation is handled by the optional
concatSegmentshook onDownloadxIo(ffmpeg recommended); without it a binary fallback produces a raw.ts. - Observability — a synchronous typed event API, an NDJSON diagnostic
journal sidecar, and
describe()/describeText()reports. - Unknown sizes handled — downloads with no
Content-Lengthstream to EOF in a single chunk.
The meta sidecar (schemaVersion, field names, the unknownSize sentinel, the
URL→id hash) is byte-compatible with the TypeScript implementation, so a
download started by one can be resumed by the other.
Quick start
import 'package:downloadx/downloadx.dart';
final manager = await createDownloadX(DownloadXConfig(
targetPath: './downloads',
maxParallel: 3,
targetChunkCount: 4,
journal: true,
));
final dl = await manager.addUrl('https://example.com/big.iso');
dl.emitter.onType<ProgressEvent>((p) {
print('${p.percent?.toStringAsFixed(1)}% @ '
'${(p.totalSpeed / 1e6).toStringAsFixed(2)} MB/s');
});
await dl.start();
API
createDownloadX(config)
Builds a DownloadX and rehydrates any persisted downloads found in
cachePath. Restored downloads stay in their last persisted state (no
autostart).
DownloadXConfig fields (all optional except targetPath):
| Field | Default | Description |
|---|---|---|
io |
NativeIo() |
Injected I/O. Defaults to a dart:io disk + HttpClient backend. |
targetPath |
required | Directory where finished files land. |
cachePath |
targetPath |
Directory for in-flight meta/part files. Fixed at construction. |
maxParallel |
3 |
Max concurrent active downloads. |
targetChunkCount |
4 |
Upper bound on live chunks per download. |
minChunkSize |
1 MiB |
Smaller ranges won't be split further. |
maxRetries |
5 |
Per-chunk HTTP retries. |
retryDelay |
1000 |
Base backoff delay (ms). |
retryBackoff |
2 |
Exponential backoff multiplier. |
speedSampleWindow |
3000 |
Moving-average window (ms) for quality. |
speedLimit |
0 |
Manager-wide bytes/sec cap shared by all downloads. 0 = off. |
requestTimeout |
30000 |
Network idle timeout (ms). |
headers |
{} |
Default HTTP headers. |
journal |
false |
Write an NDJSON event journal next to the meta file. |
Manager (DownloadX)
addUrl(url, [options])→Future<Download>— options includefilename,description,metadata,headers,targetPath,speedLimit,targetChunkCount,minChunkSize,journal,id,autoStartstart([id])/pause([id])/clear([id])list()/getDownload(id)/describeAll()setMaxParallel(n)/setTargetPath(p)setSpeedLimit(int?)— manager-wide cap shared by all downloads;nullor0= unlimitedsetTargetChunkCount(int?, {override})/setMinChunkSize(int?, {override})/setJournal(bool?, {override})—nullresets to the built-in default;override: trueforces the value onto every download regardless of their current per-download settingsetHeaders(Map<String, String?>?)— merge HTTP headers into the global config;nullvalues in the map remove that key; passnullto clear all
Download
start()/pause()/cancel()/clear()setSpeedLimit(int?)/setTargetPath(String?)/setTargetChunkCount(int?)/setMinChunkSize(int?)/setJournal(bool?)—nullclears the per-download override and reverts to the global valuesetFilename(String?)— override the final filename;nullreverts to the probe/URL-derived namesetDescription(String?)— attach a free-form note;nullclears itsetMetadata(Map<String, String?>)— merge key/value pairs;nullvalues remove individual keys. UseclearMetadata()to remove all metadata.setHeaders(Map<String, String?>)— merge HTTP headers on top of global (effective = {...global, ...local});nullvalues remove individual keys. UseclearHeaders()to remove all local overrides. Only the local portion is persisted — global header changes are reflected automatically on the next request.alloc()— pre-allocate the part file (automatic at start whenio.truncateis available)describe()/describeText()emitter— the typed event API
Part file location: the in-progress
.partfile is written to{cachePath}/{id}.part. Changing the filename mid-download never loses the downloaded bytes. The file is moved to{targetPath}/{filename}only on successful completion.
Events
Listen with emitter.onType<T>(...) for a specific event, or emitter.on(...)
for everything. All events are also relayed on the manager's emitter.
| Event | Notable fields |
|---|---|
ProgressEvent |
totalBytes, downloadedBytes, totalSpeed, activeChunks, percent, etaMs, hlsSegmentsDone?, hlsTotalSegments? |
ChunkProgressEvent |
chunkId, offset, length, downloadedBytes, instantSpeed, windowedSpeed |
ChunkQualityEvent |
same shape as ChunkProgressEvent; quality ∈ good/poor/stalled |
ChunkLifecycleEvent |
chunkId, status |
ChunkSplitEvent |
sourceChunkId, newChunkId, splitOffset, reason |
StateChangeEvent |
previous, current |
ErrorEvent |
chunkId?, error, fatal |
CompletedEvent |
filename, totalBytes, durationMs |
DiagnosticEvent |
payload (a DiagnosticPayload: level, code, message, timestamp, data?) |
Custom I/O
Implement DownloadxIo to target a different backend (web, S3, a database, an
in-memory mock for tests). writeChunk must support random-access offset
writes without truncating. The optional truncate / appendFile /
fileSize / concatSegments getters each unlock a feature when non-null:
pre-allocation / journal / size verification / HLS concat.
concatSegments — HLS segment concatenation
When provided, this hook is called after all .ts segments are downloaded.
Without it the core falls back to a binary concat, producing a raw .ts file.
ffmpeg example — remux to .mp4 (no re-encode, fast):
import 'dart:io';
class MyIo extends NativeIo {
@override
Future<void> Function(List<String> segments, String output)?
get concatSegments => (segments, output) async {
final listPath = '$output.ffconcat';
final content = StringBuffer('ffconcat version 1.0\n');
for (final s in segments) {
content.writeln("file '$s'");
}
await File(listPath).writeAsString(content.toString());
final result = await Process.run('ffmpeg', [
'-f', 'concat', '-safe', '0', '-i', listPath,
'-c', 'copy', // remux only — no re-encode
'-movflags', '+faststart',
'-y', output,
]);
await File(listPath).delete().catchError((_) => File(listPath));
if (result.exitCode != 0) {
throw Exception('ffmpeg exited ${result.exitCode}:\n${result.stderr}');
}
};
}
ffmpeg example — transcode .ts segments to .mp4 (H.264 + AAC):
get concatSegments => (segments, output) async {
final input = segments.join('|');
final result = await Process.run('ffmpeg', [
'-i', 'concat:$input',
'-c:v', 'libx264', '-preset', 'fast', '-crf', '22',
'-c:a', 'aac', '-b:a', '128k',
'-y', output,
]);
if (result.exitCode != 0) {
throw Exception('ffmpeg exited ${result.exitCode}:\n${result.stderr}');
}
};
Without
concatSegments, the core binary-concatenates the segments into a.tsfile. This is valid for most streams but skips container conversion and metadata — use ffmpeg when you need.mp4/.mkvoutput or chapter marks.
Development
dart pub get
dart analyze
dart test
Tests use an in-memory DownloadxIo and a programmable mock fetcher — no
network, no real filesystem.
License
MIT
Libraries
- downloadx
- downloadx — an IDM-style download manager for Dart and Flutter.