FreeTheVideo

Become a Download Agent

Help the network. Earn sats. Fight censorship.

2
Active Agents
176
Videos Delivered
1
Jobs Waiting

What is a download agent?

Platforms like Instagram and Reddit actively block datacenter IPs from downloading content. When FreeTheVideo's servers can't download a video directly, it gets queued as a community job.

You run a small Python script on your home computer. It polls for pending jobs, downloads videos using your residential internet connection (which platforms don't block), and uploads them to the FreeTheVideo cache. You earn sats (Bitcoin via Lightning) for every successful download.

This is real distributed infrastructure. The more agents running on different networks, the more resilient the system becomes. No single point of failure. No single IP to block. The community IS the download network.

How it works

  1. Register below to get your agent token
  2. Install the agent script (Python, ~400 lines, zero dependencies beyond yt-dlp and requests)
  3. Run it. It polls every 15-30 seconds for new jobs
  4. Earn sats for every video you successfully download and deliver

What agents CAN and CANNOT see

Privacy by design. Agents see: the video URL, download hints, and reward amount. Agents do NOT see: who requested the video, IP addresses, payment info, or any user data. The agent API is isolated from the rest of the system.

Requirements

Register Your Agent

Agent Name *
Lightning Address (optional, for payouts)

Agent registered! Save your token — it won't be shown again.

Your Agent Token:
Agent ID:

Setup Instructions

1. Install dependencies

pip install requests yt-dlp

2. Download the agent script

curl -o ftv-agent.py https://freethevideo.com/agent-download

Don't trust — verify. Read the full source →  It's plain Python, no dependencies hidden. Then confirm your copy matches this SHA-256:

sha256sum ftv-agent.py      # Linux
shasum -a 256 ftv-agent.py  # macOS

fdfee76965e398f67cf5129f511bd3320116e192fdb10d50ff2585da030d051a  ftv-agent.py
View the full source right here (626 lines) — no download needed
#!/usr/bin/env python3
"""
FreeTheVideo Download Agent — earn sats by helping download videos.

Run this on your home machine. It polls the FreeTheVideo server for download
jobs, uses yt-dlp to download videos locally (your IP, your cookies), then
uploads them back to the server. You earn sats for each completed download.

Setup:
    pip install requests yt-dlp

Usage:
    python ftv-agent.py --server https://freethevideo.com --token ftv-abc123...
    python ftv-agent.py --once            # grab one job and exit
    python ftv-agent.py --stats           # show your stats

Config file (~/.ftv-agent.json):
    {"server": "https://freethevideo.com", "token": "ftv-abc123..."}
"""

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path

try:
    import requests
except ImportError:
    print("Missing dependency: pip install requests")
    sys.exit(1)

# --- Config ---

CONFIG_FILE = Path.home() / ".ftv-agent.json"
COOKIES_FILE = Path.home() / ".ftv-agent-cookies.txt"  # optional Netscape cookies
POLL_INTERVAL = 30  # seconds between polls
MAX_FILE_SIZE = 500 * 1024 * 1024  # 500MB
YTDLP_TIMEOUT = 600  # 10 min max per download

# Desktop UA pool — used for everything except Instagram (mobile UA) and TikTok (default).
_USER_AGENTS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
]
_INSTAGRAM_UA = (
    "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 "
    "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
)

# Hosts this agent is willing to download from. A compromised or spoofed server
# must NOT be able to push an arbitrary URL — or a yt-dlp option disguised as one
# (e.g. "--exec=<cmd>") — to run against the volunteer's machine or LAN.
SUPPORTED_DOMAINS = {
    "instagram.com", "www.instagram.com",
    "tiktok.com", "www.tiktok.com", "vm.tiktok.com",
    "youtube.com", "www.youtube.com", "youtu.be", "m.youtube.com",
    "x.com", "twitter.com", "www.twitter.com",
    "facebook.com", "www.facebook.com", "m.facebook.com",
    "fb.watch", "www.fb.watch",
}


def _has_curl_cffi() -> bool:
    try:
        import curl_cffi  # noqa: F401
        return True
    except Exception:
        return False


def build_ytdlp_args(url: str, output_template: str) -> list[str]:
    """Mirror server-side _ytdlp_args with platform-specific tweaks.

    Per CLAUDE.md:
      - Instagram: iPhone UA, cookies if available, --ignore-errors for carousels.
      - Facebook: default desktop UA (NOT iPhone — breaks parsing).
      - TikTok: default UA + --impersonate (requires curl_cffi).
      - YouTube: EJS solver + concurrent fragments.
      - X/Twitter: default settings.
    """
    import random
    from urllib.parse import urlparse

    ytdlp = find_ytdlp()
    host = (urlparse(url).hostname or "").lower()

    # Reject anything that isn't a plain https URL on a supported host, before it
    # can reach the yt-dlp command line.
    if not url.lower().startswith("https://"):
        raise ValueError(f"Refusing non-https job URL: {url[:80]!r}")
    if not (host in SUPPORTED_DOMAINS or any(host.endswith("." + d) for d in SUPPORTED_DOMAINS)):
        raise ValueError(f"Refusing job URL for unsupported host: {host!r}")

    args = [
        ytdlp,
        # Prefer H.264 (avc1) — VP9-in-MP4 renders as a black screen on Safari/iOS.
        # Fall through to any codec only when H.264 isn't offered.
        # Avoid filesize-in-format predicate (manifests often lack filesize headers
        # and yt-dlp filters them out before merge).
        "-f", (
            "bv*[vcodec^=avc1][height<=720]+ba[ext=m4a]/"
            "bv*[vcodec^=avc1][height<=720]+ba/"
            "b[vcodec^=avc1][height<=720]/"
            "bv*[height<=720]+ba/b[height<=720]/best"
        ),
        "--merge-output-format", "mp4",
        "--no-playlist",
        "--no-warnings",
        "--no-check-certificates",
        "--socket-timeout", "30",
        "--retries", "3",
        "--max-filesize", "450M",
        "--write-info-json",
        "--write-thumbnail",
        "--convert-thumbnails", "jpg",
        "--ignore-errors",
        "-o", output_template,
    ]

    # Platform-specific user-agent
    if "instagram" in host:
        args.extend(["--user-agent", _INSTAGRAM_UA])
    elif "tiktok" in host:
        # Don't set UA — let yt-dlp + impersonation pick the right fingerprint.
        if _has_curl_cffi():
            args.extend(["--impersonate", "chrome"])
    elif "facebook" in host or "fb.watch" in host:
        # Desktop UA; mobile breaks Facebook parsing.
        args.extend(["--user-agent", random.choice(_USER_AGENTS)])
        args.extend(["--extractor-retries", "3"])
    else:
        args.extend(["--user-agent", random.choice(_USER_AGENTS)])

    # Optional cookies (age-restricted / login-required content)
    if COOKIES_FILE.exists():
        args.extend(["--cookies", str(COOKIES_FILE)])

    # YouTube: EJS solver + concurrent fragments
    if "youtube" in host or "youtu.be" in host:
        args.extend(["--remote-components", "ejs:github"])
        args.extend(["--concurrent-fragments", "4"])

    # End-of-options separator: everything after "--" is treated as a positional
    # URL, so a job "url" like "--exec=<cmd>" can't be parsed as a yt-dlp option.
    args.append("--")
    args.append(url)
    return args

# --- Colors ---

class C:
    RESET = "\033[0m"
    RED = "\033[91m"
    GREEN = "\033[92m"
    YELLOW = "\033[93m"
    BLUE = "\033[94m"
    CYAN = "\033[96m"
    BOLD = "\033[1m"
    DIM = "\033[2m"


def log(msg, color=C.RESET):
    ts = time.strftime("%H:%M:%S")
    print(f"{C.DIM}{ts}{C.RESET} {color}{msg}{C.RESET}")


def log_ok(msg): log(msg, C.GREEN)
def log_warn(msg): log(msg, C.YELLOW)
def log_err(msg): log(msg, C.RED)
def log_info(msg): log(msg, C.CYAN)


# --- Config loading ---

def load_config(args) -> dict:
    """Load config from args, env vars, or config file."""
    config = {}

    # Config file
    if CONFIG_FILE.exists():
        try:
            config = json.loads(CONFIG_FILE.read_text())
        except Exception:
            pass

    # Env vars override
    if os.environ.get("FTV_SERVER"):
        config["server"] = os.environ["FTV_SERVER"]
    if os.environ.get("FTV_TOKEN"):
        config["token"] = os.environ["FTV_TOKEN"]

    # CLI args override
    if args.server:
        config["server"] = args.server
    if args.token:
        config["token"] = args.token

    server = config.get("server", "").rstrip("/")
    token = config.get("token", "")

    if not server or not token:
        print(f"{C.RED}Missing config. Provide --server and --token, or create {CONFIG_FILE}{C.RESET}")
        print(f"\nExample config file ({CONFIG_FILE}):")
        print(json.dumps({"server": "https://freethevideo.com", "token": "ftv-your-token-here"}, indent=2))
        sys.exit(1)

    return {"server": server, "token": token}


def save_config(server: str, token: str):
    """Save config to file for future use."""
    CONFIG_FILE.write_text(json.dumps({"server": server, "token": token}, indent=2))
    log_ok(f"Config saved to {CONFIG_FILE}")


# --- API calls ---

def api_headers(token: str) -> dict:
    return {"Authorization": f"Bearer {token}"}


def get_jobs(server: str, token: str) -> list:
    try:
        r = requests.get(f"{server}/api/agent/jobs", headers=api_headers(token), timeout=15)
        if r.status_code == 401:
            log_err("Invalid token. Check your agent token.")
            log_err("Exiting — fix your token and restart.")
            sys.exit(1)
        r.raise_for_status()
        return r.json().get("jobs", [])
    except requests.ConnectionError:
        log_err(f"Cannot connect to {server}")
        return []
    except Exception as e:
        log_err(f"Failed to get jobs: {e}")
        return []


def claim_job(server: str, token: str, job_id: str) -> dict | None:
    try:
        r = requests.post(f"{server}/api/agent/claim/{job_id}", headers=api_headers(token), timeout=15)
        if r.status_code == 409:
            log_warn(f"Job {job_id[:8]} already claimed by another agent")
            return None
        r.raise_for_status()
        return r.json()
    except Exception as e:
        log_err(f"Failed to claim job: {e}")
        return None


# Cloudflare caps proxied request bodies at ~100MB, so anything bigger is uploaded
# in ordered <100MB chunks that the server reassembles.
CHUNK_THRESHOLD = 90 * 1024 * 1024
CHUNK_SIZE = 90 * 1024 * 1024


def upload_video(server: str, token: str, job_id: str, filepath: Path, metadata: dict, thumb_path: Path | None = None) -> dict | None:
    try:
        size = filepath.stat().st_size
    except Exception:
        size = 0
    if size > CHUNK_THRESHOLD:
        return _upload_chunked(server, token, job_id, filepath, metadata, thumb_path, size)
    open_handles = []
    try:
        vf = open(filepath, "rb")
        open_handles.append(vf)
        files = {"video": (filepath.name, vf, "video/mp4")}
        if thumb_path and thumb_path.exists():
            tf = open(thumb_path, "rb")
            open_handles.append(tf)
            files["thumbnail"] = (thumb_path.name, tf, "image/jpeg")
        r = requests.post(
            f"{server}/api/agent/upload/{job_id}",
            headers=api_headers(token),
            files=files,
            data={"metadata": json.dumps(metadata)},
            timeout=300,
        )
        r.raise_for_status()
        return r.json()
    except Exception as e:
        log_err(f"Failed to upload: {e}")
        return None
    finally:
        for h in open_handles:
            h.close()


def _upload_chunked(server: str, token: str, job_id: str, filepath: Path, metadata: dict, thumb_path: Path | None, size: int) -> dict | None:
    """Upload a large video in ordered chunks (server reassembles on the final chunk)."""
    upload_id = os.urandom(12).hex()
    total = (size + CHUNK_SIZE - 1) // CHUNK_SIZE
    log_info(f"Uploading {size / 1024 / 1024:.0f}MB in {total} chunks (large file)...")
    try:
        with open(filepath, "rb") as vf:
            for idx in range(total):
                buf = vf.read(CHUNK_SIZE)
                data = {
                    "upload_id": upload_id,
                    "chunk_index": str(idx),
                    "total_chunks": str(total),
                    "filename": filepath.name,
                }
                files = {"chunk": (filepath.name, buf, "application/octet-stream")}
                thandle = None
                if idx == total - 1:
                    data["metadata"] = json.dumps(metadata)
                    if thumb_path and thumb_path.exists():
                        thandle = open(thumb_path, "rb")
                        files["thumbnail"] = (thumb_path.name, thandle, "image/jpeg")
                try:
                    r = requests.post(
                        f"{server}/api/agent/upload-chunk/{job_id}",
                        headers=api_headers(token),
                        files=files,
                        data=data,
                        timeout=300,
                    )
                    r.raise_for_status()
                finally:
                    if thandle:
                        thandle.close()
                if idx == total - 1:
                    return r.json()
                log_info(f"  chunk {idx + 1}/{total} uploaded")
        return None
    except Exception as e:
        log_err(f"Chunked upload failed: {e}")
        return None


def report_failure(server: str, token: str, job_id: str, error: str) -> bool:
    """Report a download failure to the server so the job is released immediately."""
    try:
        r = requests.post(
            f"{server}/api/agent/fail/{job_id}",
            headers={**api_headers(token), "Content-Type": "application/json"},
            json={"error": error[:300]},
            timeout=15,
        )
        if r.status_code == 200:
            data = r.json()
            log_warn(f"Reported failure (attempt {data.get('attempts', '?')}/{data.get('max_attempts', '?')})")
            return True
        log_warn(f"Failed to report failure: HTTP {r.status_code}")
    except Exception as e:
        log_warn(f"Failed to report failure: {e}")
    return False


def get_stats(server: str, token: str) -> dict | None:
    try:
        r = requests.get(f"{server}/api/agent/stats", headers=api_headers(token), timeout=15)
        r.raise_for_status()
        return r.json()
    except Exception as e:
        log_err(f"Failed to get stats: {e}")
        return None


def request_payout(server: str, token: str) -> dict | None:
    try:
        r = requests.post(f"{server}/api/agent/payout", headers=api_headers(token), timeout=30)
        return r.json()
    except Exception as e:
        log_err(f"Payout request failed: {e}")
        return None


# --- Download ---

def find_ytdlp() -> str:
    """Find yt-dlp binary."""
    for cmd in ["yt-dlp", "yt_dlp"]:
        if shutil.which(cmd):
            return cmd
    print(f"{C.RED}yt-dlp not found. Install it: pip install yt-dlp{C.RESET}")
    sys.exit(1)


def download_video(url: str, url_hash: str, tmpdir: Path) -> tuple[Path | None, dict, Path | None]:
    """Download a video using yt-dlp. Returns (filepath, metadata, thumb)."""
    # Never trust server-supplied values blindly: url_hash is used in a filesystem
    # path and url goes onto the yt-dlp command line.
    if not re.fullmatch(r"[a-f0-9]{16}", url_hash or ""):
        log_err(f"Refusing job with invalid url_hash: {url_hash!r}")
        return None, {}, None
    output_template = str(tmpdir / f"{url_hash}.%(ext)s")
    try:
        args = build_ytdlp_args(url, output_template)
    except ValueError as e:
        log_err(str(e))
        return None, {}, None

    log_info(f"Downloading: {url}")
    start = time.time()

    try:
        result = subprocess.run(
            args,
            capture_output=True,
            text=True,
            timeout=YTDLP_TIMEOUT,
        )
    except subprocess.TimeoutExpired:
        log_err("Download timed out")
        return None, {}, None

    # Find the video file — tolerate non-zero exit (carousel posts: image items
    # fail with --ignore-errors but the video item may still succeed).
    video_file = None
    for f in tmpdir.iterdir():
        if f.suffix in (".mp4", ".webm", ".mkv") and ".info.json" not in f.name:
            video_file = f
            break

    if not video_file:
        stderr = (result.stderr or "")[-500:] if result.stderr else "Unknown error"
        log_err(f"Download failed: {stderr}")
        return None, {}, None

    elapsed = time.time() - start
    size_mb = video_file.stat().st_size / 1024 / 1024
    log_ok(f"Downloaded: {size_mb:.1f} MB in {elapsed:.0f}s")

    # Parse metadata
    metadata = {}
    info_file = tmpdir / f"{url_hash}.info.json"
    if info_file.exists():
        try:
            raw = json.loads(info_file.read_text())
            metadata = {
                "title": raw.get("title", ""),
                "uploader": raw.get("uploader", raw.get("channel", "")),
                "duration": raw.get("duration", 0),
                "description": (raw.get("description", "") or "")[:300],
            }
        except Exception:
            pass

    # Find thumbnail (yt-dlp may name it with video ID, not our hash)
    thumb_file = None
    for f in tmpdir.iterdir():
        if f.suffix == ".jpg" and ".info" not in f.name:
            thumb_file = f
            break

    return video_file, metadata, thumb_file


# --- Main loop ---

def print_banner():
    print(f"""
{C.BOLD}{C.CYAN}  ╔═══════════════════════════════════════╗
  ║  FreeTheVideo Download Agent          ║
  ║  Earn sats by helping download videos ║
  ╚═══════════════════════════════════════╝{C.RESET}
""")


def print_stats(stats: dict):
    s = stats.get("stats", {})
    balance = s.get("earned_sats", 0) - s.get("paid_sats", 0)
    print(f"""
{C.BOLD}Agent:{C.RESET} {stats.get('name', '?')} ({stats.get('agent_id', '?')})
{C.BOLD}Downloads completed:{C.RESET} {s.get('completed', 0)}
{C.BOLD}Total earned:{C.RESET} {C.GREEN}{s.get('earned_sats', 0)} sats{C.RESET}
{C.BOLD}Paid out:{C.RESET} {s.get('paid_sats', 0)} sats
{C.BOLD}Balance:{C.RESET} {C.YELLOW}{balance} sats{C.RESET}
{C.BOLD}Lightning address:{C.RESET} {stats.get('lightning_address', 'not set')}
""")


def run_once(server: str, token: str) -> bool:
    """Try to grab and complete one job. Returns True if successful."""
    jobs = get_jobs(server, token)
    if not jobs:
        log_info("No jobs available")
        return False

    log_info(f"Found {len(jobs)} available job(s)")
    job = jobs[0]
    log(f"  {C.BOLD}{job['source']}{C.RESET}: {job['url'][:80]}")
    log(f"  Reward: {C.GREEN}{job['sats_reward']} sats{C.RESET}")
    if job.get("error_hint"):
        log(f"  Hint: {C.DIM}{job['error_hint'][:100]}{C.RESET}")

    # Claim it
    claimed = claim_job(server, token, job["job_id"])
    if not claimed:
        return False
    log_ok(f"Claimed job {job['job_id'][:8]}")

    # Download
    with tempfile.TemporaryDirectory(prefix="ftv-agent-") as tmpdir:
        tmppath = Path(tmpdir)
        video_file, metadata, thumb_file = download_video(claimed["url"], claimed["url_hash"], tmppath)

        if not video_file:
            log_err("Download failed, reporting to server")
            report_failure(server, token, job["job_id"], "yt-dlp download failed")
            return False

        # Check file size
        if video_file.stat().st_size > MAX_FILE_SIZE:
            err = f"File too large ({video_file.stat().st_size / 1024 / 1024:.0f} MB > {MAX_FILE_SIZE / 1024 / 1024:.0f} MB)"
            log_err(err)
            report_failure(server, token, job["job_id"], err)
            return False

        # Upload
        log_info("Uploading to server...")
        start = time.time()
        result = upload_video(server, token, job["job_id"], video_file, metadata, thumb_file)
        elapsed = time.time() - start

        if result and result.get("status") == "ok":
            sats = result.get("sats_earned", 0)
            total = result.get("total_earned", 0)
            log_ok(f"Upload complete ({elapsed:.0f}s) — earned {C.BOLD}{sats} sats{C.GREEN} (total: {total})")
            return True
        else:
            err = f"Upload failed: {result.get('error', 'unknown') if result else 'no response'}"
            log_err(err)
            report_failure(server, token, job["job_id"], err)
            return False


def main():
    parser = argparse.ArgumentParser(
        description="FreeTheVideo Download Agent — earn sats by downloading videos",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="Config file: ~/.ftv-agent.json\nEnv vars: FTV_SERVER, FTV_TOKEN",
    )
    parser.add_argument("--server", help="Server URL (e.g., https://freethevideo.com)")
    parser.add_argument("--token", help="Agent auth token")
    parser.add_argument("--interval", type=int, default=POLL_INTERVAL, help=f"Poll interval in seconds (default: {POLL_INTERVAL})")
    parser.add_argument("--once", action="store_true", help="Process one job and exit")
    parser.add_argument("--stats", action="store_true", help="Show agent stats and exit")
    parser.add_argument("--payout", action="store_true", help="Request sats payout")
    parser.add_argument("--save-config", action="store_true", help="Save --server and --token to config file")
    args = parser.parse_args()

    print_banner()
    config = load_config(args)
    server = config["server"]
    token = config["token"]

    if args.save_config:
        save_config(server, token)
        return

    if args.stats:
        stats = get_stats(server, token)
        if stats:
            print_stats(stats)
        return

    if args.payout:
        result = request_payout(server, token)
        if result:
            if result.get("status") == "ok":
                log_ok(f"Payout successful: {result.get('amount_sats', 0)} sats sent!")
            else:
                log_err(f"Payout failed: {result.get('error', 'unknown')}")
        return

    if args.once:
        success = run_once(server, token)
        sys.exit(0 if success else 1)

    # Continuous mode
    log_info(f"Connected to {server}")
    stats = get_stats(server, token)
    if stats:
        s = stats.get("stats", {})
        balance = s.get("earned_sats", 0) - s.get("paid_sats", 0)
        log(f"Agent: {C.BOLD}{stats.get('name', '?')}{C.RESET} | Completed: {s.get('completed', 0)} | Balance: {C.GREEN}{balance} sats{C.RESET}")
    log_info(f"Polling every {args.interval}s for download jobs...")
    print()

    consecutive_empty = 0
    consecutive_errors = 0
    while True:
        try:
            jobs = get_jobs(server, token)
            if jobs:
                consecutive_empty = 0
                consecutive_errors = 0
                run_once(server, token)
            else:
                consecutive_empty += 1
                if consecutive_empty % 10 == 1:  # log every 10th empty poll
                    log(f"{C.DIM}No jobs available... waiting{C.RESET}")
            time.sleep(args.interval)
        except KeyboardInterrupt:
            print(f"\n{C.YELLOW}Shutting down agent...{C.RESET}")
            stats = get_stats(server, token)
            if stats:
                print_stats(stats)
            break
        except Exception as e:
            consecutive_errors += 1
            log_err(f"Unexpected error: {e}")
            if consecutive_errors >= 5:
                log_err("Too many consecutive errors, exiting")
                sys.exit(1)
            backoff = min(60, args.interval * consecutive_errors)
            log_warn(f"Retrying in {backoff}s (error {consecutive_errors}/5)")
            time.sleep(backoff)


if __name__ == "__main__":
    main()

3. Save your config

python ftv-agent.py --server https://freethevideo.com --token YOUR_TOKEN_HERE --save-config

4. Run the agent

# Foreground (see live output):
python ftv-agent.py

# One job and exit:
python ftv-agent.py --once

# Check your earnings:
python ftv-agent.py --stats

5. Run persistently (optional)

On Linux, create a systemd user service:

[Unit]
Description=FreeTheVideo Download Agent
After=network.target

[Service]
ExecStart=/usr/bin/python3 /path/to/ftv-agent.py --interval 15
Restart=on-failure
RestartSec=30

[Install]
WantedBy=default.target
systemctl --user enable ftv-agent
systemctl --user start ftv-agent

Earnings