TwitterAPIのアップロード系エンドポイントまとめ (140秒動画対応)

# 概要

Twitterが140秒までの動画ツイートに対応したと聞いたので，以前からあるエンドポイントも含めてアップロード系のものをまとめてみました．

サンプルには拙作の[TwistOAuth](https://github.com/mpyw/TwistOAuth)を用いたコードを掲載します．

# [POST statuses/update_with_media](https://dev.twitter.com/rest/reference/post/statuses/update_with_media)

単一の画像のアップロードに使えます．

```php
echo "Uploading and tweeting...\n";
$to->postMultipart('statuses/update_with_media', [
    'status' => 'hello',
    '@media[]' => 'photo.jpg',
]);

echo "Done!\n";
```

- 現在**非推奨**
- サーバとの通信が1回で済む
- multipart/form-dataが強制される
  - エンドポイント固有のパラメータは`oauth_signature`の生成対象に含まれない
- `media[]`というパラメータ名であるにも関わらず，単一の画像しか受け付けない

# [POST media/upload (コマンド無し)](https://dev.twitter.com/rest/reference/post/media/upload)

1枚以上の画像のアップロードに使えます．

```php
$media_ids = [];

echo "Uploading A.jpg...\n";
$media_ids[] = $to->postMultipart('media/upload', [
    '@media' => 'A.jpg',
])->media_id_string;

echo "Uploading B.jpg...\n";
$media_ids[] = $to->post('media/upload', [
    '@media' => 'B.jpg', // 自動的にデータがBase64エンコードされる
])->media_id_string;

echo "Tweeting...\n";
$to->post('statuses/update', [
    'status' => 'hello',
    'media_ids' => implode(',', $media_ids),
]);

echo "Done!\n";
```

- サーバとの通信が2回以上必要
- multipart/form-dataとapplication/x-www-form-urlencodedから選べる
  - multipart/form-dataの場合，エンドポイント固有のパラメータは`oauth_signature`の生成対象に含まれない
  - application/x-www-form-urlencodedの場合，メディアデータのみBase64エンコードする必要がある

オーバーヘッドの小さいmultipart/form-dataを推奨します．

# POST media/upload (コマンドあり・同期)

いろいろなデータのアップロードに使えます．

- 1枚以上の画像
- 1つのアニメGIF (0.5秒〜30秒)
- 1つの動画 (0.5秒〜30秒)

以下のコマンドを順番にコールする必要があります．

1. [INIT](https://dev.twitter.com/rest/reference/post/media/upload-init)
2. [APPEND](https://dev.twitter.com/rest/reference/post/media/upload-append)
3. [FINALIZE](https://dev.twitter.com/rest/reference/post/media/upload-finalize)

```php
$filename = 'small_video.mp4';

echo "Initializing...\n";
$info = $to->post('media/upload', [
    'command' => 'INIT',
    'media_type' => 'video/mp4',
    'total_bytes' => filesize($filename),
]);

echo "Uploading video...\n";
$to->postMultipart('media/upload', [
    'command' => 'APPEND',
    'media_id' => $info->media_id_string,
    'segment_index' => 0,
    '@media' => $filename,
]);

echo "Finalizing...\n";
$to->post('media/upload', [
    'command' => 'FINALIZE',
    'media_id' => $info->media_id_string,
]);

echo "Tweeting...\n";
$to->post('statuses/update', [
    'status' => 'hello',
    'media_ids' => $info->media_id_string,
]);

echo "Done!\n";
```

- サーバとの通信が4回以上必要
- multipart/form-dataとapplication/x-www-form-urlencodedから選べる
  - multipart/form-dataの場合，エンドポイント固有のパラメータは`oauth_signature`の生成対象に含まれない
  - application/x-www-form-urlencodedの場合，メディアデータのみBase64エンコードする必要がある

オーバーヘッドの小さいmultipart/form-dataを推奨します．

# GET|POST media/upload (コマンドあり・非同期)

いろいろなデータのアップロードに使えます．こちらは大きなデータをアップロードする前提なので，チャンク分割を入れます．**分割を行わないと，`413 Request Entity Too Large`が返されてしまう場合があります．**

- 1枚以上の画像
  - **`media_category=tweet_image`**
- 1つのアニメGIF (0.5秒〜**140秒**)
  - **`media_category=tweet_gif`**
- 1つの動画 (0.5秒〜**140秒**)
  - **`media_category=tweet_video`**

以下のコマンドを順番にコールする必要があります．

1. [INIT](https://dev.twitter.com/rest/reference/post/media/upload-init) **(`media_category`指定必須)**
2. [APPEND](https://dev.twitter.com/rest/reference/post/media/upload-append)
3. [FINALIZE](https://dev.twitter.com/rest/reference/post/media/upload-finalize)
4. [STATUS](https://dev.twitter.com/rest/reference/post/media/upload-status)

```php
$file = new \SplFileObject('large_video.mp4', 'rb');
$chunk_size = 262144; // 適当に調節してください

echo "Initializing...\n";
$info = $to->post('media/upload', [
    'command' => 'INIT',
    'media_type' => 'video/mp4',
    'total_bytes' => $file->getSize(),
    'media_category' => 'tweet_video', // これを忘れると非同期のほうにならない
]);

$curls = [];
for ($i = 0; '' !== $buffer = $file->fread($chunk_size); ++$i) { // チャンクサイズごとに分割
    $curls[] = $to->curlPostMultipart('media/upload', [
        'command' => 'APPEND',
        'media_id' => $info->media_id_string,
        'segment_index' => $i,
        '#media' => $buffer,
    ]); // リクエストせずにリクエスト用のcURLリソースだけを用意
}

echo "Uploading chunks...\n";
$to->curlMultiExec($curls, true); // 1チャンクごとに待ってると時間がかかるので並列化

echo "Finalizing...\n";
$info = $to->post('media/upload', [
    'command' => 'FINALIZE',
    'media_id' => $info->media_id_string,
]);

echo "Waiting for processing...\n";
while ($info->processing_info->state === 'pending' || $info->processing_info->state === 'in_progress') { // 終わるか失敗するまで繰り返す
    $percent = isset($info->processing_info->progress_percent) // 場合によってはパーセント表記が返ってくる
        ? "({$info->processing_info->progress_percent}%)"
        : ''
    ;
    echo "State: {$info->processing_info->state} {$percent}\n"; 
    sleep($info->processing_info->check_after_secs); // 待つように指示されたぶんだけ待つ
    $info = $to->get('media/upload', [
        'command' => 'STATUS',
        'media_id' => $info->media_id_string,
    ]); // 情報を更新
}

if ($info->processing_info->state === 'failed') {
    // 失敗時は例外をスロー
    throw new \RuntimeException($info->processing_info->error->name);
}

echo "Tweeting...\n";
$to->post('statuses/update', [
    'status' => 'hello',
    'media_ids' => $info->media_id_string,
]);

echo "Done!\n";
```

- サーバとの通信がかなりの回数必要
- multipart/form-dataとapplication/x-www-form-urlencodedから選べる
  - multipart/form-dataの場合，エンドポイント固有のパラメータは`oauth_signature`の生成対象に含まれない
  - application/x-www-form-urlencodedの場合，メディアデータのみBase64エンコードする必要がある
- 失敗した時に`$info->processing->state`が`"fail"`になることがある
  - **しかし，そのときのHTTPステータスコードは`200 OK`**
  - エラー内容次第では従来の `{"errors":[{"code":1,"message":"xx"}]}`が`400 Bad Request`で来ることもある
  - 失敗したメディアで`statuses/update`しようとするとしっかり`400 Bad Request`のエラーになるので，必ずしもこのエラーハンドリングを特別扱いして行う必要はない

オーバーヘッドの小さいmultipart/form-dataを推奨します．

**追記: TwistOAuthの後継である[Cowitter](https://github.com/mpyw/cowitter)では，面倒なアップロードのための記述を丸投げできます**

```php:Cowitterのメソッドを活用する場合
Co::wait(function () use ($client) {
    $file = new \SplFileObject('large_video.mp4', 'rb');

    $on_uploading = function ($percent) {
        echo "Uploading ... ({$percent}%)\n";
    };
    $on_processing = function ($percent) {
        echo "Processing ... ({$percent}%)\n";
    };

    yield $client->postAsync('statuses/update', [
        'status' => 'My video',
        'media_ids' => (yield $client->uploadVideoAsync($file, $on_uploading, $on_processing))->media_id_string,
    ]);

    echo "Done!\n";
});

```