Downloading a Dropbox shared folder without paying for storage
Written on
I subscribed to Crosshead Studios on Patreon to get their Dungeondraft assets. They share everything through a Dropbox link. The trouble is that whenever I tried to download the whole folder, Dropbox would zip it all up and then the download would fail halfway through, every time. And there are a lot of files in there, so I wasn’t about to click through and download them one by one by hand.
I don’t normally use Dropbox, so I figured I’d just make an account and use the desktop app to sync the folder down. Turns out there’s no way to point the desktop app at a shared link. This surprised me, because it’s something I do all the time with Google Drive. On Google Drive, when someone shares a link with you, you can view it and it shows up under “Shared with you”, or you can add a shortcut to it in your own drive. My spouse and I do that constantly to pass documents back and forth.
Dropbox doesn’t do either of those. Opening a shared link doesn’t add it to your account anywhere. “Shared with you” only shows things that were personally shared with your account, not things you opened from a link. And you can’t drop a shortcut to the link into your own Dropbox either. The only way to get the desktop app to sync it is to make a full copy of the entire folder into your own Dropbox first. But I don’t have that much space, and I’m not going to pay for a bigger plan just to download a link once.
So I made a small script with Claude Code instead. It walks the shared folder recursively through the Dropbox API and downloads each file one at a time, and if a download does get interrupted it can pick that file back up from the exact byte it stopped at. As it turned out my connection never dropped once I was running it — I’m guessing that pulling the files down one by one, instead of as one giant zip, kept any single download short enough that nothing had a chance to fail. It just worked its way through the whole folder. This is exactly the kind of one-off job I like handing to an AI.
The script only needs the Python standard library, and the setup instructions for the Dropbox token are in the comment at the top. I wanted to share it in case anyone else hits the same wall, and honestly so I have it saved for the next time I need it.
#!/usr/bin/env python3
"""
Resumable, file-by-file downloader for a Dropbox shared folder link.
Why this exists: Dropbox's "Download folder" makes one giant zip. If the
connection hiccups, you start over. This walks the shared folder, downloads
each file individually, and resumes any interrupted file from the exact byte
it stopped at (HTTP Range). Re-run it as many times as you like -- finished
files are skipped, partial files continue.
Requires only the Python standard library. No Dropbox account storage is used.
SETUP (one time, ~2 minutes):
1. Go to https://www.dropbox.com/developers/apps -> "Create app"
2. Choose:
API: Scoped access
Access: Full Dropbox
Name: anything unique, e.g. "kaan-folder-dl"
3. Open the app's "Permissions" tab and tick:
files.metadata.read
files.content.read
sharing.read
Click "Submit". (Order matters: set permissions BEFORE making a token.)
4. Back on the "Settings" tab, find "Generated access token" -> "Generate".
Copy the token.
The generated token expires after ~4 hours. That is fine -- if it dies
mid-download, generate a new one and re-run; the script picks up where it
left off.
USAGE:
export DROPBOX_TOKEN='sl.xxxxxxxx...'
python3 dropbox_folder_download.py --url 'https://www.dropbox.com/scl/fo/.../...?rlkey=...' --dest ~/Downloads/dungeondraft
Useful flags:
--list-only print the file tree and total size, download nothing
--workers 3 download N files at a time (default 1; keep it low)
"""
import argparse
import json
import os
import queue
import ssl
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
API_LIST = "https://api.dropboxapi.com/2/files/list_folder"
API_LIST_CONT = "https://api.dropboxapi.com/2/files/list_folder/continue"
API_GET_FILE = "https://content.dropboxapi.com/2/sharing/get_shared_link_file"
CHUNK = 1024 * 256
MAX_ATTEMPTS = 12
SSL_CTX = ssl.create_default_context()
print_lock = threading.Lock()
def log(msg):
with print_lock:
print(msg, flush=True)
def human(n):
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024 or unit == "TB":
return f"{n:.1f}{unit}" if unit != "B" else f"{n}B"
n /= 1024
# ---------------------------------------------------------------- link parsing
class DbxError(Exception):
def __init__(self, code, detail):
super().__init__(f"{code}: {detail}")
self.code, self.detail = code, detail
def normalize(url):
"""Return (base_link, subpath, full_link).
A Dropbox folder link can point at a subfolder:
https://www.dropbox.com/scl/fo/<id>/<hash>/Sub A/Sub B?rlkey=...
The API wants the *root* shared link plus a path inside it. We split the
URL after the <hash> segment and keep only the rlkey query param.
"""
parts = urllib.parse.urlsplit(url)
segs = [s for s in parts.path.split("/") if s]
rlkey = urllib.parse.parse_qs(parts.query).get("rlkey", [None])[0]
if not rlkey:
sys.exit("ERROR: the URL has no ?rlkey=... -- copy the full share link.")
# scl/fo/<id>/<hash>[/sub/folders...]
if len(segs) >= 4 and segs[0] == "scl" and segs[1] == "fo":
root_segs, sub_segs = segs[:4], segs[4:]
elif len(segs) >= 2 and segs[0] == "sh":
root_segs, sub_segs = segs[:3], segs[3:]
else:
root_segs, sub_segs = segs, []
base = urllib.parse.urlunsplit(
(parts.scheme, parts.netloc, "/" + "/".join(root_segs), f"rlkey={rlkey}", "")
)
full = urllib.parse.urlunsplit(
(parts.scheme, parts.netloc, "/" + "/".join(segs), f"rlkey={rlkey}", "")
)
sub = "/" + "/".join(urllib.parse.unquote(s) for s in sub_segs) if sub_segs else ""
return base, sub, full
# ------------------------------------------------------------------- transport
def request(url, token, api_arg, body=None, extra_headers=None, timeout=60):
headers = {"Authorization": f"Bearer {token}"}
if body is not None:
headers["Content-Type"] = "application/json"
if api_arg is not None:
headers["Dropbox-API-Arg"] = json.dumps(api_arg, ensure_ascii=True)
if extra_headers:
headers.update(extra_headers)
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
return urllib.request.urlopen(req, timeout=timeout, context=SSL_CTX)
def api_json(url, token, payload):
body = json.dumps(payload).encode()
for attempt in range(MAX_ATTEMPTS):
try:
with request(url, token, None, body=body) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:400]
if e.code == 401:
sys.exit("\nERROR 401: token is invalid or expired.\n"
"Generate a fresh one on your app's Settings tab and re-run.\n")
if e.code == 429 or e.code >= 500:
wait = int(e.headers.get("Retry-After", 2 ** attempt))
log(f" rate-limited/server error ({e.code}); waiting {wait}s")
time.sleep(min(wait, 60))
continue
if e.code == 409: # path/not_found etc -- caller decides
raise DbxError(e.code, detail)
sys.exit(f"\nERROR {e.code} from Dropbox:\n{detail}\n")
except (urllib.error.URLError, TimeoutError, OSError) as e:
time.sleep(min(2 ** attempt, 30))
if attempt == MAX_ATTEMPTS - 1:
sys.exit(f"\nNetwork error talking to Dropbox: {e}\n")
sys.exit("Gave up contacting Dropbox.")
# ------------------------------------------------------------------- listing
def list_one_folder(base, path, token):
"""List a single folder (one level). Dropbox forbids recursive=True on
shared links, so we walk the tree ourselves."""
args = {
"path": path,
"shared_link": {"url": base},
"recursive": False,
"include_non_downloadable_files": False,
}
page = api_json(API_LIST, token, args)
entries = list(page.get("entries", []))
while page.get("has_more"):
page = api_json(API_LIST_CONT, token, {"cursor": page["cursor"]})
entries.extend(page.get("entries", []))
return entries
def resolve_start(base, sub, full, token):
"""Work out which (link, path) pair Dropbox actually accepts.
Dropbox links of the form /scl/fo/<id>/<hash>/<subfolders> are inconsistent:
sometimes <hash> already scopes the link to the subfolder (so the path must
be ""), sometimes the link is the whole shared folder (so the path must be
the subfolder). Try both, then fall back to descending by folder name.
"""
attempts = [(base, sub), (full, ""), (base, "")]
for link, path in attempts:
if path == "" and link is base and sub:
break # handled by the descent below
try:
list_one_folder(link, path, token)
log(f" resolved: path={path or '(link root)'}")
return link, path
except DbxError:
continue
# Descend from the shared-link root, matching folder names case-insensitively.
log(" direct path rejected; walking down from the link root...")
cur = ""
for seg in [s for s in sub.split("/") if s]:
entries = list_one_folder(base, cur, token)
folders = [e for e in entries if e.get(".tag") == "folder"]
hit = next((e for e in folders
if e["name"].strip().lower() == seg.strip().lower()), None)
if not hit:
names = "\n ".join(e["name"] for e in entries) or "(empty)"
sys.exit(f"\nCould not find a folder named '{seg}' inside "
f"'{cur or 'the shared link root'}'.\n"
f" What's actually there:\n {names}\n\n"
f"Pass --start-path with one of those names if the link "
f"already points inside the folder you want.\n")
cur = hit.get("path_display") or f"{cur}/{hit['name']}"
log(f" found: {cur}")
return base, cur
def list_files(base, sub, token):
"""Walk every folder under the shared link, breadth-first. Returns files."""
files = []
pending = [sub] # "" means the shared-link root
seen = set()
while pending:
folder = pending.pop(0)
if folder in seen:
continue
seen.add(folder)
for e in list_one_folder(base, folder, token):
tag = e.get(".tag")
# path_display is relative to the shared-link root, e.g.
# /3. DUNGEONDRAFT ASSETS/NEW ARTSTYLE/Walls/foo.dungeondraft_pack
p = e.get("path_display") or (folder.rstrip("/") + "/" + e["name"])
if tag == "file":
files.append({"path": p, "name": e["name"], "size": e.get("size", 0)})
elif tag == "folder":
pending.append(p)
log(f" scanned {folder or '(root)'} -- {len(files)} files so far, "
f"{len(pending)} folders queued")
return files
# ------------------------------------------------------------------ downloading
def download(f, base, dest_root, sub, token):
rel = f["path"]
# Strip the subfolder prefix so we don't recreate the whole tree above it.
trimmed = rel[len(sub):] if sub and rel.lower().startswith(sub.lower()) else rel
out = os.path.join(dest_root, trimmed.lstrip("/"))
part = out + ".part"
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
expected = f["size"]
if os.path.exists(out) and os.path.getsize(out) == expected:
log(f" skip (done) {trimmed}")
return "skipped", expected
for attempt in range(MAX_ATTEMPTS):
have = os.path.getsize(part) if os.path.exists(part) else 0
if have > expected: # corrupt leftover
os.remove(part)
have = 0
if have == expected and expected > 0:
os.replace(part, out)
log(f" ok {trimmed} ({human(expected)})")
return "done", expected
headers = {"Range": f"bytes={have}-"} if have else {}
arg = {"url": base, "path": rel}
try:
with request(API_GET_FILE, token, arg, extra_headers=headers, timeout=120) as r:
# If the server ignored our Range, restart the file cleanly.
mode = "ab" if (have and r.status == 206) else "wb"
if mode == "wb":
have = 0
with open(part, mode) as fh:
while True:
buf = r.read(CHUNK)
if not buf:
break
fh.write(buf)
have += len(buf)
if have >= expected or expected == 0:
os.replace(part, out)
log(f" ok {trimmed} ({human(max(have, expected))})")
return "done", have
log(f" short read {trimmed} ({human(have)}/{human(expected)}), resuming")
except urllib.error.HTTPError as e:
if e.code == 401:
sys.exit("\nERROR 401: token expired mid-download.\n"
"Generate a new token and re-run -- progress is kept.\n")
if e.code == 416: # range past end: file is complete
os.replace(part, out)
return "done", expected
if e.code == 429 or e.code >= 500:
wait = int(e.headers.get("Retry-After", 2 ** attempt))
log(f" retry in {wait}s ({e.code}) {trimmed}")
time.sleep(min(wait, 60))
continue
log(f" FAILED {e.code} {trimmed}: {e.read().decode(errors='replace')[:200]}")
return "failed", 0
except (urllib.error.URLError, TimeoutError, OSError, ssl.SSLError) as e:
wait = min(2 ** attempt, 30)
log(f" interrupted ({type(e).__name__}); resuming in {wait}s {trimmed}")
time.sleep(wait)
log(f" FAILED after {MAX_ATTEMPTS} attempts {trimmed}")
return "failed", 0
# ------------------------------------------------------------------------ main
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True, help="Dropbox shared folder link")
ap.add_argument("--dest", required=True, help="local folder to download into")
ap.add_argument("--token", default=os.environ.get("DROPBOX_TOKEN"))
ap.add_argument("--list-only", action="store_true")
ap.add_argument("--workers", type=int, default=1)
ap.add_argument("--start-path", default=None,
help="override the folder inside the link to start from, "
"e.g. '' for the link root")
a = ap.parse_args()
if not a.token:
sys.exit("No token. Set DROPBOX_TOKEN or pass --token. See the header of this file.")
base, sub, full = normalize(a.url)
dest = os.path.abspath(os.path.expanduser(a.dest))
os.makedirs(dest, exist_ok=True)
log(f"Shared link : {base}")
log(f"Subfolder : {sub or '(root)'}")
log(f"Destination : {dest}\nResolving folder...")
if a.start_path is not None:
base, sub = base, a.start_path
else:
base, sub = resolve_start(base, sub, full, a.token)
log("Listing files...")
files = list_files(base, sub, a.token)
if not files:
sys.exit("No files found. Check the link is a folder link and still valid.")
total = sum(f["size"] for f in files)
log(f"{len(files)} files, {human(total)} total.\n")
if a.list_only:
for f in sorted(files, key=lambda x: x["path"]):
log(f" {human(f['size']):>9} {f['path']}")
return
already = 0
for f in files:
rel = f["path"]
trimmed = rel[len(sub):] if sub and rel.lower().startswith(sub.lower()) else rel
out = os.path.join(dest, trimmed.lstrip("/"))
if os.path.exists(out) and os.path.getsize(out) == f["size"]:
already += f["size"]
if already:
log(f"{human(already)} already on disk; will skip those.\n")
q = queue.Queue()
for f in sorted(files, key=lambda x: x["path"]):
q.put(f)
stats = {"done": 0, "skipped": 0, "failed": 0, "bytes": 0}
stats_lock = threading.Lock()
def worker():
while True:
try:
f = q.get_nowait()
except queue.Empty:
return
try:
status, n = download(f, base, dest, sub, a.token)
except Exception as e: # never kill the run
log(f" ERROR {f['path']}: {e}")
status, n = "failed", 0
with stats_lock:
stats[status] += 1
stats["bytes"] += n
pct = 100 * stats["bytes"] / total if total else 100
if (stats["done"] + stats["skipped"] + stats["failed"]) % 10 == 0:
log(f" -- {pct:.1f}% ({human(stats['bytes'])} / {human(total)})")
q.task_done()
threads = [threading.Thread(target=worker, daemon=True)
for _ in range(max(1, a.workers))]
for t in threads:
t.start()
for t in threads:
t.join()
log(f"\nFinished. {stats['done']} downloaded, {stats['skipped']} already had, "
f"{stats['failed']} failed.")
if stats["failed"]:
log("Re-run the same command to retry the failures; completed files are skipped.")
if __name__ == "__main__":
main()One small note: the app you make needs “Full Dropbox” access to read a shared link that isn’t yours, which sounds worse than it is. The token expires on its own after a few hours, so there’s not much to worry about — delete the app once you’ve got your files if you want to tidy up.
