Last active
November 21, 2025 15:04
-
-
Save billchurch/1e35cddaa749d20cc9d3dfccf4d88efb to your computer and use it in GitHub Desktop.
Download a file using multiple parallel HTTP range requests, then reassemble the file in the correct order, showing overall progress.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # chunked_curl.sh | |
| # | |
| # Download a file using multiple parallel HTTP range requests, then | |
| # reassemble the file in the correct order, showing overall progress. | |
| # | |
| # Bill Church - November 2025 | |
| set -euo pipefail | |
| chunks=16 | |
| url="" | |
| output_file="" | |
| usage() { | |
| echo "Usage: $0 -u URL [-o output_file] [-n chunks]" >&2 | |
| exit 1 | |
| } | |
| get_remote_size() { | |
| local remote_url="$1" | |
| local size | |
| size=$( | |
| curl -sI "$remote_url" \ | |
| | awk '/Content-Length/ {print $2}' \ | |
| | tr -d '\r' | |
| ) | |
| if [ -z "$size" ]; then | |
| echo "Could not determine Content-Length for $remote_url" >&2 | |
| exit 1 | |
| fi | |
| echo "$size" | |
| } | |
| derive_output_name() { | |
| local remote_url="$1" | |
| local name | |
| name=$(basename "$remote_url") | |
| if [ -z "$name" ] || [ "$name" = "/" ]; then | |
| name="output.bin" | |
| fi | |
| echo "$name" | |
| } | |
| get_local_size() { | |
| local file="$1" | |
| local size | |
| if stat -f "%z" "$file" >/dev/null 2>&1; then | |
| size=$(stat -f "%z" "$file") | |
| else | |
| size=$(stat -c "%s" "$file") | |
| fi | |
| echo "$size" | |
| } | |
| ############################################################################### | |
| # Monitor total downloaded bytes across all part files | |
| # Now exits when current >= total so the script does not hang. | |
| ############################################################################### | |
| monitor_bytes() { | |
| local tmpdir="$1" | |
| local total="$2" | |
| while true; do | |
| sleep 1 | |
| local current=0 | |
| local size | |
| local f | |
| for f in "$tmpdir"/part-*; do | |
| [ -f "$f" ] || continue | |
| size=$( | |
| stat -f "%z" "$f" 2>/dev/null \ | |
| || stat -c "%s" "$f" 2>/dev/null \ | |
| || echo 0 | |
| ) | |
| current=$(( current + size )) | |
| done | |
| if [ "$total" -gt 0 ]; then | |
| if [ "$current" -ge "$total" ]; then | |
| printf "\rDownloaded: %d/%d bytes (100%%)\n" \ | |
| "$total" "$total" | |
| break | |
| fi | |
| local percent=$(( current * 100 / total )) | |
| printf "\rDownloaded: %d/%d bytes (%d%%)" \ | |
| "$current" "$total" "$percent" | |
| else | |
| printf "\rDownloaded: %d bytes" "$current" | |
| fi | |
| done | |
| } | |
| while getopts "u:o:n:h" opt; do | |
| case "$opt" in | |
| u) url="$OPTARG" ;; | |
| o) output_file="$OPTARG" ;; | |
| n) chunks="$OPTARG" ;; | |
| h|*) usage ;; | |
| esac | |
| done | |
| if [ -z "$url" ]; then | |
| usage | |
| fi | |
| if ! [[ "$chunks" =~ ^[0-9]+$ ]] || [ "$chunks" -le 0 ]; then | |
| echo "Chunks must be a positive integer" >&2 | |
| exit 1 | |
| fi | |
| if [ -z "$output_file" ]; then | |
| output_file=$(derive_output_name "$url") | |
| fi | |
| echo "URL: $url" | |
| echo "Output file: $output_file" | |
| echo "Chunks: $chunks" | |
| remote_size=$(get_remote_size "$url") | |
| echo "Remote size: $remote_size bytes" | |
| chunk_size=$(( remote_size / chunks )) | |
| if [ "$chunk_size" -le 0 ]; then | |
| echo "Chunk size computed as zero, reduce number of chunks" >&2 | |
| exit 1 | |
| fi | |
| echo "Chunk size: $chunk_size bytes" | |
| tmpdir=$(mktemp -d -t chunked_curl.XXXXXX) | |
| echo "Temp dir: $tmpdir" | |
| cleanup() { | |
| rm -rf "$tmpdir" | |
| } | |
| trap cleanup EXIT | |
| echo "Starting parallel downloads..." | |
| i=0 | |
| last_index=$(( chunks - 1 )) | |
| pids=() | |
| while [ "$i" -lt "$chunks" ]; do | |
| start=$(( i * chunk_size )) | |
| if [ "$i" -eq "$last_index" ]; then | |
| range="${start}-" | |
| else | |
| end=$(( (i + 1) * chunk_size - 1 )) | |
| range="${start}-${end}" | |
| fi | |
| part_file="$tmpdir/part-$i" | |
| echo " [part $i] bytes=$range" | |
| curl -s \ | |
| -H "Range: bytes=${range}" \ | |
| "$url" \ | |
| -o "$part_file" & | |
| pids+=( "$!" ) | |
| i=$(( i + 1 )) | |
| done | |
| # Start progress monitor in background | |
| monitor_bytes "$tmpdir" "$remote_size" & | |
| monitor_pid=$! | |
| # Wait only for curl jobs, not for the monitor explicitly | |
| for pid in "${pids[@]}"; do | |
| wait "$pid" | |
| done | |
| # Once all chunks are done, the monitor will naturally hit 100 percent | |
| # and exit on its own shortly. Give it a moment then move on. | |
| wait "$monitor_pid" 2>/dev/null || true | |
| echo "All chunks downloaded." | |
| echo "Reassembling into $output_file..." | |
| : > "$output_file" | |
| i=0 | |
| while [ "$i" -lt "$chunks" ]; do | |
| part_file="$tmpdir/part-$i" | |
| if [ ! -f "$part_file" ]; then | |
| echo "Missing part file: $part_file" >&2 | |
| exit 1 | |
| fi | |
| cat "$part_file" >> "$output_file" | |
| i=$(( i + 1 )) | |
| done | |
| local_size=$(get_local_size "$output_file") | |
| echo "Local size: $local_size bytes" | |
| if [ "$local_size" -ne "$remote_size" ]; then | |
| echo "Warning: local size does not match remote size" >&2 | |
| echo " Remote: $remote_size" >&2 | |
| echo " Local: $local_size" >&2 | |
| exit 1 | |
| fi | |
| echo "Success. File downloaded and verified." |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Chunked cURL Downloader
A shell script that downloads large files using multiple parallel HTTP range requests, bypassing per-connection rate limits.
This is useful when a server throttles each connection to a slow speed, but allows multiple simultaneous connections. In my case I had a 1GB file which was rate-limited to 256Kb/sec with an ETA over an hour. I originally chunked it with curl manually to get it in a few minutes, while the original was still downloading, I was able to then refine it into the script above.
Description
No external dependencies beyond standard Unix utilities.
Features
Usage
Basic
./chunked_curl.sh -u "<https://example.com/file.zip>"This downloads the file into the current directory using 16 chunks.
Specify output file
./chunked_curl.sh \ -u "<https://example.com/file.zip>" \ -o file.zipChange number of chunks
Example with 32 chunks:
./chunked_curl.sh \ -u "<https://example.com/file.zip>" \ -o file.zip \ -n 32More chunks usually means faster downloads if the server limits per-connection throughput.
#3 Full Options
-u URL-o OUTPUT-n CHUNKS-hExample
Downloading a throttled S3 file with 32 parallel connections:
./chunked_curl.sh \ -u "https://prod-ratta-firmware.s3.ap-northeast-1.amazonaws.com/239749/update.zip" \ -o update.zip \ -n 32If the server throttles each connection to 256 KB/s, using 32 chunks can effectively push the composite rate > 8 MB/s depending on network conditions.
Output
During download, you’ll see lines like:
When complete:
Notes
License
MIT License.
Feel free to copy, modify, or embed into your own tools.