YouTube 動画をダウンロードしたい — yt-dlp と ffmpeg で「実用になる」ところまで

FFmpeg
B!

「YouTube の動画をローカルに保存したい」という要件は、調べると答えがすぐ出てくる。
yt-dlp を入れて URL を渡す。それで終わりのはずだった。

しかし実際に 9 本の動画(合計約 3 時間半、6.5GB)を保存し、
さらにチャプターごとに 22 パートへ分割するところまでやってみると、
「落ちてはくるが実用にならない」状態を何度も経由した。

  • 落としたファイルが QuickTime で再生できない
  • 単体のつもりで貼った URL が、実はプレイリスト全体を指している
  • 取り直したいのに何も起きない

この記事では、最短手順から始めて、実運用に必要な設定を 1 つずつ足していく。

前提として 対象にできるのは、自分がアップロードした動画、CC ライセンスの動画、
権利者から許諾を得た動画に限られる。YouTube の利用規約は公式機能以外でのダウンロードを禁じており、
日本の著作権法上も、違法にアップロードされたと知りながらのダウンロードは私的利用でも違法になる。
今回扱ったのは権利関係を確認済みの動画のみ。

1. 最短手順

brew install yt-dlp ffmpeg
yt-dlp "https://www.youtube.com/watch?v=XXXXXXXX"

これで動画は落ちてくる。環境は以下。

  • yt-dlp 2026.08.19
  • ffmpeg 7.1.1
  • macOS (Apple Silicon)

ffmpeg は必須である。YouTube は高画質だと映像と音声を別ストリームで配信する(DASH)ため、
結合に ffmpeg が要る。入れずに実行すると、勝手に低画質の結合済みファイルが選ばれる。

ここから、実用に必要なものを足していく。

2. 落としたファイルが再生できない — 最初の関門

最初のダウンロードは成功した。1080p、34 分、1.0GB の mp4。しかし QuickTime Player で開けない。

ffprobe で中身を見るとこうなっていた。

$ ffprobe -v error -show_entries stream=codec_type,codec_name \
    -of default=noprint_wrappers=1 video.mp4
codec_name=vp9
codec_type=video
codec_name=opus
codec_type=audio

VP9 + Opus である。ここが最初の関門で、要点は次の一行に尽きる。

コンテナ(mp4 / webm / mkv)と、中身のコーデック(H.264 / VP9 / AV1、AAC / Opus)は独立している。

--merge-output-format mp4 は「入れ物を mp4 にする」だけの指定で、再エンコードはしない。
ffmpeg は VP9 や Opus を mp4 コンテナに詰めることができてしまう。
ファイルとしては正しいが、QuickTime(VideoToolbox 経由の再生系)はこの組み合わせをデコードできない。

VLC や IINA なら再生できるので「壊れている」わけではない。純粋に互換性の問題である。

対処:フォーマット選択でコーデックを指定する

yt-dlp のフォーマットセレクタは、コーデックでの絞り込みができる。

FMT="bv*[vcodec^=avc1][height<=1080]+ba[acodec^=mp4a]"   # H.264 + AAC を最優先
FMT="$FMT/b[vcodec^=avc1][height<=1080][acodec^=mp4a]"   # 結合済みで条件を満たすもの
FMT="$FMT/bv*[vcodec^=avc1][height<=1080]+ba"            # 映像だけでも H.264 を確保
FMT="$FMT/bv*[height<=1080]+ba/b[height<=1080]/bv*+ba/b" # 最終フォールバック

yt-dlp -f "$FMT" --merge-output-format mp4 "URL"

bv* = 映像のみ、ba = 音声のみ、b = 結合済み。/ 区切りは上から順に試すフォールバックで、
^= は前方一致。avc1 が H.264、mp4a が AAC を指す。

どのフォーマットが提供されているかは -F で確認できる。

$ yt-dlp -F "URL" | grep -E 'avc1|mp4a'
139 m4a  audio only   |  11.90MiB   49k | mp4a.40.5   49k
140 m4a  audio only   |  31.59MiB  129k | mp4a.40.2  129k
137 mp4  1920x1080 30 |   1.07GiB 4498k | avc1.640028 4498k video only

この指定で取り直すと Downloading 1 format(s): 137+140 となり、h264 + aac の mp4 が得られた。

どちらを選ぶか

YouTube の H.264 は基本的に 1080p までで、同じ画質なら VP9/AV1 より容量を食う(1.0GB → 1.1GB)。

優先するもの選ぶコーデック備考
汎用性(QuickTime、編集ソフト、古い機器)H.264 + AAC1080p まで
画質・容量効率、1440p 以上VP9 / AV1再生は IINA や VLC

用途によって両方要るので、既定を H.264 にしつつ、フラグ 1 つでコーデック不問に切り替えられるようにした。

3. URL の &list= が指しているのは 1 本ではない

作業の順序としては、まず動画 ID 単体で 1 本落として設定を詰め、
そのうえで残りをまとめて取得する、という流れで進めた。
このとき効いてくるのが URL の形である。

ブラウザからコピーした URL は、たいていこうなっている。

https://www.youtube.com/watch?v=XXXXXXXX&list=PLXXXXXXXX

yt-dlp はこれを渡されるとプレイリスト全体をダウンロードする。
つまり「まず 1 本だけ試したい」つもりで貼り付けた URL が、
そのまま数 GB の一括取得になりうる。URL を見た時点で気づけないと事故になる。

対処は、既定を --no-playlist にして、一括取得のときだけ明示的にフラグを立てることである。

yt-dlp --no-playlist "URL"     # 既定:URL に list= が付いていても 1 本だけ
ytdl -p "URL"                  # 明示したときだけプレイリスト全体

--no-playlist は「URL が動画とプレイリストの両方を指している場合に、動画だけを取る」という指定なので、
playlist?list=... のような純粋なプレイリスト URL には影響しない
つまり既定を --no-playlist にしても、一括取得の機能は失われない。実行ログにも意図が出る。

[youtube:tab] Downloading just the video XXXXXXXX because of --no-playlist

今回も、検証は単体 ID で行い、9 本の一括取得は -p を付けて 1 回の実行で済ませた。
どちらのつもりで実行したのかがコマンドに残るのが、この設計の利点である。

4. 大量に取るなら --download-archive

プレイリストから 9 本を一括取得するとき、必須になるのがこのオプションである。
取得済みの動画 ID がテキストファイルに追記される。

youtube VmpVoIB7Kwc
youtube rHu9zIlgrVc
...

再実行すると、記録済みのものはスキップされる。

[download] XXXXXXXX: ... has already been recorded in the archive
[download] Downloading item 2 of 9

これにより、

  • 途中で中断しても同じコマンドで続きから再開できる(大量取得では事実上必須)
  • 後から追加された動画だけを取りに行ける

ただし「取り直し」が効かなくなる

アーカイブは動画 ID 単位である。動画として取得した後に「音声だけ mp3 で欲しい」と実行しても、
同じ ID なのでスキップされ、何も起きない
コーデックを変えて取り直したいときも同様である。該当行を消す必要がある。

grep -v "動画ID" .downloaded.txt > tmp && mv tmp .downloaded.txt

今回、VP9 で落としたものを H.264 で取り直す際にこれを使った。
「実行しても何も起きない」ときは、まずアーカイブを疑うとよい。

5. ファイル名とメタデータを整える

大量に落とすと、ファイル名の設計が効いてくる。最終的にこうなった。

yt-dlp \
  --paths "$OUTDIR" \
  --output "%(upload_date>%Y%m%d,release_date>%Y%m%d|NA)s_%(title).80B_%(id)s.%(ext)s" \
  --restrict-filenames \
  --no-mtime \
  --embed-metadata --embed-thumbnail \
  --download-archive "$ARCHIVE" \
  --concurrent-fragments 4 \
  --retries 10 \
  "URL"

意図は以下のとおり。

指定理由
先頭に投稿日Finder で時系列に並ぶ。同じ日の複数試合もまとまる
末尾に動画 IDタイトルが同じでも衝突しない。アーカイブとの突き合わせにも使える
%(title).80Bタイトルを 80 バイトで切り詰め。ファイル名長の上限対策
--restrict-filenamesASCII 化。シェルスクリプトで扱う前提なら安全側に倒す
--no-mtime更新日時を投稿日でなく取得日にする
--concurrent-fragments 4DASH の断片を並列取得。実測で目に見えて速い

upload_date は動画によっては取れないため、release_date へのフォールバックと NA を指定している。
なお、ここで付けた --embed-thumbnail が後の分割工程で効いてくる(後述)。

6. スクリプトにまとめる際のつまずき

ここまでの指定をラッパースクリプトにまとめた。その過程で踏んだのが getopts の仕様である。

while getopts "q:o:c:aslh" opt; do
  case "$opt" in
    c) COOKIES="$OPTARG" ;;

ヘルプにはこう書いてあった。

./ytdl.sh -c "URL"    Chrome の Cookie を使う

これは動かない。c:引数必須なので、-c "URL" と書くと URL が Cookie 名として食われ、
肝心の URL が消える。正しくは -c chrome "URL" である。

さらにその下にデッドコードが残っていた。

# -c を値なしで使ったとき用のデフォルト
if [ "$COOKIES" = "-" ]; then COOKIES="chrome"; fi

getopts は引数が欠けたオプションを - として返さない(optstring が : で始まらない限り ? を返す)。
この行は一度も実行されない。「引数なしでも動くようにしたい」という意図の残骸で、
ヘルプの記述もその名残だったのだろう。

なお Cookie が必要になるのは、限定公開や年齢制限の動画、
そして Sign in to confirm you're not a bot が出たときである。

yt-dlp --cookies-from-browser chrome "URL"

ブラウザを完全に終了してから実行しないと、DB がロックされていて読めない。

7. チャプターごとに分割する

各動画にはクォーターごとのチャプター情報がある。開始秒に変換して ffmpeg で切り出す。

ffmpeg -nostdin -v error -y \
  -ss "$start" -i "$src" -t "$duration" \
  -map 0:v:0 -map 0:a:0 -c copy \
  -movflags +faststart -avoid_negative_ts make_zero \
  "$out"

ポイントは 3 つ。

-c copy で再エンコードしない。 パケットを右から左に流すだけなので、画質劣化がなく速い。
22 ファイルの切り出しが数分で終わった。再エンコードなら数時間かかる。

-ss-i の前に置く。 入力側のシークになり、頭から読み飛ばさず直接その位置に飛べる。
30 分の動画の後半を切り出すときに効く。

-map 0:v:0 -map 0:a:0 で必要なストリームだけ拾う。
前述の --embed-thumbnail を使っていると、mp4 の中にサムネイル(png)が
もう 1 本の映像ストリームとして入っている。-map を省くとこれも一緒にコピーされ、
プレイヤーによっては先頭の静止画を本編と誤認する。

指定した秒数ちょうどには切れない

-c copy は再エンコードしないため、任意の位置では切れない
映像は前のフレームとの差分で構成されており、単独でデコードできるのは
キーフレーム(I フレーム)だけだからである。

対象動画のキーフレーム位置を調べてみる。

$ ffprobe -v error -select_streams v:0 -skip_frame nokey \
    -show_entries frame=pts_time -of csv=p=0 -read_intervals '%+60' video.mp4
0.000000
6.000000
12.000000
18.000000

6 秒間隔。指定時刻の直前のキーフレームまで戻って切り出しが始まる。
実際、分割後のファイル長の合計は元動画より 0.4〜11.2 秒長くなった(1 カットあたり平均約 2 秒)。

重要なのはずれの方向である。開始位置は必ず手前に寄るため、
前のパートの末尾が数秒混ざるだけで、映像が欠落することはない。用途によっては実害がない。

秒単位でぴったり切りたいなら再エンコードするしかない。

ffmpeg -ss "$start" -i in.mp4 -t "$dur" -c:v libx264 -crf 20 -c:a aac out.mp4

精度と引き換えに、画質の劣化と数時間の処理時間を受け入れることになる。

検証:長さの合計で照合する

分割結果は目視で確認しづらい。長さで機械的に照合した。

for f in *.mp4; do
  d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")
  printf '%-60s %7.1fs\n' "$f" "$d"
done

各動画について「分割後の長さの合計 − 元動画の長さ」を見れば、
差分がキーフレーム由来の数秒に収まっているか、指定ミスで区間が抜けているかを判別できる。
今回はすべて +0.4〜11.2 秒の範囲に収まり、取りこぼしがないことを確認できた。

まとめ

「ダウンロードする」だけなら 1 行で済む。実用にするために必要だったのは以下である。

症状原因対処
mp4 が QuickTime で再生できないコンテナと中身のコーデックは別物。VP9 + Opus だった-favc1 + mp4a を優先指定
単体のつもりの URL でプレイリスト全体が落ちるwatch?v=...&list=... はプレイリスト扱い--no-playlist を既定にし、一括取得は明示フラグ
実行しても何も起きない--download-archive に ID が記録済み該当行を消してから再実行
-c "URL" が動かないgetopts の引数必須オプションとヘルプの不一致-c chrome "URL" と書く
分割位置が数秒ずれる-c copy はキーフレームでしか切れないずれは手前方向で欠落なし。精度が要るなら再エンコード

いずれも「ツールが悪い」のではなく、コンテナとコーデックの関係、URL が指す対象、
コーデックの構造といった前提を知らないと踏む類のものだった。

なお、この手のツールは YouTube 側の仕様変更で頻繁に壊れる。動かなくなったらまず更新する。

brew upgrade yt-dlp
B!
← 一覧へ戻る