Technically Impossible

Lets look at the weak link in your statement. Anything "Technically Impossible" basically means we haven't figured out how yet.

記事「How They Bypass YouTube Video Download Throttling」の翻訳 - ブログ『0x7D0』より

オンライン広告のブロック対策について、何かと話題に上るのがYouTubeだ。挿入される広告を事実上「見たことにする」ブロッカーが登場したり*1、特定のブラウザでの動画読み込みが遅くなるよう対策されたり*2、もはや「上有政策、下有対策」の世界だ。

諸々の対策はともかくとして、ブラウザでYouTubeにアクセスし、動画がダウンロードされる仕組みを想像したことがあるだろうか?動画を指し示すURLへのリクエストに応じて、単純に動画のストリーミングが開始されるような、単純な仕組みではないのだ。

blog.0x7d0.dev

ブログ『0x7D0』の投稿が、この仕組みを説明してくれる。英文は簡潔明瞭で、とても分かりやすい。ソースコード交じりではあるものの、ただ英文を追うだけでも、動画ダウンロードが始まるまでの段取りを理解できるだろう。

この投稿は、件のブログに掲載された記事『How They Bypass YouTube Video Download Throttling』の翻訳だ。この記事は、2023年8月14日に投稿されており、その内容がYouTubeの現状に即しているかは分からない。
とはいえ、YouTubeの内部では、URLのリクエストに応じて動画再生を開始する以上に、思いもよらない複雑な仕掛けが施されていることが理解できるはずだ。

翻訳 - How They Bypass YouTube Video Download Throttling

YouTube動画をダウンロードしようとしたことがあるだろうか?youtube-dl、yt-dlpのようなソフトウェア、同様に機能するウェブサイトを使わずにだ。これが想像以上にややこしい。

YouTubeはユーザーの広告視聴から収益を得ている。そのため動画のダウンロードや、YouTube Vancedのような非公式クライアントによる視聴を制限するのは理にかなっている。そのような防御策の技術的詳細、そして、それをどのようにかいくぐることができるのかを説明しようと思う。

URLの抽出

まずYouTube APIを用いて、動画ファイルの実態を指し示すURLを見つける必要がある。タイトル、説明、サムネイル、そして最も重要なフォーマットなど、動画ファイルの詳細は、エンドポイント「/youtubei/v1/player」に記されている。SD、HD、UHDなどの画質に応じたファイルのURLも、ここで見つけることができる。

例として「aqz-KE-bpKQ」というIDを持つ動画について、ある画質のファイルを示すURLを探してみることにする。「context」オブジェクトに含まれる諸々の値は、APIに対して有効であることを申し添えておく。ウェブブラウザが送信するリクエストを観察し、認証されることを確認している。

🔎echo

echo -n '{"videoId":"aqz-KE-bpKQ","context":{"client":{"clientName":"WEB","clientVersion":"2.20230810.05.00"}}}' | 
  http post 'https://www.youtube.com/youtubei/v1/player' |
  jq -r '.streamingData.adaptiveFormats[0].url'

https://rr1---sn-8qu-t0aee.googlevideo.com/videoplayback?expire=1691828966&ei=hu7WZOCJHI7T8wTSrr_QBg [TRUNCATED]

このURLからダウンロードすると、非常に遅い。

🔎http --download

http --print=b --download 'https://rr1---sn-8qu-t0aee.googlevideo.com/videoplayback?expire=1691828966&ei=hu7WZOCJHI7T8wTSrr_QBg [TRUNCATED]'

Downloading to videoplayback.webm
[ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ]   0% ( 0.0/1.5 GB ) 6:23:45 66.7 kB/s

通信速度は40~70kB/sに保たれている。10分間の動画をダウンロードするのに、6時間半を要するのだ。ウェブブラウザでダウンロードするときの通信速度とは、明らかに違う。何が問題なのだろう?

以下は、URL全体を分解したものだ。かなり複雑だが、興味深いパラメータが含まれている。

🔎URL - yaml

Protocol: https
Hostname: rr1---sn-8qu-t0aee.googlevideo.com
Path name: /videoplayback
Query Parameters:
  expire: 1691829310
  ei: 3u_WZJT7Cbag_9EPn7mi0A8
  ip: 203.0.113.30
  id: o-ABGboQn9qMKsUdClvQHd6cHm6l1dWkRw4WNj3V7wBgY1
  itag: 315
  aitags: 133,134,135,136,160,242,243,244,247,278,298,299,302,303,308,315,394,395,396,397,398,399,400,401
  source: youtube
  requiressl: yes
  mh: aP
  mm: 31,29
  mn: sn-8qu-t0aee,sn-t0a7ln7d
  ms: au,rdu
  mv: m
  mvi: 1
  pcm2cms: yes
  pl: 18
  initcwndbps: 1422500
  spc: UWF9fzkQbIbHWdKe8-ahg0uWbE_UrbUM0U6LbQfFxg
  vprv: 1
  svpuc: 1
  mime: video/webm
  ns: dn5MLRkBtM4BWwzNNOhVxHIP
  gir: yes
  clen: 1536155487
  dur: 634.566
  lmt: 1662347928284893
  mt: 1691807356
  fvip: 3
  keepalive: yes
  fexp: 24007246,24363392
  c: WEB
  txp: 553C434
  n: mAq3ayrWqdeV_7wbIgP
  sparams: expire,ei,ip,id,aitags,source,requiressl,spc,vprv,svpuc,mime,ns,gir,clen,dur,lmt
  sig: AOq0QJ8wRgIhAOx29gNeoiOLRe1GhEfE52PAiXW64ZEWX7nNdAiJE6ezAiEA0Plw6Yn0kmSFFZHO2JZPZyMGd0O-gEblUXPRrexQgrY=
  lsparams: mh,mm,mn,ms,mv,mvi,pcm2cms,pl,initcwndbps
  lsig: AG3C_xAwRQIgZVOkDl4rGPGnlK6IGCAXpzxk-cB5RRFmXDesEqOWTRoCIQCzIdPKE6C6_JQVpH6OKMF3woIJ2yVYaztT9mXIVtE6xw==

2021年半ばから、YouTubeは、ファイルを指し示すURLの大半に、クエリーパラメータ「n」を組み込んでいる。このパラメータは、ウェブページ上で提供されるJavaScriptファイル「base.js」に記述されたアルゴリズムによって変換されなければならない。YouTubeは、”適切な”クライアントからのダウンロードであることを検証するために、このパラメータを用いている。「n」が正しく変換されず、検証に失敗した場合、YouTubeは警告なしに、動画ダウンロードに制約を課す。

JavaScriptアルゴリズムは難読化されており、頻繁に更新される。調べようとしてリバースエンジニアリングするのは現実的ではない。JavaScriptファイルをダウンロードして、アルゴリズムを抽出し、パラメータ「n」を引数として実行するしかない。これを実行するのが次のコードだ。

🔎JavaScript - 動画のメタデータを取得する

import axios from 'axios';
import vm from 'vm'

const videoId = 'aqz-KE-bpKQ';

/**
 * From the Youtube API, retrieve metadata about the video (title, video format and audio format)
 */
async function retrieveMetadata(videoId) {
    const response = await axios.post('https://www.youtube.com/youtubei/v1/player', {
        "videoId": videoId,
        "context": {
            "client": { "clientName": "WEB", "clientVersion": "2.20230810.05.00" }
        }
    });

    const formats = response.data.streamingData.adaptiveFormats;

    return [
        response.data.videoDetails.title,
        formats.filter(w => w.mimeType.startsWith("video/webm"))[0], 
        formats.filter(w => w.mimeType.startsWith("audio/webm"))[0],
    ];
}

/**
 * From the Youtube Web Page, retrieve the challenge algorithm for the n query parameter
 */
async function retrieveChallenge(video_id){

    /**
     * Find the URL of the javascript file for the current player version
     */
    async function retrieve_player_url(video_id) {
        let response = await axios.get('https://www.youtube.com/embed/' + video_id);
        let player_hash = /\/s\/player\/(\w+)\/player_ias.vflset\/\w+\/base.js/.exec(response.data)[1]
        return `https://www.youtube.com/s/player/${player_hash}/player_ias.vflset/en_US/base.js`
    }

    const player_url = await retrieve_player_url(video_id);

    const response = await axios.get(player_url);
    let challenge_name = /\.get\("n"\)\)&&\(b=([a-zA-Z0-9$]+)(?:\[(\d+)\])?\([a-zA-Z0-9]\)/.exec(response.data)[1];
    challenge_name = new RegExp(`var ${challenge_name}\\s*=\\s*\\[(.+?)\\]\\s*[,;]`).exec(response.data)[1];

    const challenge = new RegExp(`${challenge_name}\\s*=\\s*function\\s*\\(([\\w$]+)\\)\\s*{(.+?}\\s*return\\ [\\w$]+.join\\(""\\))};`, "s").exec(response.data)[2];

    return challenge;
}

/**
 * Solve the challenge and replace the n query parameter from the url
 */
function solveChallenge(challenge, formatUrl) {
    const url = new URL(formatUrl);

    const n = url.searchParams.get("n");
    const n_transformed = vm.runInNewContext(`((a) => {${challenge}})('${n}')`);

    url.searchParams.set("n", n_transformed);
    return url.toString();
}


const [title, video, audio] = await retrieveMetadata(videoId);
const challenge = await retrieveChallenge(videoId);

video.url = solveChallenge(challenge, video.url);
audio.url = solveChallenge(challenge, audio.url);

console.log(video.url);


動画ファイルのダウンロード

次に、適切に変換されたパラメータ「n」を含むURLを用いて、動画をダウンロードする。しかし、YouTubeはまだ制約を課す。動画の時間とサイズに応じて、ダウンロード速度を制限する。動画の半分の時間でダウンロードさせようとするのだ。これは動画ストリーミングの特性に適っている。動画ファイルを可能な限り早く提供するというのは、YouTubeにとっては、帯域幅の無駄遣いなのだ。

🔎http --download

http --print=b --download 'https://rr1---sn-8qu-t0aee.googlevideo.com/videoplayback?expire=1691888702&ei=3tfXZIXSI72c_9EP1NGHqA8 [TRUNCATED]'

Downloading to videoplayback.webm
[ ━╸━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ]   4% ( 0.1/1.5 GB ) 0:06:07 4.0 MB/s

この制約を回避するため、HTTP Rangeヘッダーを用いて、ダウンロードを小分けに分割する。リクエストごとに、ファイルのダウンロードしたい部分を指定することができる(例:Range bytes=2000-3000)。このロジックを実装したのが次のコードだ。

🔎JavaScript - 10MBずつダウンロードする

/**
 * Download a media file by breaking it into several 10MB segments
 */
async function download(url, length, file){
    const MEGABYTE = 1024 * 1024;

    await fs.promises.rm(file, { force: true });

    let downloadedBytes = 0;

    while (downloadedBytes < length) {
        let nextSegment = downloadedBytes +  10 * MEGABYTE;
        if (nextSegment > length) nextSegment = length;

        // Download segment
        const start = Date.now();
        let response = await axios.get(url, { headers: { "Range": `bytes=${downloadedBytes}-${nextSegment}` }, responseType: 'stream' });
        
        // Write segment
        await fs.promises.writeFile(file, response.data, {flag: 'a'});
        const end = Date.now();
        
        // Print download stats
        const progress = (nextSegment / length * 100).toFixed(2);
        const total = (length / MEGABYTE).toFixed(2);
        const speed = ((nextSegment - downloadedBytes) / (end - start) * 1000 / MEGABYTE).toFixed(2);
        console.log(`${progress}% of ${total}MB at ${speed}MB/s`);
        
        downloadedBytes = nextSegment + 1;
    }
}

これは機能する。制約を課すには時間がかかる、一方、常に新しいコネクションを張る、細分化されたダウンロードは高速だからだ。

node index.js

node index.js

0.68% of 1464.99MB at 46.73MB/s
1.37% of 1464.99MB at 60.98MB/s
2.05% of 1464.99MB at 71.94MB/s
2.73% of 1464.99MB at 70.42MB/s
3.41% of 1464.99MB at 68.49MB/s
4.10% of 1464.99MB at 68.97MB/s
4.78% of 1464.99MB at 74.07MB/s
5.46% of 1464.99MB at 81.97MB/s
6.14% of 1464.99MB at 104.17MB/s

極めて高速に動画をダウンロードできるようになった。テスト中、特定のダウンロードでは1Gb/s接続を目一杯に活用していた。平均速度は50-70 MB/s、あるいは400-560 Mb/sといったところだが、それでも十分高速だ。

事後処理

YouTubeは映像と音声を異なるファイルで提供する。このアプローチはスペースの節約に有効だ。HDやUHDのように解像度が異なっても、音声ファイルを共用できるからだ。付け加えれば、言語設定に応じて異なる音声を再生する動画もある。そのような事情から、最終的に映像と音声を一つのファイルに統合することになる。端的には、ffmpegを使えばよい。

🔎JavaScript - 映像と音声を統合する

/**
 * Using ffmpeg, combien the audio and video file into one
 */
async function combineChannels(destinationFile, videoFile, audioFile)
{
    await fs.promises.rm(destinationFile, { force: true });
    child_process.spawnSync('ffmpeg', [
        "-y",
        "-i", videoFile,
        "-i", audioFile,
        "-c", "copy",
        "-map", "0:v:0",
        "-map", "1:a:0",
        destinationFile
    ]);

    await fs.promises.rm(videoFile, { force: true });
    await fs.promises.rm(audioFile, { force: true });
}


最後に、関心があれば、全コードをこちらからダウンロードできる。
https://blog.0x7d0.dev/assets/download/how-they-bypass-youtube-video-download-throttling/youtube-download.js

おわりに

YouTubeが仕掛けた、動画をダウンロードさせないための制約をかいくぐるため、多くのプロジェクトが、このようなテクニックを用いている。もっともよく用いられているのが、Pythonで実装されたyt-dlpyoutube-dlの派生)だ。パラメータ「n」を変換する独自インタプリタを備えている。

yt-dlp
https://github.com/ytdl-org/youtube-dl/blob/master/youtube_dl/extractor/youtube.py
VLC media player
https://github.com/videolan/vlc/blob/master/share/lua/playlist/youtube.lua
NewPipe
https://github.com/Theta-Dev/NewPipeExtractor/blob/dev/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeJavaScriptExtractor.java
node-ytdl-core
https://github.com/fent/node-ytdl-core/blob/master/lib/sig.js