Tag: yt-dlp

  • How to Build a YouTube Video Dataset for Multimodal Training

    Downloading a few thousand YouTube videos is a script. Building a YouTube video dataset that a video-LLM, vision-language or speech model can train on (filtered, deduplicated, captioned, sharded, and traceable back to every source) is a pipeline. This article walks through that pipeline stage by stage, with the yt-dlp flags, open-source tools and published dataset recipes that each stage relies on, plus a runnable metadata script and a sample manifest record.

    TL;DR

    • Metadata first. Enumerate IDs with yt-dlp --flat-playlist, fetch full metadata with --skip-download, and filter on duration, license, language and resolution before you spend any bandwidth on video bytes.
    • The YouTube Data API is for enrichment, not discovery. The default allocation is 100 search.list calls per day plus 10,000 units for other endpoints. videos.list costs 1 unit.
    • Make the download stage idempotent. Use --download-archive, a job queue keyed by video ID, and one proxy session per worker.
    • Processing follows published recipes: shot detection (PySceneDetect), clip stitching or splitting, near-duplicate removal, motion/OCR/aesthetic filters (as in Stable Video Diffusion), recaptioning (as in Panda-70M), and ASR with Whisper.
    • Store samples as WebDataset tar shards, keep metadata in Parquet/JSONL, put a sha256 on every object, and record lineage (video ID, license, collection time) per sample so takedowns can be applied.

    The pipeline at a glance

      seeds (keywords, channels, playlists, existing ID lists)
         |
         v
    [1] DISCOVER   yt-dlp --flat-playlist -j  /  Data API videos.list
         |            -> candidate IDs (cheap, no media)
         v
    [2] FILTER     full metadata (--skip-download): duration, license,
         |            language, views, date, category, max height
         |            -> selected IDs + metadata.jsonl
         v
    [3] DOWNLOAD   job queue -> workers (yt-dlp, 1 proxy session each)
         |            video | audio-only | subs | thumbnail | info.json
         v
    [4] PROCESS    shot detection -> clips -> dedup -> quality filters
         |            -> captions / ASR / language ID
         v
    [5] STORE      WebDataset tar shards + Parquet metadata + sha256 manifest
         |            -> object storage (S3 / GCS / Azure Blob)
         v
    [6] GOVERN     lineage per sample, takedown list, re-release builds

    Stage 7, cost and throughput planning, applies to all of them. Each stage writes its output to durable storage, so a failure in stage 4 never forces a re-download from stage 3.

    Stage 1: Scope and discovery

    Start from what the model needs: target hours, modalities (video+audio, audio only, video+captions), languages, domains and minimum resolution. Then build seed lists:

    • Channels and playlists are the highest-yield seeds. Topically coherent channels produce consistent data.
    • Keyword lists produce search result pages. Each query yields a limited number of results, so you need many queries.
    • Existing ID lists from open datasets. Panda-70M, HD-VILA-100M and InternVid all ship YouTube IDs, covered in our guide to downloading Panda-70M, HD-VILA-100M and InternVid.

    Enumerate without resolving each video. In yt-dlp, --flat-playlist skips extraction of a playlist's entries, and -j/--dump-json prints one JSON object per entry without downloading:

    yt-dlp --flat-playlist -j "https://www.youtube.com/@CHANNEL/videos" > seeds/channel.jsonl
    yt-dlp --flat-playlist -j "ytsearch200:woodworking tutorial" > seeds/q_woodworking.jsonl

    Flat entries are thin: the README warns that "some entry metadata may be missing". Typically you get an ID, a title and maybe a duration. Treat this as the candidate list, not as filter input.

    YouTube Data API v3 as an alternative

    Google's quota documentation currently says projects get a default allocation of 100 search.list calls, 100 videos.insert calls, and 10,000 units per day for all other endpoints combined. Quotas reset at midnight Pacific Time. videos.list costs 1 unit and accepts a comma-separated list of IDs. The video resource exposes status.license (creativeCommon vs. youtube) and snippet.defaultAudioLanguage. That makes the API good for authoritative enrichment of IDs you already have, and a poor fit for discovery at scale given the search cap. Older articles quote "10,000 units, search costs 100", so check the current page before you plan around either model.

    Stage 2: Filter on metadata before downloading

    Video bytes are the main cost. Metadata costs a few requests per video. Fetch full metadata for every candidate, then drop what you won't train on:

    Filter yt-dlp field Why
    Duration duration Drop shorts and multi-hour streams that blow up processing.
    Live status live_status, is_live Skip ongoing and upcoming streams.
    License license Creative Commons-only subsets. YouTube CC videos report "Creative Commons Attribution license (reuse allowed)".
    Language subtitles, automatic_captions, per-format language Cheap pre-filter. Confirm later with audio language ID.
    Popularity / date view_count, upload_date Proxy for production quality; time-sliced splits.
    Category categories, tags Domain balance (e.g. cap gaming or music).
    Resolution formats[].height Skip videos whose best format is below your minimum.
    Availability availability, age_limit Skip anything that needs sign-in.

    The CLI route uses --skip-download with --write-info-json and --match-filters:

    yt-dlp -a candidates.txt --skip-download --write-info-json \
      --match-filters "duration>=30 & duration<=1800 & !is_live & license~='Creative Commons'" \
      -o "meta/%(id)s.%(ext)s"

    For a pipeline, the Python API is easier to control. The script below reads IDs, fetches metadata with extract_info(download=False), keeps the fields you filter on, and appends JSONL. It's resumable: IDs that already have a record are skipped, and failures go to a separate file so they get retried on the next run.

    import json
    import os
    import sys
    from datetime import datetime, timezone
    
    import yt_dlp
    from yt_dlp.utils import DownloadError
    
    KEEP = ["id", "title", "channel", "channel_id", "duration", "upload_date",
            "license", "categories", "tags", "view_count", "availability",
            "live_status", "age_limit"]
    
    
    def done_ids(path):
        """IDs that already have a successful record (errors get retried)."""
        done = set()
        if os.path.exists(path):
            with open(path, encoding="utf-8") as f:
                for line in f:
                    try:
                        done.add(json.loads(line)["id"])
                    except (ValueError, KeyError):
                        continue  # tolerate a truncated last line
        return done
    
    
    def to_record(info):
        rec = {k: info.get(k) for k in KEEP}
        formats = info.get("formats") or []
        rec["max_height"] = max((f.get("height") or 0 for f in formats), default=0)
        rec["audio_langs"] = sorted({f["language"] for f in formats
                                     if f.get("acodec") not in (None, "none")
                                     and f.get("language")})
        rec["subtitle_langs"] = sorted(info.get("subtitles") or {})
        rec["has_auto_captions"] = bool(info.get("automatic_captions"))
        return rec
    
    
    def now():
        return datetime.now(timezone.utc).isoformat(timespec="seconds")
    
    
    def main(ids_path, out_path, proxy=None):
        with open(ids_path, encoding="utf-8") as f:
            ids = list(dict.fromkeys(line.strip() for line in f if line.strip()))
        done = done_ids(out_path)
        todo = [i for i in ids if i not in done]
    
        opts = {"quiet": True, "no_warnings": True, "skip_download": True,
                "socket_timeout": 30, "extractor_retries": 3}
        if proxy:
            opts["proxy"] = proxy  # e.g. http://USER:[email protected]:8000
    
        with yt_dlp.YoutubeDL(opts) as ydl, \
                open(out_path, "a", encoding="utf-8") as out, \
                open(out_path + ".errors", "a", encoding="utf-8") as err:
            for vid in todo:
                url = f"https://www.youtube.com/watch?v={vid}"
                try:
                    info = ydl.sanitize_info(ydl.extract_info(url, download=False))
                except DownloadError as e:
                    err.write(json.dumps({"id": vid, "error": str(e),
                                          "fetched_at": now()}) + "\n")
                    continue
                rec = to_record(info)
                rec["fetched_at"] = now()
                out.write(json.dumps(rec, ensure_ascii=False) + "\n")
                out.flush()
    
    
    if __name__ == "__main__":
        if len(sys.argv) not in (3, 4):
            sys.exit("usage: fetch_meta.py ids.txt metadata.jsonl [proxy_url]")
        main(*sys.argv[1:])

    Run it as python fetch_meta.py ids.txt metadata.jsonl. Then filter metadata.jsonl with pandas or DuckDB to produce selected.txt. sanitize_info is there because the yt-dlp README says extract_info's return value isn't guaranteed to be JSON-serializable. Current yt-dlp lists Python 3.11+ as supported, and it needs a JavaScript runtime plus yt-dlp-ejs for full YouTube support. If metadata requests start failing with bot checks, see fixing "Sign in to confirm you're not a bot".

    Stage 3: Download

    One yt-dlp invocation per job, configured for the modality you need:

    # video + subtitles + thumbnail + metadata, capped at 720p
    yt-dlp -a selected.txt \
      -S "res:720,fps" --merge-output-format mp4 \
      --write-subs --write-auto-subs --sub-langs "en.*,ja" --sub-format "vtt/best" \
      --write-thumbnail --write-info-json \
      --download-archive archive.txt \
      -R 10 --fragment-retries 10 --retry-sleep exp=1:60 \
      --sleep-requests 1 --sleep-subtitles 2 \
      --proxy "http://USER:[email protected]:8000" \
      -o "raw/%(id)s/%(id)s.%(ext)s"
    
    # speech corpus: best audio-only stream, no video bytes at all
    yt-dlp -a selected.txt -f "ba" --download-archive archive_audio.txt \
      -o "raw_audio/%(id)s.%(ext)s"
    • Format selection. -f chooses formats. -S sorts them, and res:720 prefers the largest resolution not above 720p. Pin a resolution ceiling: the bytes difference between 720p and 4K dominates your bill.
    • Audio-only. -f ba keeps the native stream (usually Opus or AAC). Add -x --audio-format flac only if your ASR stage needs it. Decoding to 16 kHz mono at training time is usually cheaper than storing WAV.
    • Subtitles. --write-subs fetches uploader captions and --write-auto-subs fetches YouTube's ASR. Keep them separate in metadata: they differ in quality, and auto-captions are a weak label.
    • Idempotency. --download-archive records completed IDs, so re-runs skip them. At scale, put IDs in a queue (Redis, SQS, a Postgres table with SELECT ... FOR UPDATE SKIP LOCKED), lease jobs with a timeout, and mark a job done only after the upload to object storage and its checksum are confirmed.
    • Proxies. Give each worker its own proxy session and keep one video on one IP for its whole lifetime (metadata, manifest, fragments). Details, including per-worker configuration, are in the yt-dlp proxy guide.

    Stage 4: Processing

    Shot detection and clip splitting

    Most video-text training uses clips of a few seconds to a minute, not whole videos. PySceneDetect provides ContentDetector, a two-pass AdaptiveDetector that handles fast camera movement better, and ThresholdDetector for fades. Splitting needs ffmpeg (or mkvmerge):

    from scenedetect import detect, AdaptiveDetector, split_video_ffmpeg
    
    scenes = detect("raw/abc123XYZ_-/abc123XYZ_-.mp4", AdaptiveDetector())
    keep = [(s, e) for s, e in scenes if 2.0 <= (e - s).get_seconds() <= 60.0]
    split_video_ffmpeg("raw/abc123XYZ_-/abc123XYZ_-.mp4", keep)

    Raw shot boundaries over-split. Panda-70M runs shot boundary detection with PySceneDetect first. It then uses ImageBind frame embeddings to stitch adjacent clips that are semantically similar. Its splitting code also cuts the first 5 seconds of any clip longer than 7 seconds, recursively, to catch fades and unedited footage. Store clip boundaries as timestamps in metadata. Whether you materialize cut files or decode ranges at load time is a storage-vs-compute trade-off.

    Deduplication

    • Exact: sha256 of the downloaded file. This only catches byte-identical copies.
    • Perceptual: re-uploads are re-encoded, so hash sampled frames (e.g. 1 fps) with a perceptual hash and compare sequences by Hamming distance.
    • Semantic near-dup: embed clips with an image/video encoder, index them in an approximate-nearest-neighbor index, and drop pairs above a cosine-similarity threshold you tune on a labeled sample.
    • Against evaluation sets: drop any video ID that appears in benchmarks you report on, to avoid contamination.

    Quality filters

    The Stable Video Diffusion paper is one of the clearer public write-ups. It uses cascaded cut detection, dense optical flow computed at 2 FPS to remove near-static clips below a motion threshold, text detection to remove clips with excessive text, and CLIP-based aesthetic and text-image similarity scores. The same paper notes that InternVid-10M is "specifically filtered for high aesthetics". Practical guidance:

    • Compute scores and store them as metadata columns rather than deleting clips. Thresholds change between training runs, and re-scoring is expensive.
    • Calibrate each threshold by viewing a few hundred clips on both sides of it.
    • Text-heavy filters (slides, lyric videos, screen recordings) matter for generation models. For document-understanding models you may want exactly those clips.

    Captioning and recaptioning

    Titles and descriptions are noisy captions, and ASR transcripts describe speech, not visuals. Panda-70M's approach is to caption each clip with multiple cross-modality teacher models (using frames, the video description and subtitles as inputs). A retrieval model, fine-tuned on a small manually-labeled subset, then picks the best caption per clip. A simpler version: run one strong vision-language model per clip, prompted with the video title and the ASR text for that time range. Store the model name and prompt version with every caption.

    ASR and language ID

    Whisper does multilingual transcription, translation and spoken language identification. The turbo model is an optimized large-v3 with faster transcription and minimal accuracy loss. Per the README, it isn't trained for translation. For language ID, model.detect_language(mel) returns per-language probabilities. Compare the result with the metadata language and caption tracks: mismatches often flag dubbed content, music, or mislabeled uploads. Whisper's accuracy varies widely by language (the README publishes per-language WER/CER), so set per-language confidence thresholds.

    Stage 5: Storage layout and formats

    WebDataset stores samples as plain tar files. Files belonging to one sample share a basename, and shards are numbered like name-000000.tar. That gives sequential reads from object storage and trivial parallelism.

    s3://bucket/ytds/v2026-11/
      shards/clips-000000.tar        # abc123XYZ_-_000012.mp4 / .json / .txt / .opus
      shards/clips-000001.tar
      metadata/clips.parquet         # one row per clip: scores, captions, lineage
      metadata/videos.parquet        # one row per source video
      manifest.jsonl                 # one line per object: path, bytes, sha256
      takedowns.jsonl                # removed video_ids, with date and reason
    • Keep raw downloads in a separate prefix from training shards, so you can re-cut or re-filter without re-downloading.
    • Parquet for analytical metadata (filtering, stats, joins). JSONL for append-only logs and human-readable manifests.
    • Shard size: pick a size that streams efficiently from your object store. Don't write millions of small per-clip objects.
    • Version releases (v2026-11) and never mutate a published shard. Takedowns produce a new release.

    Stage 6: Data governance and lineage

    Every sample should answer: where did this come from, when, under what license, and has it been withdrawn? A clip-level record (illustrative values):

    {
      "sample_id": "abc123XYZ_-_000012",
      "video_id": "abc123XYZ_-",
      "source_url": "https://www.youtube.com/watch?v=abc123XYZ_-",
      "channel_id": "UCxxxxxxxxxxxxxxxxxxxxxx",
      "license": "Creative Commons Attribution license (reuse allowed)",
      "upload_date": "20240611",
      "collected_at": "2026-10-28T14:03:11Z",
      "collector_version": "yt-dlp 2026.10.xx / pipeline 1.4.0",
      "clip": {"start": 83.417, "end": 91.758, "height": 720, "fps": 30},
      "audio": {"lang_meta": "en", "lang_whisper": "en", "lang_prob": 0.97},
      "captions": [
        {"source": "vlm", "model": "MODEL_NAME", "prompt_version": "v3", "text": "..."},
        {"source": "asr", "model": "whisper-turbo", "text": "..."}
      ],
      "scores": {"motion": 3.1, "ocr_text_area": 0.02, "aesthetic": 5.4},
      "dedup_cluster": 1882301,
      "shard": "clips-000417.tar",
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
    }

    Takedown handling is a join: keep takedowns.jsonl keyed by video_id or channel_id, rebuild the affected shards without those samples, publish a new version, and delete the raw files. Periodically re-check source availability. A video that became private or was deleted is a strong signal to drop it from future releases.

    Stage 7: Cost and throughput planning

    Size everything from hours and bitrate:

    bytes      = hours x 3600 x avg_bitrate_bps / 8
    throughput = bytes x 8 / collection_window_seconds
    
    example (assumed 2 Mbps average at a 720p cap):
      1,000,000 h x 3600 x 2e6 / 8   = 9.0e14 B  = 900 TB
      9.0e14 x 8 / (30 x 86400 s)    = ~2.78 Gbps sustained for 30 days

    Measure your own average bitrate from filesize/filesize_approx and duration in a metadata sample, because it varies widely by resolution, codec and content. Then budget for headroom: retries, metadata requests, re-downloads after failures, and the fraction of IDs that turn out unavailable. GPU stages (embedding, captioning, ASR) usually become the bottleneck after download. Benchmark clips per GPU-hour on your own hardware before you commit to a schedule. Detailed tables are in bandwidth planning for large-scale video downloads.

    Compliance

    YouTube's Terms of Service restrict automated access and downloading. Copyright and licensing still apply to the content: some teams restrict collection to Creative Commons-licensed videos and track the license per sample. Faces and voices are personal data under regimes like GDPR. Honor takedown and opt-out requests, and design the pipeline so you can. Get legal review for your jurisdiction and use case before you collect or redistribute.

    If you'd rather not run this yourself

    Corpora offers dedicated 1–40 Gbps unmetered proxy capacity for teams running their own yt-dlp stack. It also offers a fully managed option that collects, deduplicates and delivers video, audio, subtitles and metadata (JSONL/Parquet with sha256 checksums and manifests) to your S3, GCS or Azure bucket, with a free sample batch. Get in touch to scope it.

    FAQ

    How do I get only metadata from YouTube with yt-dlp?

    Use -j/--dump-json to print JSON without downloading, or --skip-download --write-info-json to write .info.json files. Add --flat-playlist when you only need the entry list of a channel or playlist.

    How many API calls does the YouTube Data API allow per day?

    Google's quota page currently states a default of 100 search.list calls, 100 videos.insert calls and 10,000 units per day for other endpoints, resetting at midnight Pacific Time. You can request more through Google's quota audit process.

    How do I filter for Creative Commons videos?

    In yt-dlp, filter on the license field (e.g. --match-filters "license~='Creative Commons'"). In the Data API, read status.license, which is creativeCommon for CC videos.

    Should I store whole videos or pre-cut clips?

    Keep the raw videos plus clip timestamps as the source of truth. Materialize cut clips into WebDataset shards for each training release. That way, changing the splitting or filtering logic doesn't require a re-download.

    Which ASR model should I use for a YouTube speech dataset?

    Whisper is the common default. turbo is the faster option for transcription, while the multilingual models are needed for translation. Validate WER on a hand-transcribed sample per language before trusting the transcripts as labels.

  • Download YouTube Video Datasets: Panda-70M, HD-VILA, InternVid

    Panda-70M, HD-VILA-100M, InternVid and most other large video-language datasets don't ship any video. You get YouTube IDs, clip timestamps and captions, and you have to collect the videos yourself. This guide covers what each dataset actually releases (checked against the paper and the official repo), which tooling works for it, and how to plan storage, failures and scaling before you start a multi-terabyte download.

    TL;DR

    • Panda-70M, HD-VILA-100M, InternVid, HowTo100M, AudioSet and VoxCeleb2 all release metadata only (IDs, timestamps, text or labels). Kinetics-700-2020 is the exception: CVDF hosts the video files.
    • For Panda-70M, use the video2dataset fork inside the Panda-70M repo. The authors say upstream video2dataset can't read their CSV format.
    • Many clips per source video: download the video once and cut it locally with ffmpeg. One short segment per video: use yt-dlp --download-sections.
    • Expect missing videos. Kinetics-700-2020's own download log shows about 2% of videos already gone a few months after the 2020 release.
    • Storage ≈ hours × 3600 × bitrate ÷ 8. Panda-70M's own figure (~36 TB for 167k hours at 360p) works out to roughly 0.48 Mbps.
    • Annotation licenses don't give you any rights to the underlying videos.

    Why these datasets ship as IDs and timestamps

    The videos belong to their uploaders. Dataset authors can license their annotations (captions, labels, clip boundaries), but they generally can't redistribute the footage. What you download is a table of pointers, so every team that wants the pixels has to collect them again. This has three practical effects: your copy of the dataset is smaller than the paper's, it shrinks over time, and it depends on how well your downloader keeps up with YouTube.

    Dataset overview

    All numbers below come from each dataset's paper, project page or official repo. Durations appear only where the source states them.

    Dataset (year) What's released Source videos Clips / segments Stated duration Annotation license Official tooling
    Panda-70M (2024) CSV with YouTube URL, clip timestamps, caption, matching score; desirability and shot-boundary annotations added Oct 2024 3,779,763 (full train) 70,723,513 167k hrs (clips) Snap non-commercial research license video2dataset fork in snap-research/Panda-70M
    HD-VILA-100M (2022) Video URLs plus clip spans, separate metadata archive; text is ASR transcripts 3.3M 100M (avg 13.4 s) 371.5k hrs, 720p Research Use of Data Agreement (R-UDA) v1.0 src/cut_videos.py in microsoft/XPretrain (cutting only)
    InternVid (2023) YouTube IDs, clip timestamps, generated captions; subsets 10M-FLT, 10M-DIV, 18M-AES; full annotations as InternVid-Full 7M+ 234M (paper); 10M in -FLT ~760k hrs (full) CC BY-NC-SA 4.0 (Hugging Face card, gated) None for video download in the README
    HowTo100M (2019) YouTube IDs; captions are narrations from YouTube's automatic subtitles; precomputed S3D features 1.22M 136M "15 years of video" (project page) Not stated on the project page Feature-extraction code in antoine77340/howto100m
    Kinetics-700 / -2020 YouTube ID, ~10 s segment, action label. CVDF also hosts the video files One clip per video 647,907 (2020 report: 545,793 / 34,256 / 67,858) Not stated Not verified here; check DeepMind's Kinetics page cvdfoundation/kinetics-dataset (k700_2020_downloader.sh)
    AudioSet (2017) CSV: YTID, start, end, labels; 128-d audio features (TFRecord) One segment per video 2,085,544 ten-second segments, 527 classes Not stated (≈5,793 hrs at 10 s each) CC BY 4.0 (ontology CC BY-SA 4.0) None for raw audio
    VoxCeleb2 (2018) YouTube URLs and utterance timestamps. The site now says URLs, audio and video are "no longer available from this website" 150,480 1,128,246 utterances, 6,112 speakers Not stated Metadata CC BY-SA 4.0 None currently

    YT-Temporal-180M (6 million videos, 180M extracted frames) and YT-Temporal-1B (MERLOT Reserve, 20 million videos) aren't in the table. The MERLOT repo still lists "information about the videos used in this work" as planned, and I found no public ID list for either. Ask the authors directly.

    HD-VILA's numbers are consistent with each other: 100M clips × 13.4 s ≈ 372k hours. That means the 371.5k hours figure counts clip duration, not full source-video duration.

    Step 1: get the metadata

    • Panda-70M: CSVs are linked from the dataset_dataloading README. Training sets are zipped (full: 2.73 GB; 10M: 504 MB; 2M: 118 MB). The 10M subset keeps at most 3 clips per source video, and every clip has a matching score above 0.43.
    • HD-VILA-100M: the README links a URL archive and a metadata archive on Azure Blob, last updated August 2026. Each JSONL record has video_id, url and a clip list of {clip_id, span: [start, end]}.
    • InternVid: OpenGVLab/InternVid on Hugging Face is gated. You fill in name, organization and email and accept the terms before the JSONL files become available.
    • AudioSet: three CSVs (eval 20,383, balanced train 22,176, unbalanced train 2,042,985 segments).

    Step 2: choose your downloader

    Panda-70M: the official video2dataset fork

    Panda-70M's README says to use the bundled fork, because "the video2dataset from the official repository cannot work with our csv format":

    git clone https://github.com/snap-research/Panda-70M.git
    cd Panda-70M/dataset_dataloading/video2dataset
    pip install -e .
    cd ..
    
    video2dataset --url_list="panda70m_training_2m.csv" \
                  --url_col="url" \
                  --caption_col="caption" \
                  --clip_col="timestamp" \
                  --output_folder="panda70m_2m" \
                  --save_additional_columns="[matching_score,desirable_filtering,shot_boundary_detection]" \
                  --config="video2dataset/video2dataset/configs/panda70m.yaml"

    The bundled panda70m.yaml sets download_size: 360 and download_audio: True, downloads English subtitles, and writes 100 samples per shard using 32 processes × 32 threads. The README's known-issues table maps HTTP 403 to "your IP got blocked" and HTTP 429 to "reduce processes_count and thread_count". It also notes that several YouTube errors are fixed by upgrading yt-dlp, so run pip install -U "yt-dlp[default]" inside that environment after installing. Current yt-dlp needs Python 3.11+, plus yt-dlp-ejs and a JavaScript runtime such as Deno for full YouTube support (see the yt-dlp README).

    Upstream video2dataset

    iejMac/video2dataset is still at that address (MIT license, not archived). Its last push was in July 2024, and the latest PyPI release is 1.3.0 from February 2024, so treat it as stable but largely unmaintained. It reads CSV or Parquet (--input_format), writes files, webdataset, parquet or tfrecord (--output_format), and distributes work over multiprocessing, PySpark or Slurm. The README says PySpark mode was used to download 40M videos. It's a reasonable base for InternVid or HD-VILA, but you'll have to convert their timestamp formats to whatever clip_col format your version expects. Test on 1,000 rows before launching a full run.

    yt-dlp directly

    For custom pipelines, call yt-dlp directly. How you handle clipping depends on how many clips come from each source video:

    • Many clips per video (Panda-70M averages ~18.7 clips per source video in the full set; HD-VILA has ~30): download the whole video once, then cut it locally.
    • One short segment per video (AudioSet, Kinetics): download just the section with --download-sections. The * prefix marks a time range, and the option needs ffmpeg.
    # Full video, capped at 360p, smallest file among the remaining candidates
    yt-dlp -S "res:360,+size" --merge-output-format mp4 \
      -o "videos/%(id)s.%(ext)s" \
      "https://www.youtube.com/watch?v=QMi8x8o55Ns"
    
    # One 10-second AudioSet segment, audio only
    yt-dlp -f bestaudio -x --audio-format flac \
      --download-sections "*420-430" \
      -o "audio/%(id)s_%(section_start)s.%(ext)s" \
      "https://www.youtube.com/watch?v=-0RWZT-miFs"

    Section downloads cut at keyframes by default. Add --force-keyframes-at-cuts for accurate boundaries. The README warns that this is slow because it re-encodes.

    Cutting clips with ffmpeg

    HD-VILA's cut_videos.py uses ffmpeg -ss … -t … -c copy. Stream copy is fast, but each clip starts at the nearest keyframe, so a 5-second clip can pick up extra frames. If your training needs exact caption alignment, re-encode:

    import json
    import subprocess
    from pathlib import Path
    
    def to_sec(ts):  # "00:04:52.140" to 292.14
        h, m, s = ts.split(":")
        return int(h) * 3600 + int(m) * 60 + float(s)
    
    with open("meta_part0.jsonl") as f:  # one HD-VILA metadata part
        for line in f:
            meta = json.loads(line)
            src = Path("videos") / f"{meta['video_id']}.mp4"
            if not src.exists():
                continue
            out_dir = Path("clips") / meta["video_id"]
            out_dir.mkdir(parents=True, exist_ok=True)
            for c in meta["clip"]:
                start, end = (to_sec(t) for t in c["span"])
                subprocess.run([
                    "ffmpeg", "-y", "-loglevel", "error",
                    "-ss", f"{start:.3f}", "-i", str(src), "-t", f"{end - start:.3f}",
                    "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
                    "-c:a", "aac", "-b:a", "96k",
                    str(out_dir / c["clip_id"]),
                ], check=True)

    Choosing format and resolution

    Resolution has more effect on storage and bandwidth than any other setting. In yt-dlp's format sorting, res:360 prefers the largest format that is no larger than 360p. Adding +size breaks ties toward smaller files. Panda-70M's tooling defaults to 360p for this reason. If you're training generation models that need HD, set a higher download_size and budget for it. Use audio-only formats for AudioSet and VoxCeleb-style work. Also consider whether you need audio at all: Panda's README notes that turning off download_audio speeds up downloads.

    Link rot: plan for missing videos

    Videos get deleted, set to private, region-locked or taken down by their owners. CVDF's Kinetics-700-2020 readme is one of the few published measurements. It was downloaded in September 2020 with youtube-dl:

    • Train: 536,699 of 545,793 videos (98.3%)
    • Validation: 33,966 of 34,256 (99.2%)
    • Test: 64,535 of 67,858 (95.1%)

    About 7,000 videos that were unavailable by September 2020 were recovered from a December 2019 crawl. Expect losses to grow the longer a dataset has been public. Panda-70M's README lists "set to private or removed" as an expected failure and tells you to skip those samples. VoxCeleb2's official site no longer provides the URL list at all. In practice, always report how many clips you actually obtained, and don't assume your numbers are directly comparable to the paper's.

    Estimating storage

    Formula: bytes = hours × 3600 × bitrate (bits/s) ÷ 8.

    Panda-70M gives a calibration point: ~36 TB for 167k hours at 360p with audio. That's 36×10¹² B ÷ (167,000 × 3600 s) ≈ 59,900 B/s ≈ 0.48 Mbps. The table below assumes 0.5 Mbps for 360p and 2.5 Mbps for 720p H.264. These are round assumptions, not measurements.

    Dataset (stated hours) Seconds At 0.5 Mbps At 2.5 Mbps
    Panda-70M full (167k, clips) 6.01×10⁸ ≈ 37.6 TB ≈ 188 TB
    HD-VILA-100M (371.5k, clips) 1.34×10⁹ ≈ 83.6 TB ≈ 418 TB
    InternVid full (~760k, source videos) 2.74×10⁹ ≈ 171 TB ≈ 855 TB

    These estimates cover the clips you store. Transfer volume is higher if you download full source videos and keep only the clipped parts, and the sources don't state total source duration for Panda-70M or HD-VILA. For transfer time: 37.6 TB × 8 ÷ 1 Gbps ≈ 300,800 s ≈ 3.5 days at full line rate, which you won't sustain in practice. Bandwidth for large-scale video downloads goes through the full calculation.

    Output formats

    • Loose files (video2dataset's default files): Panda-70M's layout is one folder per shard, with .mp4, .txt (caption) and .json (metadata, including status) per sample. This is easy to inspect but slow on object storage once you have millions of files.
    • WebDataset tar shards (--output_format webdataset): this is what the video2dataset README recommends "for most use cases". It streams well from S3/GCS into PyTorch, TF or JAX.
    • Parquet metadata: video2dataset writes a Parquet file per shard alongside the tars. Merge these into a single manifest (clip ID, video ID, start, end, caption, status, error, bytes, resolution, sha256, download date) so you can filter and audit without opening any shards.

    Panda's README also warns that shards are unshuffled, so all clips from one source video end up in the same shard. Shuffle before training.

    Resumability and failure tracking

    video2dataset's incremental_mode (default incremental) skips finished shards on restart. This only works if you keep the same output folder, the same number_sample_per_shard and the same input list. Failed samples record "status": "failed_to_download" and an error_message in their JSON.

    With plain yt-dlp, --download-archive FILE skips IDs that have already been downloaded. You still need to separate permanent failures from transient ones. A minimal status log:

    import json
    import subprocess
    from pathlib import Path
    
    IDS = Path("video_ids.txt")
    LOG = Path("status.jsonl")
    OUT = Path("videos")
    PERMANENT = ("Video unavailable", "Private video", "removed by the uploader")
    
    finished = set()
    if LOG.exists():
        with LOG.open() as f:
            for line in f:
                rec = json.loads(line)
                if rec["status"] in ("ok", "gone"):
                    finished.add(rec["id"])
    
    OUT.mkdir(exist_ok=True)
    with LOG.open("a") as log:
        for vid in IDS.read_text().split():
            if vid in finished:
                continue
            p = subprocess.run(
                ["yt-dlp", "--no-progress", "-S", "res:360,+size",
                 "--merge-output-format", "mp4",
                 "-o", str(OUT / "%(id)s.%(ext)s"),
                 f"https://www.youtube.com/watch?v={vid}"],
                capture_output=True, text=True,
            )
            if p.returncode == 0:
                status = "ok"
            elif any(s in p.stderr for s in PERMANENT):
                status = "gone"
            else:
                status = "retry"  # rate limits, bot checks, network errors
            log.write(json.dumps({"id": vid, "status": status,
                                  "error": p.stderr.strip()[-500:]}) + "\n")
            log.flush()

    On the next run, IDs marked retry are attempted again, while ok and gone are skipped. YouTube's error strings change over time, so check a sample of retry errors periodically and update the matcher.

    Scaling: rate limits and bot checks

    At 10k+ videos, throughput is limited by YouTube's limits rather than your code. Typical failures are HTTP 429, 403 and "Sign in to confirm you're not a bot". Things that help:

    For the end-to-end pipeline after download (dedup, filtering, captioning), see building a YouTube video dataset for multimodal training.

    Licensing and compliance

    The licenses above (Snap's non-commercial license, R-UDA, CC BY-NC-SA 4.0, CC BY 4.0) cover the annotations. They grant no rights to the underlying videos, which belong to their uploaders. YouTube's Terms of Service restrict automated access and downloading. Copyright and licensing still apply, and some teams filter for Creative Commons-licensed videos. Faces and voices raise privacy issues (e.g., GDPR), and takedown and opt-out requests should be honored. Panda-70M's authors, for example, offer removal on request. Get legal review for your jurisdiction and use case before collecting or redistributing.

    If you'd rather not run this yourself

    Corpora offers two options. If you run your own stack, dedicated 1–40 Gbps unmetered proxy capacity (HTTP(S)/SOCKS5, compatible with yt-dlp) is $2,000 per Gbps per month. Alternatively, a fully managed collection service delivers video, audio, subtitles and metadata (JSONL/Parquet with sha256 checksums and manifests) to your S3, GCS or Azure bucket, priced per delivered PB, with a free sample batch. Contact us to scope it.

    FAQ

    Can I download Panda-70M videos directly instead of from YouTube?

    Not officially. The release consists of CSVs (URLs, timestamps, captions, scores) and code. The videos have to be fetched from YouTube with the bundled video2dataset fork or your own tooling.

    How big is Panda-70M once downloaded?

    The README lists ~36 TB for the full training set (70.7M clips, 167k hours), ~8.0 TB for the 10M subset and ~1.6 TB for the 2M subset, at the default 360p with audio.

    Can I use HD-VILA-100M or InternVid commercially?

    Their annotation licenses don't allow it: R-UDA v1.0 is limited to non-commercial research, and InternVid is CC BY-NC-SA 4.0. Separately, neither license covers rights to the videos themselves.

    Why do I get fewer clips than the paper reports?

    Link rot. Videos get removed or set to private over time. Kinetics-700-2020's own download already missed about 2% within months of release. Log every failure so you can report what you actually obtained.

    Should I use video2dataset or yt-dlp?

    Use the official fork for Panda-70M. Elsewhere, video2dataset gives you sharding, WebDataset/Parquet output and resumption. Plain yt-dlp gives you more control, but you write the bookkeeping yourself.

  • yt-dlp Proxy Guide: --proxy, SOCKS5, Sessions and Workers

    Pointing yt-dlp at a proxy takes one flag. Getting it to work reliably across thousands of videos and dozens of workers takes more. This guide covers the proxy URL format, SOCKS vs HTTP, environment variables, config files, the Python API, sticky sessions, aria2c, and a troubleshooting table for the errors you'll actually hit.

    Everything below was checked against the yt-dlp README and source for release 2026.10.26, the current stable release as of November 2026. yt-dlp changes quickly, so run yt-dlp -U and check yt-dlp --help if a flag behaves differently for you.

    TL;DR

    • Use --proxy "http://USER:[email protected]:8080" or --proxy "socks5h://USER:[email protected]:1080". If the password has special characters, URL-encode it.
    • In yt-dlp, socks5:// is silently rewritten to socks5h://, so DNS is resolved by the proxy either way. That's not true of every external downloader.
    • If --proxy is not set, yt-dlp reads HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY from the environment. --proxy "" forces a direct connection.
    • Fetch a video's metadata and download its media from the same exit IP. Use sticky sessions, or one static IP per worker, rather than a new IP on every request.
    • --geo-verification-proxy does nothing for YouTube in the current source. Only --proxy matters.
    • aria2c only supports HTTP proxies, and ffmpeg can't use SOCKS proxies.

    HTTP/HTTPS vs SOCKS5, and socks5 vs socks5h

    The network options describe --proxy URL as "Use the specified HTTP/HTTPS/SOCKS proxy". The scheme you put in the URL decides the proxy type:

    Scheme What it means Notes
    http:// Plain HTTP proxy. HTTPS traffic is tunnelled through it with CONNECT The most widely supported option. A URL with no scheme is treated as http://.
    https:// TLS connection to the proxy itself Needs the requests or curl_cffi request handler. The plain urllib handler doesn't support it. The README lists requests as the dependency "for HTTPS proxy". Standalone binaries include it.
    socks5:// SOCKS5 yt-dlp rewrites it to socks5h (see below).
    socks5h:// SOCKS5 with remote DNS The proxy resolves hostnames.
    socks4://, socks4a:// SOCKS4 / 4a No username/password support in SOCKS4. Rarely useful today.

    Most tools use the h suffix to mean "let the proxy resolve DNS". Without it, the client resolves hostnames locally, which leaks DNS lookups and can pick a CDN node near your server instead of the proxy. yt-dlp handles this differently: in yt_dlp/utils/networking.py, clean_proxies() maps 'socks5': 'socks5h' with the comment "compat: socks5 was treated as socks5h". As a result, both schemes give you remote DNS for yt-dlp's own requests.

    Still write socks5h:// explicitly. It documents your intent, and the same URL may later be passed to curl or another tool where socks5:// really does mean local DNS.

    HTTP or SOCKS5? For yt-dlp alone, either works. Choose HTTP if you plan to use aria2c or ffmpeg as the downloader (see below). Choose SOCKS5 if your provider only offers SOCKS or you're tunnelling through something like ssh -D.

    The --proxy flag and URL format

    The general form is scheme://user:password@host:port:

    # HTTP proxy with credentials
    yt-dlp --proxy "http://USER:[email protected]:8080" "https://www.youtube.com/watch?v=VIDEO_ID"
    
    # SOCKS5 with remote DNS
    yt-dlp --proxy "socks5h://USER:[email protected]:1080" "https://www.youtube.com/watch?v=VIDEO_ID"
    
    # Force a direct connection (overrides env vars and config files)
    yt-dlp --proxy "" "https://www.youtube.com/watch?v=VIDEO_ID"

    URL-encode special characters in passwords

    Characters like @ : / # ? % in a username or password break URL parsing. You'll usually get a confusing connection error or a 407 instead of a clear message. Percent-encode the credentials. yt-dlp and its HTTP backends decode them before sending authentication:

    python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' 'p@ss:w/rd#1'
    # p%40ss%3Aw%2Frd%231
    
    yt-dlp --proxy "http://USER:p%40ss%3Aw%2Frd%[email protected]:8080" URL

    Always quote the proxy URL in the shell. A #, & or ! left unquoted will be read by the shell, not passed to yt-dlp.

    --geo-verification-proxy

    The README says this option makes yt-dlp "use this proxy to verify the IP address for some geo-restricted sites", while --proxy is still used for the actual download. Only extractors that call the geo_verification_headers() helper use it. In 2026.10.26, the YouTube extractor doesn't. For YouTube, --geo-verification-proxy has no effect. If you need a YouTube video to appear to come from a given country, the exit IP from --proxy is what counts.

    Environment variables

    When --proxy is absent, yt-dlp calls Python's urllib.request.getproxies() (see the proxies property in YoutubeDL.py). In practice:

    • http_proxy/HTTP_PROXY and https_proxy/HTTPS_PROXY are used for those URL schemes. If only the HTTP one is set, yt-dlp reuses it for HTTPS. Set HTTPS_PROXY=__noproxy__ to undo that.
    • all_proxy/ALL_PROXY is the fallback for any scheme.
    • no_proxy/NO_PROXY is honoured.
    • On macOS and Windows, if no proxy environment variables are set, getproxies() can fall back to the system proxy settings. A desktop VPN or proxy app can therefore change yt-dlp's routing without you noticing.
    • Any --proxy value replaces the environment completely, including --proxy "".
    export HTTPS_PROXY="http://USER:[email protected]:8080"
    export HTTP_PROXY="$HTTPS_PROXY"
    yt-dlp URL

    Config files

    Any CLI option can go into a config file. yt-dlp searches, in order: the file given to --config-locations, a portable yt-dlp.conf next to the binary, yt-dlp.conf in the -P home path (or the current directory), the user config, and the system config. Recommended user locations are ${XDG_CONFIG_HOME}/yt-dlp/config on Linux/macOS and ${APPDATA}/yt-dlp/config on Windows. ~/yt-dlp.conf, ~/.yt-dlp/config and /etc/yt-dlp.conf also work.

    # ~/.config/yt-dlp/config
    --proxy "http://USER:[email protected]:8080"
    --socket-timeout 30
    --retries 10
    --fragment-retries 10

    Two gotchas. Config lines are parsed like a shell, so quote values containing #. And --ignore-config on the command line skips all of these files. That helps when you're debugging which proxy is actually in effect. yt-dlp -v prints which config files were loaded.

    Using a proxy from the Python API

    The option keys match the CLI flags: proxy, socket_timeout, retries, fragment_retries, source_address. One difference matters a lot: according to the docstrings, retries and fragment_retries default to 0 in the API but 10 in the CLI. Set them explicitly.

    import yt_dlp
    
    opts = {
        "proxy": "http://USER:[email protected]:8080",
        "socket_timeout": 30,
        "retries": 10,
        "fragment_retries": 10,
        "outtmpl": "data/%(id)s.%(ext)s",
    }
    
    with yt_dlp.YoutubeDL(opts) as ydl:
        info = ydl.extract_info("https://www.youtube.com/watch?v=VIDEO_ID", download=True)
        print(info["id"], info.get("title"))

    Check your exit IP

    Before blaming YouTube, confirm the proxy works and see which IP you're actually exiting from. With curl:

    curl -x "http://USER:[email protected]:8080" https://api.ipify.org
    curl -x "socks5h://USER:[email protected]:1080" https://api.ipify.org

    A better check goes through yt-dlp's own networking stack, so the proxy parsing, handlers and environment handling are the same ones your downloads use:

    import yt_dlp
    
    with yt_dlp.YoutubeDL({"proxy": "http://USER:[email protected]:8080"}) as ydl:
        print(ydl.urlopen("https://api.ipify.org").read().decode())

    Run it twice in a row. If the IP changes between calls, your endpoint rotates per request or per connection. That matters for the next section.

    Sticky vs rotating sessions: keep one video on one IP

    Downloading a YouTube video takes several requests: the watch page and player API calls (metadata, format list, signed media URLs), then many range requests to googlevideo.com for the media. The yt-dlp FAQ puts it this way: "In many cases, requests for the video (to download/play it) must come from the same IP address and with the same cookies and/or HTTP headers." Google doesn't document YouTube's signed media URLs, so the exact binding rules are an observed behaviour rather than a contract. The practical rule is simple, though: if extraction happens on IP A and the media download on IP B, expect intermittent 403s. The PO Token Guide adds another binding: most PO tokens are tied to a session or a video ID, which is another reason to keep each video's requests together.

    Proxies come in two broad modes:

    • Rotating per request or connection: every new TCP connection may exit from a different IP. That works for stateless page fetches and is risky for yt-dlp, because one download opens several connections.
    • Sticky sessions: the provider pins you to one exit IP for a set time, usually selected by a session ID in the proxy username. The exact syntax is provider-specific, so check your provider's docs. Static or dedicated IPs behave like a permanently sticky session.

    The pattern that works: one sticky session per video (or per worker, for a batch of videos), long enough to cover extraction and the full media download. Then move on to a new session. Rotation still spreads load across the pool; it just happens between videos, not inside one.

    Running many workers, each with its own proxy session

    Run a separate YoutubeDL instance per job, in separate processes, and derive a session ID from the video so retries of the same video get the same IP. The username format below is illustrative; substitute your provider's sticky-session syntax.

    import hashlib
    import urllib.parse
    from concurrent.futures import ProcessPoolExecutor
    
    import yt_dlp
    
    PROXY_USER = "USER"
    PROXY_PASS = urllib.parse.quote("PASS", safe="")
    PROXY_HOST = "proxy.example.com:8080"
    
    
    def proxy_for(url: str) -> str:
        session = hashlib.sha1(url.encode()).hexdigest()[:12]
        # Sticky-session username syntax varies by provider; this format is illustrative.
        return f"http://{PROXY_USER}-session-{session}:{PROXY_PASS}@{PROXY_HOST}"
    
    
    def fetch(url: str) -> tuple[str, int]:
        opts = {
            "proxy": proxy_for(url),
            "socket_timeout": 30,
            "retries": 5,
            "fragment_retries": 5,
            "outtmpl": "data/%(id)s.%(ext)s",
            "download_archive": "archive.txt",
            "quiet": True,
            "noprogress": True,
        }
        try:
            with yt_dlp.YoutubeDL(opts) as ydl:
                return url, ydl.download([url])
        except yt_dlp.utils.DownloadError:
            return url, 1
    
    
    if __name__ == "__main__":
        with open("urls.txt") as f:
            urls = [line.strip() for line in f if line.strip()]
        with ProcessPoolExecutor(max_workers=8) as pool:
            for url, code in pool.map(fetch, urls):
                print(code, url)

    With a list of static proxies instead, assign one proxy per worker. From the shell, a file with one URL PROXY pair per line plus xargs does the same job:

    xargs -P 8 -L 1 sh -c 'yt-dlp --proxy "$1" -o "data/%(id)s.%(ext)s" -- "$0"' < jobs.txt

    The shared archive.txt above is fine for a single machine. Across machines, use a real job queue. Concurrency per IP matters as much as the total: many parallel videos behind one exit IP is how IPs get flagged. Pacing and IP reputation are covered in fixing "Sign in to confirm you're not a bot", and sizing the total pipe in bandwidth for large-scale video downloads.

    aria2c as the external downloader

    --downloader aria2c hands HTTP/FTP downloads to aria2c. In downloader/external.py, yt-dlp passes your --proxy value to aria2c as --all-proxy. It also sets -x16 -s16 --min-split-size 1M by default, then appends whatever you give --downloader-args.

    yt-dlp --proxy "http://USER:[email protected]:8080" \
      --downloader aria2c \
      --downloader-args "aria2c:-x 4 -s 4 --connect-timeout=15 --timeout=30 --max-tries=5" \
      "https://www.youtube.com/watch?v=VIDEO_ID"

    Three caveats:

    • HTTP proxies only. The aria2 manual gives the proxy format as [http://][USER:PASSWORD@]HOST[:PORT]. aria2c doesn't speak SOCKS. With a SOCKS proxy, use the native downloader. (ffmpeg has the same limitation: yt-dlp warns that it "does not support SOCKS proxies".)
    • 16 connections per file. On a per-connection rotating proxy, each segment may come from a different IP, which brings back the 403 problem. Use a sticky session and fewer connections (-x 4), or skip aria2c.
    • aria2c reads proxy environment variables itself. Only an explicit --proxy is forwarded by yt-dlp, so test the exact configuration you deploy.

    Add -v to see the final command line ([debug] aria2c command line: ...) and confirm which arguments took effect.

    Timeouts and retries

    Flag (API key) Default What it does
    --socket-timeout (socket_timeout) 20 s (DEFAULT_TIMEOUT in the networking layer) How long to wait on an unresponsive connection. Raise it to 30–60 s for slow residential exits.
    -R, --retries (retries) 10 CLI / 0 API Retries for the download on expected network errors. Accepts infinite.
    --fragment-retries (fragment_retries) 10 CLI / 0 API Retries per fragment for DASH, hlsnative and ISM.
    --retry-sleep [TYPE:]EXPR none Backoff between retries, e.g. --retry-sleep exp=1:30 or --retry-sleep fragment:linear=1::2.
    --extractor-retries 3 Retries for known extractor errors.

    Retries go through the same proxy URL. If that URL is a sticky session, they reuse the same IP, which is what you want for a transient error. For a hard block (repeated 403 or 429), retrying on the same IP rarely helps. Fail fast, record the video, and requeue it on a fresh session.

    Choosing a proxy type for this workload

    Video collection is unusual among proxy workloads: few requests, lots of bytes. That changes the trade-offs.

    Type Strengths Weaknesses for yt-dlp
    Datacenter (shared) Cheap, fast, stable IPs Cloud and hosting ranges are the most likely to get bot checks and 403s from YouTube. Shared IPs inherit other users' reputation.
    Residential (rotating) IPs look like home users, many locations Usually billed per GB, which gets expensive at video volumes. Bandwidth and latency vary by peer. Sessions can drop mid-download.
    ISP / static residential Residential-registered IPs on datacenter hardware. Stable, so naturally sticky Fewer IPs per dollar. A flagged IP stays flagged until replaced.
    High-bandwidth dedicated (unmetered, per-Gbps) Predictable cost for terabyte-scale transfer, consistent throughput, capacity reserved for you You pay for the capacity whether you use it or not. IP reputation still depends on the ranges and on how hard you push each IP.

    The test that matters is your own: run a few hundred videos through each candidate, then compare 403/bot-check rates and cost per delivered GB.

    Troubleshooting

    Symptom Likely cause Fix
    407 Proxy Authentication Required Wrong credentials, unencoded special characters in the password, or source-IP allow-listing on the provider side URL-encode the credentials. Test with curl -x. Check the provider's allow-list.
    Unable to connect to proxy / Connection refused Wrong host or port, wrong scheme (SOCKS port with http://), or a firewall Match the scheme to the port the provider documents. Check with curl -x.
    SSL: CERTIFICATE_VERIFY_FAILED or WRONG_VERSION_NUMBER Using https:// for a plain HTTP proxy (or the reverse), or an intercepting proxy Usually you want http://. --no-check-certificates hides the problem instead of fixing it; only use it for debugging.
    Timeouts, Read timed out Slow or overloaded exit, peer dropped Raise --socket-timeout, add --retry-sleep, reduce concurrency per IP.
    HTTP Error 403 on media after extraction succeeded Extraction and download from different IPs (rotating proxy, aria2c multi-connection), missing PO token, outdated yt-dlp Sticky session per video, yt-dlp -U, see the PO Token Guide.
    "Sign in to confirm you're not a bot" IP reputation or request rate See our dedicated guide.
    Proxy seems ignored --proxy in a config file, a system or desktop proxy, or NO_PROXY matching yt-dlp -v --ignore-config, then check the exit IP through ydl.urlopen.

    A note on compliance

    YouTube's Terms of Service restrict automated access and downloading, and a proxy doesn't change that. If you're collecting video for training data, copyright and licensing also matter (for example, filtering for Creative Commons–licensed content), as do privacy obligations for faces and voices (GDPR and similar laws) and a process for honouring takedown and opt-out requests. Get legal review for your jurisdiction and use case.

    If you'd rather not run this yourself

    Corpora offers dedicated 1–40 Gbps unmetered proxy capacity (HTTP(S) and SOCKS5, yt-dlp compatible, rotating pools, multi-region) for teams running their own stack. See pricing. If you only need the end result, Corpora can also collect, deduplicate and deliver video, audio, subtitles and metadata to your object storage. Get in touch for a free sample batch. For the pipeline side, see building a YouTube video dataset for multimodal training and downloading Panda-70M, HD-VILA and InternVid.

    FAQ

    How do I use a SOCKS5 proxy with yt-dlp?

    Pass --proxy "socks5h://USER:[email protected]:1080". yt-dlp treats socks5:// as socks5h://, so DNS goes through the proxy either way. Don't combine SOCKS with --downloader aria2c or ffmpeg, which don't support SOCKS proxies.

    Does yt-dlp use the HTTP_PROXY environment variable?

    Yes, when --proxy isn't given. It reads HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY (upper or lower case) through Python's urllib. Any --proxy value, including --proxy "", overrides them.

    Why do I get 403 Forbidden when downloading through a rotating proxy?

    Usually because the media requests left from a different IP than the one that fetched the video's metadata and signed URLs. Use a sticky session for the whole video. Also update yt-dlp and check PO-token requirements.

    Can I use a different proxy for each video in yt-dlp?

    Not within a single CLI invocation. --proxy applies to the whole run. Launch one process (or one YoutubeDL instance) per video or per worker, each with its own proxy URL, as in the worker example above.

    How do I put a password with special characters in the yt-dlp proxy URL?

    Percent-encode it, e.g. with urllib.parse.quote(password, safe=""), so that @ becomes %40 and : becomes %3A. Then quote the whole URL in the shell.

  • yt-dlp "Sign in to confirm you're not a bot": How to Fix It

    If yt-dlp suddenly fails with ERROR: [youtube] VIDEO_ID: Sign in to confirm you're not a bot, YouTube has decided that your session or your IP address looks automated. It often shows up together with HTTP 403, HTTP 429 or "This content isn't available, try again later". This guide walks through the fixes in order, cheapest first. It keeps two cases apart: one person on a laptop, and a pipeline doing thousands of downloads per hour from servers.

    Everything below was checked against the yt-dlp README, the official wiki and the source code as of early November 2026, when the latest stable release was 2026.10.26. YouTube changes enforcement often, so treat client names and limits as a snapshot.

    TL;DR

    • Update first. Run yt-dlp -U, or switch to the nightly channel with yt-dlp --update-to nightly. Many "bot" and 403 reports turn out to be outdated installs.
    • Install a JavaScript runtime. YouTube extraction now needs an external JS runtime (Deno is the default) plus the yt-dlp-ejs scripts. Without them, some formats go missing.
    • Slow down. The wiki puts the guest rate limit at about 300 videos/hour per session. -t sleep applies sensible delays for you.
    • PO tokens fix 403s on some clients, not the bot check. Use a PO Token Provider plugin such as bgutil-ytdlp-pot-provider.
    • Cookies work, but they put the account at risk. Fine for one person on a laptop. At pipeline scale, expect accounts to be banned.
    • On servers, IP reputation decides most of the outcome. Datacenter ranges get the bot check much more often than residential IPs. Spread load across IPs and sessions instead of pushing harder through one.

    What the error actually means

    The text comes from YouTube's player response, not from yt-dlp. yt-dlp appends a hint ("Use --cookies-from-browser or --cookies for the authentication") and links the wiki's cookie instructions. Don't misread it: the video doesn't need an account. YouTube wants a session it trusts more than an anonymous request from your IP.

    The related errors have different causes, which is why they need different fixes:

    • "This content isn't available, try again later" means your session hit the rate limit. In current yt-dlp versions, the error says the session "has been rate-limited by YouTube for up to an hour" and suggests -t sleep.
    • HTTP Error 403: Forbidden on the actual media download usually means a missing or invalid PO token for the client in use, an outdated yt-dlp, or a JS challenge that failed. Less often, the IP is blocked.
    • HTTP Error 429: Too Many Requests means your IP is being throttled. The yt-dlp FAQ calls this "usually a soft block".

    Error → cause → fix

    Error message Likely cause First fix to try
    Sign in to confirm you're not a bot Low-reputation IP (often datacenter), or too many requests from one IP or session Update, slow down, and change egress IP. Use cookies only as a last resort (account risk).
    This content isn't available, try again later Session rate limit: about 300 videos/hour for guests, about 2,000/hour for accounts (per the wiki) -t sleep or the sleep flags. Wait up to an hour.
    HTTP Error 403: Forbidden on media/fragments Missing PO token (GVS) for the chosen client, outdated yt-dlp, or a JS challenge that wasn't solved Update, install Deno and EJS, add a PO Token Provider plugin
    HTTP Error 429: Too Many Requests IP throttled for overuse Back off. Use fewer parallel workers per IP. Move to another IP.
    No supported JavaScript runtime could be found (warning) No Deno, or another runtime not enabled Install Deno, or pass --js-runtimes node
    n challenge solving failed: Some formats may be missing EJS scripts missing or outdated, or the runtime is too old Update yt-dlp together with yt-dlp-ejs. Check the runtime version.
    YouTube is requiring a captcha challenge before playback IP or session flagged Stop and rotate the IP. Don't retry in a tight loop.

    Step 1: Diagnose with -v

    Before you change anything, run a single failing URL with verbose output:

    yt-dlp -v "https://www.youtube.com/watch?v=VIDEO_ID"

    Check the [debug] header for the version and channel, the JS runtimes: line, and the detected JS Challenge Providers and PO Token Providers. If the runtimes line says none, or a provider you installed is missing, you've found the problem. Add --print-traffic for the raw HTTP exchange.

    Step 2: Update yt-dlp (and pick the right channel)

    YouTube breaks yt-dlp regularly, and fixes ship fast. The README's update section describes three channels. stable is released on a roughly monthly schedule and is "often 'stale' and prone to external breakage". nightly is the "recommended channel for regular users". master publishes after every push.

    # Official binary: update within the current channel
    yt-dlp -U
    
    # Switch a stable binary to nightly
    yt-dlp --update-to nightly
    
    # pip install: re-run the install, including the default extras (pulls in yt-dlp-ejs)
    python3 -m pip install -U "yt-dlp[default]"
    
    # pip nightly (development releases)
    python3 -m pip install -U --pre "yt-dlp[default]"

    If your distro package is outdated, the FAQ says to install the official release instead. In a pipeline, pin a version in your image but rebuild it often.

    Step 3: Install a JavaScript runtime and the EJS scripts

    This is the newest requirement and a common cause of missing formats and 403s. Per the EJS wiki page, yt-dlp now solves YouTube's JavaScript challenges with an external runtime that runs the yt-dlp-ejs solver scripts. The old built-in interpreter is no longer used for YouTube, and the code now calls extraction without a JS runtime "deprecated".

    • Deno (2.3.0 or newer) is recommended and enabled by default.
    • Node (22.0.0 or newer) works with --js-runtimes node. QuickJS works with --js-runtimes quickjs. Bun support is deprecated.
    • The official binaries (yt-dlp_linux, yt-dlp.exe, the zipimport yt-dlp) bundle yt-dlp-ejs. If you install with pip, use yt-dlp[default]. If your distro package doesn't ship the scripts, add --remote-components ejs:github (or ejs:npm with Deno or Bun).
    # Deno somewhere other than PATH
    yt-dlp --js-runtimes "deno:/opt/deno/bin/deno" URL
    
    # Use Node instead of Deno (clear the defaults first if Deno is also installed)
    yt-dlp --no-js-runtimes --js-runtimes node URL

    The wiki stresses that yt-dlp-ejs must be updated together with yt-dlp, because older versions are ignored. In Docker images, install both in the same layer.

    Step 4: Slow down

    The Extractors wiki gives concrete numbers. With default settings, the limit is "~300 videos/hour (~1000 webpage/player requests per hour)" for guest sessions and "~2000 videos/hour (~4000 webpage/player requests per hour)" for accounts. It recommends a delay of about 5–10 seconds between downloads.

    The easiest option is the built-in preset. -t sleep expands to --sleep-subtitles 5 --sleep-requests 0.75 --sleep-interval 10 --max-sleep-interval 20. The individual flags:

    Flag What it does
    --sleep-requests SECONDS Sleep between requests during extraction (webpage, player API calls)
    --sleep-interval SECONDS (alias --min-sleep-interval) Sleep before each download. Acts as the minimum when combined with the max.
    --max-sleep-interval SECONDS Upper bound for a random sleep. Only valid together with the minimum.
    --sleep-subtitles SECONDS Sleep before each subtitle download
    -r, --limit-rate RATE Cap download bandwidth, e.g. 5M. Makes traffic look less bursty but doesn't change the request count.
    -R, --retries / --retry-sleep Retry count (default 10) and backoff, e.g. --retry-sleep http:exp=1:120

    Throughput math: with -t sleep the per-video sleep averages (10 + 20) / 2 = 15 s, so one worker starts at most 3600 / 15 = 240 videos/hour, under the ~300/hour guest figure. Faster pipelines need more sessions, not less sleep.

    Step 5: PO tokens and player clients (for 403s)

    The PO Token Guide describes a Proof of Origin token that some YouTube clients must send. Without it, "requests for the affected clients' format URLs may return HTTP Error 403, or result in your account or IP address being blocked". Tokens are now bound to the video ID, so pasting a token by hand is no longer recommended. The guide's recommended setup is a PO Token Provider plugin that feeds the mweb client. It features two plugins: bgutil-ytdlp-pot-provider and yt-dlp-getpot-wpc.

    # 1) Run the bgutil token server, bound to loopback only
    docker run --name bgutil-provider -d --init \
      -p 127.0.0.1:4416:4416 brainicism/bgutil-ytdlp-pot-provider
    
    # 2) Install the plugin next to a pip-installed yt-dlp
    python3 -m pip install -U bgutil-ytdlp-pot-provider
    
    # 3) Use it with the mweb client
    yt-dlp -v --extractor-args "youtube:player_client=default,mweb" URL

    In the -v output, look for [pot:bgutil:http] Generating a gvs PO Token. The bgutil README warns that leaving 127.0.0.1 out of the port mapping exposes an unauthenticated server.

    About player_client: in 2026.10.26 the README gives the default as tv_samsung,visionos,web, with a different set when logged-in cookies are passed. Use default,-web syntax to add or exclude clients. The wiki's PO token table predates some of these clients, so check the README for your installed version rather than copying old forum advice.

    An important limit, stated in the bgutil README: a PO token does not bypass IP-based login restrictions. If you still see "Sign in to confirm you're not a bot" after a token was generated, which is common on datacenter IPs, the problem is your IP or session, not the token.

    Step 6: Cookies (laptop yes, pipeline carefully)

    For a single user, logged-in cookies are the quickest way past the bot check. The Extractors wiki has a caution box: "By using your account with yt-dlp, you run the risk of it being banned (temporarily or permanently)." It suggests a throwaway account. YouTube rotates cookies on open tabs, so the wiki's method for long-lived cookies is this: log in from a private window, open https://www.youtube.com/robots.txt in that same tab, export the youtube.com cookies with an extension, then close the window.

    # Laptop: read cookies straight from a browser profile
    yt-dlp --cookies-from-browser firefox URL
    
    # Exported Netscape-format file
    yt-dlp --cookies /secure/path/cookies.txt URL

    Two practical points. First, the wiki says not to export with --cookies-from-browser BROWSER --cookies FILE for this purpose, because that exports your regular browser cookies, not the private session. Second, --cookies FILE both reads and writes the file. If you share one file across parallel workers, they share one session, write over each other's changes, and burn through that account's ~2,000 videos/hour budget together.

    At pipeline scale, don't build on accounts. Accounts get a higher rate limit, but every ban costs you one. Use cookies to retry the occasional login-gated video, not as your main path.

    Step 7: IP reputation, proxies and spreading load

    This is the step laptop guides skip. YouTube judges requests partly by source IP, and cloud and hosting ranges carry far more automated traffic than home connections. They hit the bot check much sooner, so a command that works at home can fail immediately on a fresh VM. Google publishes no thresholds, so test on your own ranges.

    yt-dlp's tools for this are --proxy (HTTP, HTTPS or SOCKS) and --source-address, which binds to a specific local IP if your host has several. Use -4 or -6 to pin the address family.

    # HTTP proxy with auth
    yt-dlp --proxy "http://USER:[email protected]:8080" URL
    
    # SOCKS5 (note the scheme)
    yt-dlp --proxy "socks5://USER:[email protected]:1080" URL

    For a full walkthrough of proxy types, authentication and rotation patterns, see our yt-dlp proxy guide.

    A pattern for thousands of downloads per hour

    1. One worker = one egress IP = one guest session. Give each worker its own proxy endpoint or sticky session and its own cookie jar (or none). Don't share state between workers.
    2. Size the pool with arithmetic, not hope. With -t sleep a worker waits 10–20 s (15 s on average) before each download, so it tops out at about 3,600 ÷ 15 = 240 videos/hour before counting the download time itself. Using that ceiling, a target of 5,000 videos/hour needs at least 5000 / 240 ≈ 21 workers, each on separate IPs. Plan for more, since some IPs will be cooling down at any moment. For bandwidth sizing, see our bandwidth guide.
    3. Treat bot and rate-limit errors as signals, not retryable failures. When an IP returns the bot check or a 429, take it out of rotation for at least an hour. Hammering it with retries extends the block.
    4. Keep the toolchain current everywhere. yt-dlp, yt-dlp-ejs, Deno and the PO token plugin should be rebuilt together on a regular schedule.
    5. Make runs resumable. Use --download-archive so a rotated or restarted worker skips finished IDs.
    # yt-dlp.conf for one pipeline worker
    --proxy http://USER:[email protected]:8080
    --sleep-requests 1
    --sleep-interval 10
    --max-sleep-interval 20
    --sleep-subtitles 5
    --retries 5
    --retry-sleep http:exp=1:120
    --extractor-retries 3
    --download-archive /data/archive.txt
    -o /data/%(id)s.%(ext)s

    For the surrounding pipeline (manifests, deduplication, metadata), see how to build a YouTube video dataset for multimodal training and our notes on downloading Panda-70M, HD-VILA and InternVid.

    Troubleshooting order, cheapest first

    1. Run -v on one URL and read the debug header.
    2. Update yt-dlp (-U or --update-to nightly) and yt-dlp-ejs.
    3. Install Deno (or enable Node/QuickJS) and confirm the JS runtimes: line.
    4. Add -t sleep and cut parallelism per IP.
    5. Install a PO Token Provider plugin. Try player_client variations if 403s persist.
    6. Laptop: pass cookies from a throwaway account. Pipeline: move to cleaner IPs and spread load across more of them.
    7. Still stuck on the latest nightly? Check the pinned known-issues thread and the issue tracker before you file a report with full -v output.

    A note on compliance

    YouTube's Terms of Service restrict automated access and downloading. Building a training set also involves copyright and licensing (for example, filtering for Creative Commons content), privacy rules for faces and voices (such as GDPR), and honoring takedown and opt-out requests. Get legal review for your jurisdiction and use case.

    If you'd rather not run this yourself

    Corpora offers dedicated 1–40 Gbps unmetered proxy capacity (HTTP(S) and SOCKS5, rotating pools, multi-region) that drops into the --proxy setup above, at $2,000 per Gbps per month. If you'd rather skip the infrastructure, we can also collect and deliver video, audio, subtitles and metadata straight to your object storage. Get in touch for a free sample batch.

    FAQ

    Why does yt-dlp say "Sign in to confirm you're not a bot" on my server but not on my laptop?

    The message comes from YouTube and depends heavily on the source IP. Datacenter and cloud IPs are flagged far more readily than residential connections. Updating and slowing down help, but on servers the lasting fix is better IPs plus spreading load across them.

    Does a PO token fix "Sign in to confirm you're not a bot"?

    Usually not. PO tokens fix 403s on clients that need them. Per the bgutil README, a token "does not bypass IP-based login restrictions".

    Do I need Deno for yt-dlp now?

    For YouTube you need a supported JavaScript runtime. Deno is the default. Node and QuickJS work with --js-runtimes.

    Is using cookies with yt-dlp safe?

    The wiki warns the account can be banned temporarily or permanently. Use a throwaway account and low request rates.

    How long does the "This content isn't available, try again later" block last?

    yt-dlp's own message says the session is rate-limited "for up to an hour". Wait, then resume with -t sleep or equivalent delays so you stay under the roughly 300 videos/hour guest limit.

  • Bandwidth for Large-Scale Video Downloads: 100 TB to 5 PB/Month

    If you need to collect 100 TB, 1 PB or 5 PB of video or audio per month, the first question is not which tool to use. It is how many gigabits per second you need to keep running all month. This guide gives you the exact formula, sizing tables for 10 TB to 5 PB per month, the reverse table (what 1–40 Gbps can actually move), and the practical limits that decide whether you hit those numbers.

    TL;DR

    • Formula: Gbps = TB/month × 8,000 ÷ seconds per month. With a 30-day month (2,592,000 s), 1 Gbps at 100% utilization moves 324 TB/month, or 10.8 TB/day.
    • 100 TB/month ≈ 0.31 Gbps, 1 PB/month ≈ 3.09 Gbps, 5 PB/month ≈ 15.4 Gbps. That assumes the link is saturated 24/7, and it won't be.
    • Plan for 60–70% sustained utilization. That means provisioning about 1.4–1.7× the theoretical number.
    • 800 TB/month ≈ 2.47 Gbps at 100%, so provision about 4–5 Gbps.
    • Writing data into S3 or GCS from the internet is free. Reading it back out to the internet is not.
    • Per-GB pricing only makes sense at low volume. At $2,000 per Gbps per month, the break-even is $2,000 ÷ (price per GB) GB per month.

    The formula

    Bandwidth is measured in bits per second, but storage is measured in bytes. You convert between them, then divide by the number of seconds in the period:

    Gbps = TB_per_month × 8,000 / seconds_per_month
    
    # 1 TB (decimal) = 10^12 bytes = 8 × 10^12 bits = 8,000 Gb
    # 30-day month    = 30 × 86,400   = 2,592,000 s
    # average month   = 30.44 × 86,400 ≈ 2,630,016 s  (365.25 / 12 days)

    This article uses the 30-day month. It is the more conservative choice, because fewer seconds means a slightly higher Gbps figure. The 30.44-day average lowers every result by about 1.5% (100 TB/month comes out at 0.304 Gbps instead of 0.309). For capacity planning that difference is smaller than your error bars.

    TB vs TiB

    Network speeds are always decimal: 1 Gbps = 10^9 bits per second. Storage tools often report binary units even when they label them "TB". Check whether df -h, your object-store console or your billing export means TB or TiB:

    • 1 TB = 10^12 bytes; 1 TiB = 2^40 bytes ≈ 1.0995 TB (about 10% larger).
    • 1 PB = 10^15 bytes; 1 PiB = 2^50 bytes ≈ 1.126 PB (about 12.6% larger).
    • 100 TiB/month needs 0.339 Gbps, compared with 0.309 Gbps for 100 TB. 1 PiB/month needs 3.47 Gbps, compared with 3.09 Gbps for 1 PB.

    If your target is set in TiB or PiB, convert it to decimal before you use the tables below.

    Monthly volume → required bandwidth

    The "100%" column is pure arithmetic. The provisioned columns divide it by 0.70 and 0.60, which are the sustained utilization levels you can realistically plan around (the next section explains why). All values use a 30-day month and decimal units.

    Volume / month Per day Gbps at 100% Provision at 70% util. Provision at 60% util.
    10 TB 0.33 TB 0.031 0.04 Gbps 0.05 Gbps
    50 TB 1.67 TB 0.154 0.22 Gbps 0.26 Gbps
    100 TB 3.33 TB 0.309 0.44 Gbps 0.51 Gbps
    250 TB 8.33 TB 0.772 1.10 Gbps 1.29 Gbps
    500 TB 16.7 TB 1.543 2.20 Gbps 2.57 Gbps
    800 TB 26.7 TB 2.469 3.53 Gbps 4.12 Gbps
    1 PB 33.3 TB 3.086 4.41 Gbps 5.14 Gbps
    2 PB 66.7 TB 6.173 8.82 Gbps 10.29 Gbps
    5 PB 166.7 TB 15.432 22.05 Gbps 25.72 Gbps

    Round up to whatever increments your provider sells. For example, 1 PB/month lands at 5 Gbps provisioned, 2 PB/month at 10 Gbps, and 5 PB/month at 25 Gbps.

    Why plan for 60–70% utilization, not 100%

    The 100% number assumes every second of the month moves useful bytes at line rate. In practice, several things get in the way:

    • Protocol overhead. Ethernet, IP and TCP headers, TLS records and HTTP framing all use bandwidth on the wire but don't count toward the bytes stored on disk. Proxy hops add their own overhead on top.
    • Retries and failed or partial downloads. A download that dies at 80% and restarts from zero (or re-fetches fragments) still used bandwidth. Expired stream URLs, bot checks (see fixing "Sign in to confirm you're not a bot") and connection resets all show up as wasted bytes.
    • Non-media traffic. Metadata extraction, manifest and player requests, subtitle fetches and format probing all use requests and time without adding much volume.
    • Per-connection throughput limits. A single stream rarely fills a fast link. Unless you keep enough downloads in flight at all times, the pipe sits partly idle.
    • Diurnal and operational gaps. Queue refills, deploys, worker restarts, rate-limit backoff, proxy rotation and the daily traffic patterns of shared upstreams all create troughs. A 24-hour average is always below the peak.

    Sustained 60–70% utilization is a reasonable target for a well-run pipeline. It is a planning assumption, not a measured guarantee. If your own monitoring shows 80% sustained, you can provision closer to the 100% column. If you are running at 40%, fix the pipeline before you buy more bandwidth.

    Worked example: 800 TB per month

    800 × 8,000 / 2,592,000 = 2.469 Gbps   (at 100% utilization, 30-day month)
    800 × 8,000 / 2,630,016 = 2.433 Gbps   (30.44-day average month)
    
    2.469 / 0.70 = 3.53 Gbps
    2.469 / 0.60 = 4.12 Gbps
    → provision ~4–5 Gbps
    
    At 4 Gbps provisioned, 800 TB needs 2.469 / 4 = 62% sustained utilization.
    At 5 Gbps provisioned, it needs 2.469 / 5 = 49%, which leaves room for re-downloads and growth.

    Bandwidth → monthly volume (reverse table)

    This table answers the opposite question: you already have a link, so how much can it move? The formula is TB = Gbps × seconds / 8,000.

    Link Per day at 100% Per 30-day month at 100% Per 30.44-day month at 100% Per 30-day month at 65%
    1 Gbps 10.8 TB 324 TB 329 TB 211 TB
    2 Gbps 21.6 TB 648 TB 658 TB 421 TB
    5 Gbps 54 TB 1.62 PB 1.64 PB 1.05 PB
    10 Gbps 108 TB 3.24 PB 3.29 PB 2.11 PB
    20 Gbps 216 TB 6.48 PB 6.58 PB 4.21 PB
    40 Gbps 432 TB 12.96 PB 13.15 PB 8.42 PB

    A useful rule of thumb: 1 Gbps sustained ≈ 10.8 TB/day ≈ 324 TB per 30 days. Everything else scales linearly from there.

    What that volume means in hours of media

    Training-data budgets are usually set in hours, not terabytes. To convert between them you need an average bitrate:

    GB per hour      = Mbps × 3,600 / 8 / 1,000  = Mbps × 0.45
    hours per PB     = 1,000,000 / (GB per hour)

    Delivered bitrates on YouTube vary a lot with codec (H.264/AVC, VP9, AV1), frame rate and content. Static talking heads compress far better than sports. The figures below are approximate planning assumptions, not published specifications. For reference, YouTube's recommended upload encoding settings list 8 Mbps for 1080p SDR at 24–30 fps and 12 Mbps at 48–60 fps. Those are upload targets; the re-encoded streams YouTube delivers are usually well below them.

    Format (approximate) Assumed bitrate GB per hour Hours per 100 TB Hours per 1 PB
    Audio only, AAC (m4a) ~128 kbps 0.128 Mbps 0.058 ~1.74 M ~17.4 M
    Audio only, Opus (webm) ~160 kbps 0.16 Mbps 0.072 ~1.39 M ~13.9 M
    720p video + audio 1.5 Mbps 0.675 ~148 k ~1.48 M
    1080p, efficient codec (VP9/AV1) 2 Mbps 0.9 ~111 k ~1.11 M
    1080p, H.264 or high-motion 4 Mbps 1.8 ~56 k ~556 k
    2160p (4K) 15 Mbps 6.75 ~15 k ~148 k

    The format choice can change your bandwidth bill by an order of magnitude. Audio-only collection for speech models needs roughly 1/12th to 1/30th of the bandwidth of 1080p video for the same number of hours. Before you size anything, measure your actual mix on a sample with yt-dlp. -O/--print implies simulate mode, so nothing is downloaded:

    yt-dlp -f "bv*[height<=1080]+ba/b[height<=1080]" \
      -O "%(id)s %(duration)s %(tbr)s %(filesize,filesize_approx)s" \
      --batch-file sample_urls.txt > sample_sizes.txt

    tbr is the total average bitrate in kbps, and filesize or filesize_approx is in bytes (it may print NA when unknown). Use -F to see every available format for a single video. Multiply the sample's mean bytes per second of duration by your target hours to get TB, then use the first table. For format selection and proxy flags, see the yt-dlp proxy guide.

    Storage, ingress and egress costs

    • Ingress is free on the big clouds. The Amazon S3 pricing page lists "data transferred in from the internet" among the transfers you don't pay for. Google's Cloud Storage pricing lists inbound data transfer as free. You still pay per-request charges (PUT/POST, multipart parts), so avoid uploading millions of tiny objects.
    • Egress is charged. S3 includes 100 GB/month of internet egress free, aggregated across AWS services, and charges per GB beyond that. For GCS general network usage, internet egress starts at $0.12/GiB for the first 10 TiB to worldwide destinations (excluding Asia and Australia) and steps down at volume. Moving a petabyte out of a cloud to train elsewhere costs a lot, so decide early where training will run.
    • Storage grows linearly. At 1 PB/month you add 12 PB a year before replicas. Multiply by your per-GB-month rate and storage class, and budget for lifecycle rules (cold tiers for raw downloads, hot tiers for curated shards).
    • Downloading on a cloud VM means your proxy or provider traffic arrives as VM ingress, which is free on AWS and GCP. But the traffic between your VM and an external proxy is outbound requests plus inbound responses, so check your provider's NAT gateway and inter-zone charges too.

    Per-GB vs per-Gbps pricing: break-even

    Proxy and transit capacity is sold in one of two ways: metered (per GB transferred) or flat (per Gbps of committed capacity, unmetered). Using $2,000 per Gbps per month as the flat price, the break-even is:

    break_even_GB_per_month = flat_price_per_Gbps / price_per_GB
                            = 2,000 / price_per_GB
    
    1 Gbps can move at most 324,000 GB per 30 days (~210,000 GB at 65% utilization).

    The per-GB prices below are illustrative examples, not quotes from any provider:

    Example per-GB price Break-even volume per Gbps Cost of 100 TB metered Cost of 1 PB metered
    $0.10 / GB 20 TB/month $10,000 $100,000
    $0.50 / GB 4 TB/month $50,000 $500,000
    $1.00 / GB 2 TB/month $100,000 $1,000,000
    $3.00 / GB 0.67 TB/month $300,000 $3,000,000

    Even at $0.10/GB, metered pricing costs more than a flat 1 Gbps line once you move more than 20 TB/month, which is about 6% of that line's capacity. At the 100 TB to 5 PB volumes in this article, per-GB pricing is only reasonable for small, hard-to-reach slices of traffic. It doesn't work for the bulk of the download volume.

    Practical limits that decide whether you hit the number

    Parallelism

    Required concurrency follows directly from per-connection throughput:

    concurrent_downloads = target_Gbps × 1,000 / per_connection_Mbps
    
    e.g. 2.5 Gbps at 20 Mbps per download  → 125 downloads in flight
         15 Gbps  at 20 Mbps per download  → 750 downloads in flight

    The 20 Mbps figure is only an example. Measure your own per-download throughput through your proxies, because server-side pacing, proxy hops and round-trip time all cap a single stream. TCP itself sets a ceiling of window ÷ RTT: a 64 KiB window at 100 ms RTT tops out around 5.2 Mbps. yt-dlp's -N/--concurrent-fragments parallelizes fragments within one DASH/HLS download, but at petabyte scale most of the parallelism comes from running many independent downloads across many workers and IPs.

    Disk write throughput

    Divide Gbps by 8 to get the sustained write rate: 2.5 Gbps ≈ 312 MB/s, 15.4 Gbps ≈ 1.93 GB/s, and that is around the clock. If you download separate video and audio streams and merge them with ffmpeg locally, every byte is written at least twice and read once. Plan for roughly 2–3× the network rate in disk I/O, or stream finished files directly to object storage and keep only scratch space local.

    NIC and host limits

    A 10 GbE NIC can't carry 10 Gbps of useful payload once you subtract overhead. TLS termination, Python processes per download, ffmpeg remuxing and checksum computation all use CPU. File descriptors, ephemeral ports and conntrack tables run out at thousands of concurrent connections, so raise ulimit -n and check kernel limits before blaming the network.

    Why one fast server is not enough

    A single 25 or 40 Gbps box is a single point of failure, a single egress IP set (which gets rate-limited or challenged as one unit), and a single disk and CPU bottleneck. Horizontal scaling works better: many workers of a few Gbps each, pulling from a shared queue, uploading to object storage, and spread across regions and IP pools. If one node dies, you lose a fraction of throughput instead of the whole month's schedule.

    Monitor utilization, not just "is it running"

    • Track bytes stored per hour against the target rate (for 1 PB/month that is about 1.39 TB/hour), not just link utilization. The gap between the two is your waste.
    • Graph link utilization per worker and in aggregate, and alert on sustained drops below your planning assumption.
    • Track the retry rate, failure reasons (HTTP 403/429, bot checks, timeouts) and the bytes discarded from partial downloads.
    • Watch the queue depth so workers never idle waiting for URLs.

    Compliance

    YouTube's Terms of Service restrict automated access and downloading. Copyright and licensing matter: many teams filter for Creative Commons-licensed videos. Privacy also matters, since faces and voices can be personal data under GDPR, and so does honoring takedown and opt-out requests. Get legal review for your jurisdiction and use case before you collect at scale. For dataset design, see building a YouTube video dataset for multimodal training and downloading Panda-70M, HD-VILA and InternVid.

    If you'd rather not run this yourself

    Corpora sells dedicated 1–40 Gbps unmetered proxy capacity (HTTP(S) and SOCKS5, compatible with yt-dlp and custom crawlers) at $2,000 per Gbps per month, with volume discounts from 5 Gbps. See pricing. If you only want the data, the fully managed option delivers deduplicated video, audio, subtitles and metadata straight to your S3, GCS or Azure bucket, priced per delivered PB. Get in touch for a free sample batch.

    FAQ

    How many Gbps do I need for 1 PB per month?

    1 PB × 8,000 ÷ 2,592,000 s ≈ 3.09 Gbps at 100% utilization over a 30-day month. At a realistic 60–70% sustained utilization, provision about 4.4–5.1 Gbps. In practice that means 5 Gbps.

    How much data can a 1 Gbps connection download in a month?

    About 324 TB in 30 days at 100% utilization (10.8 TB/day), or about 329 TB over an average 30.44-day month. At 65% sustained utilization, expect about 210 TB.

    How much bandwidth do I need for 100 TB per month?

    About 0.31 Gbps sustained. At 60–70% utilization, provision 0.44–0.51 Gbps, so a 1 Gbps line has plenty of headroom.

    Does uploading to S3 or Google Cloud Storage cost bandwidth fees?

    No. Data transfer in from the internet is free on both S3 and GCS according to their pricing pages. Requests and storage are billed, and so is data transfer out to the internet.

    Should I size in TB or TiB?

    Use decimal TB to match network units (Gbps is decimal). If your storage reports TiB, multiply by 1.0995 before you use the formula. A 100 TiB target needs about 10% more bandwidth than 100 TB.