#!/usr/bin/env python3
"""
===============================================================================
 DNFileVault - Sync / Mirror Script  (heavily commented reference version)
===============================================================================

 WHAT THIS SCRIPT DOES
 ---------------------
 1. Asks you (or reads from environment variables) for your DNFileVault
    email + password.
 2. Logs in to the API and receives a JWT "token" (your temporary key).
 3. Lists every PURCHASE you own and every GROUP (subscription) you belong to.
 4. Builds a complete list of every file you are entitled to download.
 5. Compares that list against what already exists on your local drive.
 6. Downloads ONLY the files that are missing (or the wrong size).

 DOWNLOAD ORDER (this is the important part)
 -------------------------------------------
 For every file, the script tries TWO sources, in this order:

     ATTEMPT 1 -> CLOUD  (Cloudflare R2 CDN)
                  URL comes from the file's "cloud_share_link" field.
                  Example: https://vault.dnfilevault.com/<uuid>
                  This is a CDN edge download. It is normally MUCH faster
                  because it is served from a Cloudflare data center near
                  you instead of from the origin server. No auth header
                  is needed - the UUID in the URL is the access token.

     ATTEMPT 2 -> LOCAL  (DNFileVault origin server)
                  URL: https://api.dnfilevault.com/download/<uuid>
                  Requires your Bearer token. This is the fallback used
                  when the file has no cloud_share_link yet (very new
                  files are uploaded to R2 by a background daemon, so
                  there can be a short window with no cloud link), or
                  when the cloud attempt fails for any reason.

 If BOTH attempts fail, the script reports the file as failed and moves on
 to the next one. Nothing is deleted, nothing is overwritten in place - each
 file is written to a ".part" temp name first and only renamed into place
 after the download finishes successfully.

 HOW TO RUN
 ----------
     pip install requests
     python dnfilevault_sync_mirror.py

 Optional - avoid typing credentials every time by setting env vars:
     Windows :  set DNFILEVAULT_EMAIL=you@example.com
                set DNFILEVAULT_PASSWORD=yourpassword
     Linux   :  export DNFILEVAULT_EMAIL=you@example.com
                export DNFILEVAULT_PASSWORD=yourpassword

 Optional - change where files land (default is ./DNFileVault next to script):
     set DNFILEVAULT_SYNC_DIR=D:\\MarketData\\DNFileVault

 Command line switches:
     --dry-run     Show what WOULD be downloaded, but download nothing.
     --dir PATH    Override the sync folder for this run.

===============================================================================
"""

import os
import sys
import time
import getpass
import argparse

# 'requests' is the only third-party library used. Install with: pip install requests
import requests


# =============================================================================
# SECTION 1 - CONFIGURATION
# =============================================================================
# Everything you might want to change lives in this block. There are no other
# hard-coded settings further down in the file.

# The public API endpoint. Do not add a trailing slash.
BASE_URL = "https://api.dnfilevault.com"

# DNFileVault has anti-scanner protection. Requests that look "bot-like"
# (blank or default User-Agent strings) may be deliberately slowed down.
# ALWAYS send a real, descriptive User-Agent that identifies your client.
USER_AGENT = "DNFileVaultSyncClient/1.0 (+support@deltaneutral.com)"

# Where the mirrored files are stored on your machine. The script creates
# subfolders inside here, one per purchase and one per group.
DEFAULT_SYNC_DIR = os.environ.get(
    "DNFILEVAULT_SYNC_DIR",
    os.path.join(os.path.dirname(os.path.abspath(__file__)), "DNFileVault"),
)

# Network timeouts, in seconds.
#   LOGIN/LIST calls are small and fast, so a short timeout is fine.
#   DOWNLOAD calls move multi-gigabyte files, so they need a long timeout.
TIMEOUT_API = 60
TIMEOUT_DOWNLOAD = 900          # 15 minutes; raise this if you have slow internet

# How many bytes to pull off the network at a time while streaming a download.
# 1 MB is a good balance between memory use and syscall overhead.
CHUNK_SIZE = 1024 * 1024

# How many times to retry a single source (cloud or local) before giving up
# on that source and moving to the next one.
RETRIES_PER_SOURCE = 2

# Seconds to wait between retry attempts.
RETRY_DELAY = 5


# =============================================================================
# SECTION 2 - SMALL HELPER FUNCTIONS
# =============================================================================

def log(message):
    """
    Print a timestamped message. Using a helper (instead of bare print())
    means every line of output is consistently formatted and you can easily
    redirect it to a log file later.
    """
    stamp = time.strftime("%Y-%m-%d %H:%M:%S")
    print(f"[{stamp}] {message}", flush=True)


def human_size(num_bytes):
    """
    Convert a raw byte count into something readable, e.g. 70338485 -> '67.1 MB'.
    Purely cosmetic - only used for progress messages.
    """
    if num_bytes is None:
        return "unknown size"
    size = float(num_bytes)
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if size < 1024.0:
            return f"{size:.1f} {unit}"
        size /= 1024.0
    return f"{size:.1f} PB"


def safe_folder_name(name):
    r"""
    Turn an arbitrary product or group name into something safe to use as a
    Windows/Linux folder name.

    Example: 'Level 2 Full History' -> 'Level 2 Full History'
             'L3/EOD: 2026'         -> 'L3_EOD_ 2026'

    Windows forbids these characters in file names: < > : " / \ | ? *
    We replace each of them with an underscore.
    """
    if not name:
        return "Unnamed"
    cleaned = str(name)
    for bad_char in '<>:"/\\|?*':
        cleaned = cleaned.replace(bad_char, "_")
    # Windows also dislikes trailing dots and spaces on folder names.
    return cleaned.strip().rstrip(".") or "Unnamed"


def build_session():
    """
    Create a requests.Session object.

    WHY A SESSION INSTEAD OF PLAIN requests.get()?
      - It keeps the TCP connection alive between calls (much faster when you
        are downloading dozens of files in a row).
      - Headers set on the session are automatically sent with every request,
        so you only have to set the User-Agent once.
    """
    session = requests.Session()
    session.headers.update({"User-Agent": USER_AGENT})
    return session


# =============================================================================
# SECTION 3 - AUTHENTICATION  (get your key)
# =============================================================================

def get_credentials():
    """
    Collect the customer's email and password.

    Order of preference:
      1. Environment variables DNFILEVAULT_EMAIL / DNFILEVAULT_PASSWORD.
         Best for scheduled/automated runs - nothing is typed and nothing
         is stored in the script itself.
      2. Interactive prompt. getpass.getpass() hides the password as you type
         so it does not appear on screen or in your shell history.

    NOTE: Never hard-code your password into this file. If you commit the file
    to source control your credentials go with it.
    """
    email = os.environ.get("DNFILEVAULT_EMAIL")
    password = os.environ.get("DNFILEVAULT_PASSWORD")

    if not email:
        email = input("DNFileVault email: ").strip()
    if not password:
        password = getpass.getpass("DNFileVault password: ")

    if not email or not password:
        log("ERROR: email and password are both required.")
        sys.exit(1)

    return email, password


def login(session, email, password):
    """
    Exchange email + password for a JWT token.

    The API returns JSON shaped like:
        {"success": true, "token": "eyJhbGciOi..."}

    That token is your "key". It is placed in the Authorization header on
    every subsequent request:
        Authorization: Bearer eyJhbGciOi...

    IMPORTANT: tokens expire after 24 hours. For a long-running sync this is
    plenty of time, but if you ever see a 401 mid-run, just log in again.
    """
    log(f"Logging in as {email} ...")

    response = session.post(
        f"{BASE_URL}/auth/login",
        json={"email": email, "password": password},
        timeout=TIMEOUT_API,
    )

    # A 401 here means bad credentials. Anything else non-200 is a server or
    # network problem worth showing in full.
    if response.status_code == 401:
        log("ERROR: Login failed - invalid email or password.")
        sys.exit(1)
    if response.status_code != 200:
        log(f"ERROR: Login failed with HTTP {response.status_code}: {response.text[:300]}")
        sys.exit(1)

    token = response.json().get("token")
    if not token:
        log("ERROR: Login succeeded but no token was returned.")
        sys.exit(1)

    # Attach the token to the session so every later call is authenticated.
    session.headers["Authorization"] = f"Bearer {token}"

    log("Login successful - token acquired.")
    return token


# =============================================================================
# SECTION 4 - DISCOVERY  (what am I entitled to?)
# =============================================================================
#
# DNFileVault has TWO separate ways you can own files:
#
#   PURCHASES  - a one-time product you bought (e.g. "Level 2 Full History").
#                Files hang off the purchase. Endpoint: /purchases
#
#   GROUPS     - an ongoing subscription/data feed (e.g. "eodLevel3").
#                New files are added daily. Endpoint: /groups
#
# A customer can have either, both, or neither. This script handles all cases,
# so do not be alarmed if one of the two lists comes back empty.
# =============================================================================

def list_purchases(session):
    """
    GET /purchases

    Returns JSON: {"purchases": [...], "count": N}

    Each purchase looks like:
        {
          "id": 152,
          "product_name": "Level 2 Full History",
          "description": "Order 69524",
          "purchase_date": "Tue, 21 Jul 2026 11:02:56 GMT",
          "expiration_date": "Wed, 21 Jul 2027 15:02:56 GMT",
          "is_subscription": 0
        }
    """
    log("Fetching your purchases ...")
    response = session.get(f"{BASE_URL}/purchases", timeout=TIMEOUT_API)
    response.raise_for_status()

    purchases = response.json().get("purchases", [])
    log(f"  Found {len(purchases)} purchase(s).")
    return purchases


def list_purchase_files(session, purchase_id):
    """
    GET /purchases/<id>/files

    Returns JSON: {"purchase": {...}, "files": [...], "count": N}

    Each file record looks like:
        {
          "uuid_filename": "fd217fd2-98b3-496b-9b44-01e7fccb8051",
          "display_name": "L3_20260724.zip",
          "file_size": 70338485,
          "created_at": "Fri, 24 Jul 2026 22:10:03 GMT",
          "cloud_share_link": "https://vault.dnfilevault.com/fd217fd2-..."
        }

    NOTE: "cloud_share_link" is OPTIONAL. It only appears once the background
    R2 uploader has pushed the file to Cloudflare. Brand-new files may not
    have it yet - that is exactly why this script has a local fallback.
    """
    response = session.get(
        f"{BASE_URL}/purchases/{purchase_id}/files",
        timeout=TIMEOUT_API,
    )
    response.raise_for_status()
    return response.json().get("files", [])


def list_groups(session):
    """
    GET /groups

    Returns JSON: {"groups": [...], "count": N}

    Each group looks like:
        {
          "id": 2,
          "name": "eodLevel3",
          "description": "Level 3 End of Day option data",
          "file_count": 412,
          "expires_at": "Sun, 23 May 2027 23:59:59 GMT",
          "notify_on_new_files": 1
        }
    """
    log("Fetching your subscription groups ...")
    response = session.get(f"{BASE_URL}/groups", timeout=TIMEOUT_API)
    response.raise_for_status()

    groups = response.json().get("groups", [])
    log(f"  Found {len(groups)} group subscription(s).")
    return groups


def list_group_files(session, group_id):
    """
    GET /groups/<id>/files

    Same file record shape as list_purchase_files(). Returns every file
    currently published to that group.
    """
    response = session.get(
        f"{BASE_URL}/groups/{group_id}/files",
        timeout=TIMEOUT_API,
    )
    response.raise_for_status()
    return response.json().get("files", [])


# =============================================================================
# SECTION 5 - THE DOWNLOAD ENGINE  (cloud first, then local)
# =============================================================================

def stream_to_disk(response, destination_path, expected_size):
    """
    Write an already-open streaming HTTP response to disk.

    SAFETY PATTERN: we write to "<destination>.part" first, then rename to the
    real name only after the whole body has been written AND the size checks
    out. This guarantees you can never end up with a truncated file sitting
    there looking complete - if the connection drops mid-transfer, the leftover
    .part file is discarded and the real file is simply still missing, so the
    next run will retry it.

    Returns True on success, False on failure.
    """
    temp_path = destination_path + ".part"
    bytes_written = 0

    try:
        with open(temp_path, "wb") as handle:
            # iter_content() yields the body in CHUNK_SIZE pieces so we never
            # load a multi-gigabyte file entirely into RAM.
            for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
                if chunk:                       # filter out keep-alive chunks
                    handle.write(chunk)
                    bytes_written += len(chunk)

        # Size verification. The API tells us how big the file should be, so
        # a mismatch means the transfer was cut short even if no error was
        # raised. Treat that as a failure so the other source gets a turn.
        if expected_size and bytes_written != expected_size:
            log(f"    Size mismatch: got {bytes_written} bytes, expected {expected_size}.")
            os.remove(temp_path)
            return False

        # Atomically move the finished .part file into its final name.
        # os.replace() overwrites the target if it exists and is atomic on
        # both Windows and Linux.
        os.replace(temp_path, destination_path)
        return True

    except Exception as error:
        log(f"    Transfer error: {error}")
        # Clean up the partial file so it does not confuse the next run.
        if os.path.exists(temp_path):
            try:
                os.remove(temp_path)
            except OSError:
                pass
        return False


def download_from_cloud(session, cloud_url, destination_path, expected_size):
    """
    ATTEMPT 1 - Cloudflare R2 CDN.

    The cloud_share_link is a public, unguessable URL of the form
        https://vault.dnfilevault.com/<uuid_filename>

    Because the UUID itself is the secret, NO Authorization header is required.
    In fact we deliberately strip it: sending your DNFileVault Bearer token to
    a third-party CDN host is unnecessary and is bad security hygiene.

    This route is normally the fastest option - Cloudflare serves the bytes
    from an edge location near the customer rather than from the origin server
    in the US, and it does not consume origin bandwidth.

    Returns True on success, False if this source should be abandoned.
    """
    log(f"    [CLOUD] Trying Cloudflare CDN: {cloud_url}")

    for attempt in range(1, RETRIES_PER_SOURCE + 1):
        try:
            # requests.get() is used here rather than session.get() precisely
            # so that the session's Authorization header is NOT sent.
            response = requests.get(
                cloud_url,
                headers={"User-Agent": USER_AGENT},
                stream=True,
                timeout=TIMEOUT_DOWNLOAD,
            )

            # 404 means the object is not on R2 (yet). Retrying will not help,
            # so break out immediately and let the caller fall back to local.
            if response.status_code == 404:
                log("    [CLOUD] Not found on CDN (404) - will fall back to local.")
                response.close()
                return False

            if response.status_code != 200:
                log(f"    [CLOUD] HTTP {response.status_code} on attempt {attempt}.")
                response.close()
                if attempt < RETRIES_PER_SOURCE:
                    time.sleep(RETRY_DELAY)
                continue

            try:
                ok = stream_to_disk(response, destination_path, expected_size)
            finally:
                response.close()

            if ok:
                log("    [CLOUD] Success.")
                return True

            # stream_to_disk already logged why it failed; retry if we can.
            if attempt < RETRIES_PER_SOURCE:
                time.sleep(RETRY_DELAY)

        except requests.RequestException as error:
            log(f"    [CLOUD] Network error on attempt {attempt}: {error}")
            if attempt < RETRIES_PER_SOURCE:
                time.sleep(RETRY_DELAY)

    log("    [CLOUD] Giving up on CDN - falling back to local.")
    return False


def download_from_local(session, uuid_filename, destination_path, expected_size):
    """
    ATTEMPT 2 - DNFileVault origin server.

    URL: https://api.dnfilevault.com/download/<uuid_filename>

    Unlike the CDN route, this endpoint IS authenticated - the session's
    Bearer token proves you are entitled to the file. The origin server also
    records the download in your account history.

    Use this when:
      - the file has no cloud_share_link yet (newly published files), or
      - the CDN attempt failed for any reason.

    Returns True on success, False on failure.
    """
    origin_url = f"{BASE_URL}/download/{uuid_filename}"
    log(f"    [LOCAL] Trying origin server: {origin_url}")

    for attempt in range(1, RETRIES_PER_SOURCE + 1):
        try:
            # session.get() DOES include the Authorization header - required here.
            response = session.get(origin_url, stream=True, timeout=TIMEOUT_DOWNLOAD)

            # 401 -> token expired or invalid. 403/410 -> your access to this
            # product/subscription has expired. None of these are retryable.
            if response.status_code in (401, 403, 410):
                log(f"    [LOCAL] Access denied (HTTP {response.status_code}). "
                    f"Your purchase or subscription may have expired.")
                response.close()
                return False

            if response.status_code != 200:
                log(f"    [LOCAL] HTTP {response.status_code} on attempt {attempt}.")
                response.close()
                if attempt < RETRIES_PER_SOURCE:
                    time.sleep(RETRY_DELAY)
                continue

            try:
                ok = stream_to_disk(response, destination_path, expected_size)
            finally:
                response.close()

            if ok:
                log("    [LOCAL] Success.")
                return True

            if attempt < RETRIES_PER_SOURCE:
                time.sleep(RETRY_DELAY)

        except requests.RequestException as error:
            log(f"    [LOCAL] Network error on attempt {attempt}: {error}")
            if attempt < RETRIES_PER_SOURCE:
                time.sleep(RETRY_DELAY)

    return False


def download_file(session, file_record, destination_folder, dry_run=False):
    """
    Download ONE file into destination_folder, using the cloud-first strategy.

    Returns one of three strings so the caller can tally results:
        "skipped"  - already present locally with the correct size
        "ok"       - downloaded successfully
        "failed"   - both cloud and local attempts failed
    """
    display_name = file_record.get("display_name")
    uuid_filename = file_record.get("uuid_filename")
    expected_size = file_record.get("file_size")
    cloud_url = file_record.get("cloud_share_link")   # may be absent!

    # Defensive check - a record with no uuid cannot be downloaded at all.
    if not uuid_filename or not display_name:
        log(f"  SKIP (malformed record): {file_record}")
        return "failed"

    destination_path = os.path.join(destination_folder, display_name)

    # ---------------------------------------------------------------------
    # MIRROR LOGIC: is this file already on disk and complete?
    #
    # We compare file size rather than just checking existence, because a
    # previous run that was killed halfway could have left a short file.
    # (The .part pattern in stream_to_disk makes that unlikely, but a file
    # copied in by hand or interrupted by a power cut could still be short.)
    #
    # If you want a stricter check you could compare SHA-256 checksums, but
    # that requires re-reading every local file on every run, which is very
    # slow for hundreds of gigabytes. Size is the practical choice.
    # ---------------------------------------------------------------------
    if os.path.exists(destination_path):
        local_size = os.path.getsize(destination_path)
        if not expected_size or local_size == expected_size:
            return "skipped"
        log(f"  RE-DOWNLOAD {display_name}: local size {local_size} "
            f"!= expected {expected_size}")

    log(f"  NEED {display_name} ({human_size(expected_size)})")

    # In dry-run mode we stop right here - report the intent, transfer nothing.
    if dry_run:
        source = "CLOUD" if cloud_url else "LOCAL (no cloud link available)"
        log(f"    [DRY RUN] Would download from {source}")
        return "ok"

    # -------------------------------------------------------------------------
    # ATTEMPT 1: Cloudflare R2 CDN - only possible if the API gave us a link.
    # -------------------------------------------------------------------------
    if cloud_url:
        if download_from_cloud(session, cloud_url, destination_path, expected_size):
            return "ok"
    else:
        log("    [CLOUD] No cloud_share_link on this record - skipping CDN attempt.")

    # -------------------------------------------------------------------------
    # ATTEMPT 2: origin server fallback.
    # -------------------------------------------------------------------------
    if download_from_local(session, uuid_filename, destination_path, expected_size):
        return "ok"

    log(f"  FAILED: {display_name} could not be downloaded from either source.")
    return "failed"


# =============================================================================
# SECTION 6 - MIRROR ONE COLLECTION (a purchase or a group)
# =============================================================================

def mirror_collection(session, label, subfolders, files, sync_root, dry_run):
    """
    Download every missing file for a single purchase or group.

    label       - human-readable name used in log output, e.g. "GROUP eodLevel3"
    subfolders  - list of path parts to create under the sync root,
                  e.g. ["groups", "eodLevel3"]. Each part is sanitised
                  SEPARATELY, then joined - if you sanitised the whole joined
                  path at once the "/" separator itself would be replaced with
                  an underscore and you would get one flat folder named
                  "groups_eodLevel3" instead of a proper nested folder.
    files       - list of file records from the API
    sync_root   - top-level mirror directory
    dry_run     - if True, report but do not transfer

    Returns a (downloaded, skipped, failed) tuple of counts.
    """
    # Sanitise each path component on its own, then join.
    clean_parts = [safe_folder_name(part) for part in subfolders]
    destination_folder = os.path.join(sync_root, *clean_parts)

    # exist_ok=True means "do not raise an error if the folder already exists",
    # which is the normal case on every run after the first.
    os.makedirs(destination_folder, exist_ok=True)

    log(f"{label}: {len(files)} file(s) published -> {destination_folder}")

    downloaded = skipped = failed = 0

    for file_record in files:
        result = download_file(session, file_record, destination_folder, dry_run)
        if result == "ok":
            downloaded += 1
        elif result == "skipped":
            skipped += 1
        else:
            failed += 1

    log(f"{label}: {downloaded} downloaded, {skipped} already present, {failed} failed.")
    return downloaded, skipped, failed


# =============================================================================
# SECTION 7 - MAIN PROGRAM
# =============================================================================

def main():
    # ---- Parse command line switches -------------------------------------
    parser = argparse.ArgumentParser(
        description="Mirror your DNFileVault purchases and subscriptions to a local folder."
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="List what would be downloaded without transferring anything.",
    )
    parser.add_argument(
        "--dir",
        default=DEFAULT_SYNC_DIR,
        help=f"Local mirror folder (default: {DEFAULT_SYNC_DIR})",
    )
    args = parser.parse_args()

    sync_root = os.path.abspath(args.dir)

    log("=" * 70)
    log("DNFileVault Sync / Mirror")
    log(f"API endpoint : {BASE_URL}")
    log(f"Mirror folder: {sync_root}")
    if args.dry_run:
        log("MODE         : DRY RUN (nothing will be downloaded)")
    log("=" * 70)

    os.makedirs(sync_root, exist_ok=True)

    # ---- Step 1: authenticate --------------------------------------------
    session = build_session()
    email, password = get_credentials()
    login(session, email, password)

    # Running totals across everything we mirror.
    total_downloaded = total_skipped = total_failed = 0

    # ---- Step 2: mirror every PURCHASE -----------------------------------
    purchases = list_purchases(session)

    for purchase in purchases:
        purchase_id = purchase.get("id")
        product_name = purchase.get("product_name", f"Purchase {purchase_id}")
        expires = purchase.get("expiration_date", "no expiration")

        log("-" * 70)
        log(f"PURCHASE {purchase_id}: {product_name}   (expires: {expires})")

        try:
            files = list_purchase_files(session, purchase_id)
        except requests.RequestException as error:
            log(f"  ERROR listing files for purchase {purchase_id}: {error}")
            total_failed += 1
            continue

        # Purchases are filed under: <sync_root>/purchases/<id> - <product name>/
        # The purchase id is included because a customer can own the SAME
        # product more than once (e.g. renewed a year later); without the id
        # both would land in one folder and overwrite each other.
        d, s, f = mirror_collection(
            session,
            label=f"PURCHASE {product_name}",
            subfolders=["purchases", f"{purchase_id} - {product_name}"],
            files=files,
            sync_root=sync_root,
            dry_run=args.dry_run,
        )
        total_downloaded += d
        total_skipped += s
        total_failed += f

    # ---- Step 3: mirror every GROUP / SUBSCRIPTION ------------------------
    groups = list_groups(session)

    for group in groups:
        group_id = group.get("id")
        group_name = group.get("name", f"Group {group_id}")
        expires = group.get("expires_at", "no expiration")

        log("-" * 70)
        log(f"GROUP {group_id}: {group_name}   (expires: {expires})")

        try:
            files = list_group_files(session, group_id)
        except requests.RequestException as error:
            log(f"  ERROR listing files for group {group_id}: {error}")
            total_failed += 1
            continue

        # Groups are filed under: <sync_root>/groups/<group name>/
        d, s, f = mirror_collection(
            session,
            label=f"GROUP {group_name}",
            subfolders=["groups", group_name],
            files=files,
            sync_root=sync_root,
            dry_run=args.dry_run,
        )
        total_downloaded += d
        total_skipped += s
        total_failed += f

    # ---- Step 4: final summary -------------------------------------------
    log("=" * 70)
    log("SYNC COMPLETE")
    log(f"  Downloaded     : {total_downloaded}")
    log(f"  Already synced : {total_skipped}")
    log(f"  Failed         : {total_failed}")
    log(f"  Mirror folder  : {sync_root}")
    log("=" * 70)

    # Exit code 1 if anything failed, so schedulers (cron, Task Scheduler)
    # can detect a bad run and alert you.
    sys.exit(1 if total_failed else 0)


if __name__ == "__main__":
    # Catch Ctrl+C cleanly instead of dumping a traceback at the customer.
    try:
        main()
    except KeyboardInterrupt:
        log("Interrupted by user. Partial .part files were cleaned up; "
            "re-run to resume where you left off.")
        sys.exit(130)
