{"id":18,"date":"2026-11-04T03:14:23","date_gmt":"2026-11-04T03:14:23","guid":{"rendered":"https:\/\/globalcorpora.com\/blog\/build-youtube-video-dataset-for-multimodal-training\/"},"modified":"2026-11-04T03:14:23","modified_gmt":"2026-11-04T03:14:23","slug":"build-youtube-video-dataset-for-multimodal-training","status":"publish","type":"post","link":"https:\/\/globalcorpora.com\/blog\/build-youtube-video-dataset-for-multimodal-training\/","title":{"rendered":"How to Build a YouTube Video Dataset for Multimodal Training"},"content":{"rendered":"<p>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.<\/p>\n<h2>TL;DR<\/h2>\n<ul>\n<li><strong>Metadata first.<\/strong> Enumerate IDs with <code>yt-dlp --flat-playlist<\/code>, fetch full metadata with <code>--skip-download<\/code>, and filter on duration, license, language and resolution before you spend any bandwidth on video bytes.<\/li>\n<li><strong>The YouTube Data API is for enrichment, not discovery.<\/strong> The default allocation is 100 <code>search.list<\/code> calls per day plus 10,000 units for other endpoints. <code>videos.list<\/code> costs 1 unit.<\/li>\n<li><strong>Make the download stage idempotent.<\/strong> Use <code>--download-archive<\/code>, a job queue keyed by video ID, and one proxy session per worker.<\/li>\n<li><strong>Processing follows published recipes:<\/strong> 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.<\/li>\n<li><strong>Store samples as WebDataset tar shards<\/strong>, 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.<\/li>\n<\/ul>\n<h2>The pipeline at a glance<\/h2>\n<pre><code>  seeds (keywords, channels, playlists, existing ID lists)\n     |\n     v\n[1] DISCOVER   yt-dlp --flat-playlist -j  \/  Data API videos.list\n     |            -&gt; candidate IDs (cheap, no media)\n     v\n[2] FILTER     full metadata (--skip-download): duration, license,\n     |            language, views, date, category, max height\n     |            -&gt; selected IDs + metadata.jsonl\n     v\n[3] DOWNLOAD   job queue -&gt; workers (yt-dlp, 1 proxy session each)\n     |            video | audio-only | subs | thumbnail | info.json\n     v\n[4] PROCESS    shot detection -&gt; clips -&gt; dedup -&gt; quality filters\n     |            -&gt; captions \/ ASR \/ language ID\n     v\n[5] STORE      WebDataset tar shards + Parquet metadata + sha256 manifest\n     |            -&gt; object storage (S3 \/ GCS \/ Azure Blob)\n     v\n[6] GOVERN     lineage per sample, takedown list, re-release builds<\/code><\/pre>\n<p>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.<\/p>\n<h2>Stage 1: Scope and discovery<\/h2>\n<p>Start from what the model needs: target hours, modalities (video+audio, audio only, video+captions), languages, domains and minimum resolution. Then build seed lists:<\/p>\n<ul>\n<li><strong>Channels and playlists<\/strong> are the highest-yield seeds. Topically coherent channels produce consistent data.<\/li>\n<li><strong>Keyword lists<\/strong> produce search result pages. Each query yields a limited number of results, so you need many queries.<\/li>\n<li><strong>Existing ID lists<\/strong> from open datasets. Panda-70M, HD-VILA-100M and InternVid all ship YouTube IDs, covered in <a href=\"https:\/\/globalcorpora.com\/blog\/download-youtube-video-datasets-panda-70m-hd-vila-internvid\/\">our guide to downloading Panda-70M, HD-VILA-100M and InternVid<\/a>.<\/li>\n<\/ul>\n<p>Enumerate without resolving each video. In <a href=\"https:\/\/github.com\/yt-dlp\/yt-dlp\">yt-dlp<\/a>, <code>--flat-playlist<\/code> skips extraction of a playlist's entries, and <code>-j<\/code>\/<code>--dump-json<\/code> prints one JSON object per entry without downloading:<\/p>\n<pre><code>yt-dlp --flat-playlist -j \"https:\/\/www.youtube.com\/@CHANNEL\/videos\" &gt; seeds\/channel.jsonl\nyt-dlp --flat-playlist -j \"ytsearch200:woodworking tutorial\" &gt; seeds\/q_woodworking.jsonl<\/code><\/pre>\n<p>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.<\/p>\n<h3>YouTube Data API v3 as an alternative<\/h3>\n<p>Google's <a href=\"https:\/\/developers.google.com\/youtube\/v3\/determine_quota_cost\">quota documentation<\/a> currently says projects get a default allocation of <strong>100 <code>search.list<\/code> calls, 100 <code>videos.insert<\/code> calls, and 10,000 units per day<\/strong> for all other endpoints combined. Quotas reset at midnight Pacific Time. <a href=\"https:\/\/developers.google.com\/youtube\/v3\/docs\/videos\/list\"><code>videos.list<\/code><\/a> costs 1 unit and accepts a comma-separated list of IDs. The <a href=\"https:\/\/developers.google.com\/youtube\/v3\/docs\/videos\">video resource<\/a> exposes <code>status.license<\/code> (<code>creativeCommon<\/code> vs. <code>youtube<\/code>) and <code>snippet.defaultAudioLanguage<\/code>. 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.<\/p>\n<h2>Stage 2: Filter on metadata before downloading<\/h2>\n<p>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:<\/p>\n<table>\n<thead>\n<tr>\n<th>Filter<\/th>\n<th>yt-dlp field<\/th>\n<th>Why<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Duration<\/td>\n<td><code>duration<\/code><\/td>\n<td>Drop shorts and multi-hour streams that blow up processing.<\/td>\n<\/tr>\n<tr>\n<td>Live status<\/td>\n<td><code>live_status<\/code>, <code>is_live<\/code><\/td>\n<td>Skip ongoing and upcoming streams.<\/td>\n<\/tr>\n<tr>\n<td>License<\/td>\n<td><code>license<\/code><\/td>\n<td>Creative Commons-only subsets. YouTube CC videos report <code>\"Creative Commons Attribution license (reuse allowed)\"<\/code>.<\/td>\n<\/tr>\n<tr>\n<td>Language<\/td>\n<td><code>subtitles<\/code>, <code>automatic_captions<\/code>, per-format <code>language<\/code><\/td>\n<td>Cheap pre-filter. Confirm later with audio language ID.<\/td>\n<\/tr>\n<tr>\n<td>Popularity \/ date<\/td>\n<td><code>view_count<\/code>, <code>upload_date<\/code><\/td>\n<td>Proxy for production quality; time-sliced splits.<\/td>\n<\/tr>\n<tr>\n<td>Category<\/td>\n<td><code>categories<\/code>, <code>tags<\/code><\/td>\n<td>Domain balance (e.g. cap gaming or music).<\/td>\n<\/tr>\n<tr>\n<td>Resolution<\/td>\n<td><code>formats[].height<\/code><\/td>\n<td>Skip videos whose best format is below your minimum.<\/td>\n<\/tr>\n<tr>\n<td>Availability<\/td>\n<td><code>availability<\/code>, <code>age_limit<\/code><\/td>\n<td>Skip anything that needs sign-in.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>The CLI route uses <code>--skip-download<\/code> with <code>--write-info-json<\/code> and <code>--match-filters<\/code>:<\/p>\n<pre><code>yt-dlp -a candidates.txt --skip-download --write-info-json \\\n  --match-filters \"duration&gt;=30 &amp; duration&lt;=1800 &amp; !is_live &amp; license~='Creative Commons'\" \\\n  -o \"meta\/%(id)s.%(ext)s\"<\/code><\/pre>\n<p>For a pipeline, the Python API is easier to control. The script below reads IDs, fetches metadata with <code>extract_info(download=False)<\/code>, 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.<\/p>\n<pre><code>import json\nimport os\nimport sys\nfrom datetime import datetime, timezone\n\nimport yt_dlp\nfrom yt_dlp.utils import DownloadError\n\nKEEP = [\"id\", \"title\", \"channel\", \"channel_id\", \"duration\", \"upload_date\",\n        \"license\", \"categories\", \"tags\", \"view_count\", \"availability\",\n        \"live_status\", \"age_limit\"]\n\n\ndef done_ids(path):\n    \"\"\"IDs that already have a successful record (errors get retried).\"\"\"\n    done = set()\n    if os.path.exists(path):\n        with open(path, encoding=\"utf-8\") as f:\n            for line in f:\n                try:\n                    done.add(json.loads(line)[\"id\"])\n                except (ValueError, KeyError):\n                    continue  # tolerate a truncated last line\n    return done\n\n\ndef to_record(info):\n    rec = {k: info.get(k) for k in KEEP}\n    formats = info.get(\"formats\") or []\n    rec[\"max_height\"] = max((f.get(\"height\") or 0 for f in formats), default=0)\n    rec[\"audio_langs\"] = sorted({f[\"language\"] for f in formats\n                                 if f.get(\"acodec\") not in (None, \"none\")\n                                 and f.get(\"language\")})\n    rec[\"subtitle_langs\"] = sorted(info.get(\"subtitles\") or {})\n    rec[\"has_auto_captions\"] = bool(info.get(\"automatic_captions\"))\n    return rec\n\n\ndef now():\n    return datetime.now(timezone.utc).isoformat(timespec=\"seconds\")\n\n\ndef main(ids_path, out_path, proxy=None):\n    with open(ids_path, encoding=\"utf-8\") as f:\n        ids = list(dict.fromkeys(line.strip() for line in f if line.strip()))\n    done = done_ids(out_path)\n    todo = [i for i in ids if i not in done]\n\n    opts = {\"quiet\": True, \"no_warnings\": True, \"skip_download\": True,\n            \"socket_timeout\": 30, \"extractor_retries\": 3}\n    if proxy:\n        opts[\"proxy\"] = proxy  # e.g. http:\/\/USER:PASS@proxy.example.com:8000\n\n    with yt_dlp.YoutubeDL(opts) as ydl, \\\n            open(out_path, \"a\", encoding=\"utf-8\") as out, \\\n            open(out_path + \".errors\", \"a\", encoding=\"utf-8\") as err:\n        for vid in todo:\n            url = f\"https:\/\/www.youtube.com\/watch?v={vid}\"\n            try:\n                info = ydl.sanitize_info(ydl.extract_info(url, download=False))\n            except DownloadError as e:\n                err.write(json.dumps({\"id\": vid, \"error\": str(e),\n                                      \"fetched_at\": now()}) + \"\\n\")\n                continue\n            rec = to_record(info)\n            rec[\"fetched_at\"] = now()\n            out.write(json.dumps(rec, ensure_ascii=False) + \"\\n\")\n            out.flush()\n\n\nif __name__ == \"__main__\":\n    if len(sys.argv) not in (3, 4):\n        sys.exit(\"usage: fetch_meta.py ids.txt metadata.jsonl [proxy_url]\")\n    main(*sys.argv[1:])<\/code><\/pre>\n<p>Run it as <code>python fetch_meta.py ids.txt metadata.jsonl<\/code>. Then filter <code>metadata.jsonl<\/code> with pandas or DuckDB to produce <code>selected.txt<\/code>. <code>sanitize_info<\/code> is there because the yt-dlp README says <code>extract_info<\/code>'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 <code>yt-dlp-ejs<\/code> for full YouTube support. If metadata requests start failing with bot checks, see <a href=\"https:\/\/globalcorpora.com\/blog\/yt-dlp-sign-in-to-confirm-not-a-bot\/\">fixing \"Sign in to confirm you're not a bot\"<\/a>.<\/p>\n<h2>Stage 3: Download<\/h2>\n<p>One yt-dlp invocation per job, configured for the modality you need:<\/p>\n<pre><code># video + subtitles + thumbnail + metadata, capped at 720p\nyt-dlp -a selected.txt \\\n  -S \"res:720,fps\" --merge-output-format mp4 \\\n  --write-subs --write-auto-subs --sub-langs \"en.*,ja\" --sub-format \"vtt\/best\" \\\n  --write-thumbnail --write-info-json \\\n  --download-archive archive.txt \\\n  -R 10 --fragment-retries 10 --retry-sleep exp=1:60 \\\n  --sleep-requests 1 --sleep-subtitles 2 \\\n  --proxy \"http:\/\/USER:PASS@proxy.example.com:8000\" \\\n  -o \"raw\/%(id)s\/%(id)s.%(ext)s\"\n\n# speech corpus: best audio-only stream, no video bytes at all\nyt-dlp -a selected.txt -f \"ba\" --download-archive archive_audio.txt \\\n  -o \"raw_audio\/%(id)s.%(ext)s\"<\/code><\/pre>\n<ul>\n<li><strong>Format selection.<\/strong> <code>-f<\/code> chooses formats. <code>-S<\/code> sorts them, and <code>res:720<\/code> prefers the largest resolution not above 720p. Pin a resolution ceiling: the bytes difference between 720p and 4K dominates your bill.<\/li>\n<li><strong>Audio-only.<\/strong> <code>-f ba<\/code> keeps the native stream (usually Opus or AAC). Add <code>-x --audio-format flac<\/code> only if your ASR stage needs it. Decoding to 16 kHz mono at training time is usually cheaper than storing WAV.<\/li>\n<li><strong>Subtitles.<\/strong> <code>--write-subs<\/code> fetches uploader captions and <code>--write-auto-subs<\/code> fetches YouTube's ASR. Keep them separate in metadata: they differ in quality, and auto-captions are a weak label.<\/li>\n<li><strong>Idempotency.<\/strong> <code>--download-archive<\/code> records completed IDs, so re-runs skip them. At scale, put IDs in a queue (Redis, SQS, a Postgres table with <code>SELECT ... FOR UPDATE SKIP LOCKED<\/code>), lease jobs with a timeout, and mark a job done only after the upload to object storage and its checksum are confirmed.<\/li>\n<li><strong>Proxies.<\/strong> 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 <a href=\"https:\/\/globalcorpora.com\/blog\/yt-dlp-proxy-guide\/\">the yt-dlp proxy guide<\/a>.<\/li>\n<\/ul>\n<h2>Stage 4: Processing<\/h2>\n<h3>Shot detection and clip splitting<\/h3>\n<p>Most video-text training uses clips of a few seconds to a minute, not whole videos. <a href=\"https:\/\/github.com\/Breakthrough\/PySceneDetect\">PySceneDetect<\/a> provides <code>ContentDetector<\/code>, a two-pass <code>AdaptiveDetector<\/code> that handles fast camera movement better, and <code>ThresholdDetector<\/code> for fades. Splitting needs ffmpeg (or mkvmerge):<\/p>\n<pre><code>from scenedetect import detect, AdaptiveDetector, split_video_ffmpeg\n\nscenes = detect(\"raw\/abc123XYZ_-\/abc123XYZ_-.mp4\", AdaptiveDetector())\nkeep = [(s, e) for s, e in scenes if 2.0 &lt;= (e - s).get_seconds() &lt;= 60.0]\nsplit_video_ffmpeg(\"raw\/abc123XYZ_-\/abc123XYZ_-.mp4\", keep)<\/code><\/pre>\n<p>Raw shot boundaries over-split. <a href=\"https:\/\/arxiv.org\/abs\/2402.19479\">Panda-70M<\/a> runs shot boundary detection with PySceneDetect first. It then uses ImageBind frame embeddings to stitch adjacent clips that are semantically similar. Its <a href=\"https:\/\/github.com\/snap-research\/Panda-70M\">splitting code<\/a> 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.<\/p>\n<h3>Deduplication<\/h3>\n<ul>\n<li><strong>Exact:<\/strong> sha256 of the downloaded file. This only catches byte-identical copies.<\/li>\n<li><strong>Perceptual:<\/strong> re-uploads are re-encoded, so hash sampled frames (e.g. 1 fps) with a perceptual hash and compare sequences by Hamming distance.<\/li>\n<li><strong>Semantic near-dup:<\/strong> 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.<\/li>\n<li><strong>Against evaluation sets:<\/strong> drop any video ID that appears in benchmarks you report on, to avoid contamination.<\/li>\n<\/ul>\n<h3>Quality filters<\/h3>\n<p>The <a href=\"https:\/\/arxiv.org\/abs\/2311.15127\">Stable Video Diffusion<\/a> 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:<\/p>\n<ul>\n<li>Compute scores and store them as metadata columns rather than deleting clips. Thresholds change between training runs, and re-scoring is expensive.<\/li>\n<li>Calibrate each threshold by viewing a few hundred clips on both sides of it.<\/li>\n<li>Text-heavy filters (slides, lyric videos, screen recordings) matter for generation models. For document-understanding models you may want exactly those clips.<\/li>\n<\/ul>\n<h3>Captioning and recaptioning<\/h3>\n<p>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.<\/p>\n<h3>ASR and language ID<\/h3>\n<p><a href=\"https:\/\/github.com\/openai\/whisper\">Whisper<\/a> does multilingual transcription, translation and spoken language identification. The <code>turbo<\/code> model is an optimized <code>large-v3<\/code> with faster transcription and minimal accuracy loss. Per the README, it isn't trained for translation. For language ID, <code>model.detect_language(mel)<\/code> 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.<\/p>\n<h2>Stage 5: Storage layout and formats<\/h2>\n<p><a href=\"https:\/\/github.com\/webdataset\/webdataset\">WebDataset<\/a> stores samples as plain tar files. Files belonging to one sample share a basename, and shards are numbered like <code>name-000000.tar<\/code>. That gives sequential reads from object storage and trivial parallelism.<\/p>\n<pre><code>s3:\/\/bucket\/ytds\/v2026-11\/\n  shards\/clips-000000.tar        # abc123XYZ_-_000012.mp4 \/ .json \/ .txt \/ .opus\n  shards\/clips-000001.tar\n  metadata\/clips.parquet         # one row per clip: scores, captions, lineage\n  metadata\/videos.parquet        # one row per source video\n  manifest.jsonl                 # one line per object: path, bytes, sha256\n  takedowns.jsonl                # removed video_ids, with date and reason<\/code><\/pre>\n<ul>\n<li><strong>Keep raw downloads<\/strong> in a separate prefix from training shards, so you can re-cut or re-filter without re-downloading.<\/li>\n<li><strong>Parquet<\/strong> for analytical metadata (filtering, stats, joins). <strong>JSONL<\/strong> for append-only logs and human-readable manifests.<\/li>\n<li><strong>Shard size<\/strong>: pick a size that streams efficiently from your object store. Don't write millions of small per-clip objects.<\/li>\n<li><strong>Version releases<\/strong> (<code>v2026-11<\/code>) and never mutate a published shard. Takedowns produce a new release.<\/li>\n<\/ul>\n<h2>Stage 6: Data governance and lineage<\/h2>\n<p>Every sample should answer: where did this come from, when, under what license, and has it been withdrawn? A clip-level record (illustrative values):<\/p>\n<pre><code>{\n  \"sample_id\": \"abc123XYZ_-_000012\",\n  \"video_id\": \"abc123XYZ_-\",\n  \"source_url\": \"https:\/\/www.youtube.com\/watch?v=abc123XYZ_-\",\n  \"channel_id\": \"UCxxxxxxxxxxxxxxxxxxxxxx\",\n  \"license\": \"Creative Commons Attribution license (reuse allowed)\",\n  \"upload_date\": \"20240611\",\n  \"collected_at\": \"2026-10-28T14:03:11Z\",\n  \"collector_version\": \"yt-dlp 2026.10.xx \/ pipeline 1.4.0\",\n  \"clip\": {\"start\": 83.417, \"end\": 91.758, \"height\": 720, \"fps\": 30},\n  \"audio\": {\"lang_meta\": \"en\", \"lang_whisper\": \"en\", \"lang_prob\": 0.97},\n  \"captions\": [\n    {\"source\": \"vlm\", \"model\": \"MODEL_NAME\", \"prompt_version\": \"v3\", \"text\": \"...\"},\n    {\"source\": \"asr\", \"model\": \"whisper-turbo\", \"text\": \"...\"}\n  ],\n  \"scores\": {\"motion\": 3.1, \"ocr_text_area\": 0.02, \"aesthetic\": 5.4},\n  \"dedup_cluster\": 1882301,\n  \"shard\": \"clips-000417.tar\",\n  \"sha256\": \"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\"\n}<\/code><\/pre>\n<p>Takedown handling is a join: keep <code>takedowns.jsonl<\/code> keyed by <code>video_id<\/code> or <code>channel_id<\/code>, 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.<\/p>\n<h2>Stage 7: Cost and throughput planning<\/h2>\n<p>Size everything from hours and bitrate:<\/p>\n<pre><code>bytes      = hours x 3600 x avg_bitrate_bps \/ 8\nthroughput = bytes x 8 \/ collection_window_seconds\n\nexample (assumed 2 Mbps average at a 720p cap):\n  1,000,000 h x 3600 x 2e6 \/ 8   = 9.0e14 B  = 900 TB\n  9.0e14 x 8 \/ (30 x 86400 s)    = ~2.78 Gbps sustained for 30 days<\/code><\/pre>\n<p>Measure your own average bitrate from <code>filesize<\/code>\/<code>filesize_approx<\/code> and <code>duration<\/code> 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 <a href=\"https:\/\/globalcorpora.com\/blog\/bandwidth-for-large-scale-video-downloads\/\">bandwidth planning for large-scale video downloads<\/a>.<\/p>\n<h2>Compliance<\/h2>\n<p>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.<\/p>\n<h2>If you'd rather not run this yourself<\/h2>\n<p>Corpora offers <a href=\"https:\/\/globalcorpora.com\/#pricing\">dedicated 1\u201340 Gbps unmetered proxy capacity<\/a> 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. <a href=\"https:\/\/globalcorpora.com\/#contact\">Get in touch<\/a> to scope it.<\/p>\n<h2>FAQ<\/h2>\n<h3>How do I get only metadata from YouTube with yt-dlp?<\/h3>\n<p>Use <code>-j<\/code>\/<code>--dump-json<\/code> to print JSON without downloading, or <code>--skip-download --write-info-json<\/code> to write <code>.info.json<\/code> files. Add <code>--flat-playlist<\/code> when you only need the entry list of a channel or playlist.<\/p>\n<h3>How many API calls does the YouTube Data API allow per day?<\/h3>\n<p>Google's quota page currently states a default of 100 <code>search.list<\/code> calls, 100 <code>videos.insert<\/code> 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.<\/p>\n<h3>How do I filter for Creative Commons videos?<\/h3>\n<p>In yt-dlp, filter on the <code>license<\/code> field (e.g. <code>--match-filters \"license~='Creative Commons'\"<\/code>). In the Data API, read <code>status.license<\/code>, which is <code>creativeCommon<\/code> for CC videos.<\/p>\n<h3>Should I store whole videos or pre-cut clips?<\/h3>\n<p>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.<\/p>\n<h3>Which ASR model should I use for a YouTube speech dataset?<\/h3>\n<p>Whisper is the common default. <code>turbo<\/code> 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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Build a YouTube video dataset for multimodal training: metadata-first discovery, filtering, yt-dlp downloads, scene splitting, dedup, ASR, shards and lineage.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[25,23,24,26,22,7],"class_list":["post-18","post","type-post","status-publish","format-standard","hentry","category-datasets","tag-data-pipeline","tag-multimodal-training-data","tag-video-language-models","tag-webdataset","tag-youtube-video-dataset","tag-yt-dlp"],"_links":{"self":[{"href":"https:\/\/globalcorpora.com\/blog\/wp-json\/wp\/v2\/posts\/18","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/globalcorpora.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/globalcorpora.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/globalcorpora.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/globalcorpora.com\/blog\/wp-json\/wp\/v2\/comments?post=18"}],"version-history":[{"count":0,"href":"https:\/\/globalcorpora.com\/blog\/wp-json\/wp\/v2\/posts\/18\/revisions"}],"wp:attachment":[{"href":"https:\/\/globalcorpora.com\/blog\/wp-json\/wp\/v2\/media?parent=18"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/globalcorpora.com\/blog\/wp-json\/wp\/v2\/categories?post=18"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/globalcorpora.com\/blog\/wp-json\/wp\/v2\/tags?post=18"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}