ブライトコーブ API サムネイル・ポスター画像を差し替える方法

## 事前準備

以下6点の情報をブライトコーブ管理者に相談して、
事前に準備しておいてください

1. ブライトコーブ アカウントID
2. ブライトコーブ クライアントID
3. ブライトコーブ 秘密鍵
4. ブライトコーブ API 権限設定
5. ブライトコーブ テスト用の動画を用意する(動画IDを用意する)
6. 公開サーバー上にテスト用の画像を置く

アカウントIDは、以下のURLに確認手順が書いてあります
(ブライトコーブを使ったことがあれば、アカウントIDは周知だと思いますが念のため)

https://ja.studio.support.brightcove.com/admin/managing-account-information.html

クライアントIDと秘密鍵の発行手順は、以下のURLに詳しく書いてあります

https://ja.studio.support.brightcove.com/admin/managing-api-authentication-credentials.html

APIの権限設定は、ブライトコーブ管理画面 左下 歯車アイコン(admin) > API認証(API Authentication) から行えます。
※ブライトコーブ管理者アカウントのみ表示されます

* Exposed Brightcove APIs
   * CMS - Video Read/Write
   * Dynamic Ingest - Create
   * Ingestion Profiles - Configuration Read
   * Ingestion Profiles - Read

どこかで拾ったパーミッション設定の画像を載せておきます。

![di-permissions.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130352/2e423aab-c523-281d-1b8f-7be89dd5d8c5.png)

ここまで出来れば、以下のソースコードを使うことで
画像の差し替えが可能になります。

## 実装

以下のソースコードを、PHPが動くサーバー上に「 `bc_image/upload.php` 」など、
任意の名前をつけて置いてからGETメソッドでアクセスしてください。
定数の値は適宜、変更してください。

```php:bc_image/upload.php
<?php
/**
 * [NOTE]
 * 画像アップロードの前に、必要な権限をブライトコーブ上で許可しておくこと
 * ブライトコーブ管理画面 左下 歯車アイコン(admin) > API認証(API Authentication)
 * Exposed Brightcove APIs
 * - CMS - Video Read/Write
 * - Dynamic Ingest - Create
 * - Ingestion Profiles - Configuration Read
 * - Ingestion Profiles - Read
 */

ini_set('display_errors', 1);

define("BC_ACCOUNT_ID", "1234567890123"); // 1. ブライトコーブのアカウントIDを入力する
define("BC_CLIENT_ID", "your_brightcove_client_id_here"); // 2. ブライトコーブのクライアントIDを入力する
define("BC_CLIENT_SECRET", "your_brightcove_secret_key_here"); // 3. ブライトコーブの秘密鍵を入力する

define("BC_TEST_VIDEO_ID", "1234567890123"); // 5. ブライトコーブ テスト用動画IDを入力する
define("BC_TEST_IMAGE_URL", "https://www.example.com/images/test.jpg"); // 6. 公開サーバー上に置いた画像のURLを入力する

/**
 * 画像のサイズ情報を返す
 */
function getImageInfo($imageUrl)
{
    $info = getimagesize($imageUrl);
    $ret = [
        "width" => $info[0],
        "height" => $info[1],
        "mime" => $info["mime"]
    ];
    return $ret; // array or false
}

/**
 * ブライトコーブの画像更新に必要なフォーマットを返す
 *
 * [NOTE] 公式リファレンスにはurlだけが必須パラメータである旨記載されていたが、
 * 実際にはwidthとheightも入力しなければ更新されなかった
 * @see https://ja.apis.support.brightcove.com/dynamic-ingest/references/reference.html#tag/Ingest/operation/AccountsVideosIngestRequestsByAccountIdAndVideoIdPost
 */
function getBcImageFormat($imageUrl)
{
    $imageInfo = getImageInfo($imageUrl);

    $imageFormat = [
        "poster" => [
            "url" => $imageUrl,
            "width" => $imageInfo["width"],
            "height" => $imageInfo["height"]
        ],
        "thumbnail" => [
            "url" => $imageUrl,
            "width" => $imageInfo["width"],
            "height" => $imageInfo["height"]
        ],
        "images" => [
            [
                "variant" => "poster",
                "url" => $imageUrl,
                "width" => $imageInfo["width"],
                "height" => $imageInfo["height"]
            ],
            [
                "variant" => "thumbnail",
                "url" => $imageUrl,
                "width" => $imageInfo["width"],
                "height" => $imageInfo["height"]
            ]
        ]
    ];

    return $imageFormat;
}

function updateBcImages($videoId, $imageFormat)
{
    $response = postData($videoId, $imageFormat);
    return $response;
}

/**
 * 画像をブライトコーブサーバーに反映する
 */
function postData($videoId, $data)
{
    $accountId = BC_ACCOUNT_ID;

    $apiUrlBase = "https://ingest.api.brightcove.com/v1/";
    $apiPath = "accounts/{$accountId}/videos/{$videoId}/ingest-requests";

    $url = $apiUrlBase . $apiPath;

    $accessToken = getBcToken();

    //send the http request
    $options = [
        CURLOPT_POST           => TRUE,
        CURLOPT_CUSTOMREQUEST  => "POST",
        CURLOPT_RETURNTRANSFER => TRUE,
        CURLOPT_SSL_VERIFYPEER => FALSE,
        CURLOPT_POSTFIELDS     => json_encode($data),
        CURLOPT_HTTPHEADER     => array(
            'Content-type: application/json',
            "Authorization: Bearer {$accessToken}",
        )
    ];

    $ch = curl_init($url);
    curl_setopt_array($ch, $options);

    $response = curl_exec($ch);
    curl_close($ch);

    // Check for errors
    if ($response === FALSE) {
        echo '{"ERROR": "There was a problem with your API call"}';
        die(curl_error($ch));
    }

    // return the response to the AJAX caller
    $response = json_decode($response);

    if(! isset($response)){
        echo '{null}';
        die(curl_error($ch));
    }

    return $response;
}

/**
 * ブライトコーブのアクセストークンを取得する
 */
function getBcToken()
{
    $data = array();
    $clientId     = BC_CLIENT_ID;
    $clientSecret = BC_CLIENT_SECRET;
    $authString   = "{$clientId}:{$clientSecret}";

    $apiUrlBase   = "https://oauth.brightcove.com/v4/";
    $apiPath      = "access_token?grant_type=client_credentials";

    $url = $apiUrlBase . $apiPath;
    $ch  = curl_init($url);

    curl_setopt_array($ch, array(
        CURLOPT_POST           => TRUE,
        CURLOPT_RETURNTRANSFER => TRUE,
        CURLOPT_SSL_VERIFYPEER => FALSE,
        CURLOPT_USERPWD        => $authString,
        CURLOPT_HTTPHEADER     => array('Content-type: application/x-www-form-urlencoded'),
        CURLOPT_POSTFIELDS => $data
    ));

    $response = curl_exec($ch);
    curl_close($ch);

    // Check for errors
    if ($response === FALSE) {
        echo '{"ERROR": "There was a problem with your API call"}';
        die(curl_error($ch));
    }

    // return the response to the AJAX caller
    $response = json_decode($response);

    if(! isset($response)){
        echo '{null}';
        die(curl_error($ch));
    }

    $accessToken = $response->access_token;
    return $accessToken;
}

function main()
{
    $videoId = BC_TEST_VIDEO_ID; // ブライトコーブの動画ID
    $imageUrl = BC_TEST_IMAGE_URL; // 差し替える画像のURL
    $imageFormat = getBcImageFormat($imageUrl);

    // Update Brightcove Images
    $job = updateBcImages($videoId, $imageFormat);

    $message = "";
    $message .= '画像アップロードに成功しました。ジョブID: ' . $job->id . "<br>";
    $message .= '次の画像を反映中です。' . '<a href="' . $imageUrl. '" target="_blank">' . $imageUrl . '</a>';

    echo $message;
    exit;
}

main();
```

## 動作確認

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130352/48091944-5971-8fa0-976a-348e9bd7530f.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130352/2fe19a71-cab0-d7c2-02da-9c24764c13b5.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130352/c5e4ba8f-55de-3f72-ca41-dc236906bb80.png)

## つまづきポイント

### 画像の差し替えにはDynamic Ingest APIを使用する

まずこの結論にたどり着くのに時間がかかりました。
ブライトコーブAPIの情報なさすぎワロタ。結構需要がありそうなものですが。。

### 画像更新を行うには、アクセストークンが必要である

これは過去に別のAPIで開発実績があったのでそこまでつまづきませんでしたが、
アクセストークンが必要であることを念押ししておきます。
アクセストークン取得のサンプルコードは以下のURLを参考にしてください

https://ja.apis.support.brightcove.com/oauth/guides/getting-access-tokens.html


### 画像更新には、画像の横幅と縦幅の情報が事実上、必須である

公式リファレンスには、画像更新に必須のパラメータはurlのみである、と記載がありましたが、
実際にはwidthとheightの値も必須でした。
これを省略すると、送信は成功しますがどれだけ待っても反映されませんでした。

### 非公開サーバーに置いた画像は取得できないので差し替え不可

当たり前ではありますが、ブライトコーブAPIからURL経由で画像を取得しますので、画像が非公開のS3バケットなどにある場合、当然ながら画像の取得が出来ず、504 Gateway Timeoutになります。
公開中のS3バケットやWEBサーバーを用意するなどして、そこに画像を置くようにしてください。
セキュアにしたいならば、スクリプト自体はIP制限を設けたWEBサーバーに置きつつ、画像アップロードをs3::putObjectで公開済のS3バケットに置いて、s3::getObjectでURLを取得する、といった感じでしょうか。誰かサンプル書いてくれや

## 参考URL

https://ja.apis.support.brightcove.com/dynamic-ingest/index.html

https://ja.apis.support.brightcove.com/dynamic-ingest/references/reference.html

「Ingest Videos and Assets」の項目を参照してください

## 追記 Javascript版の実装

実際には使いませんでしたが、APIの検証目的でjavascriptで作ったコードがあったので供養のために置いておきます。
これでも画像の差し替えが出来ることは確認してあります。

```javascript
/**
 * グローバル変数
 */
var API_PATH_BASE = "./bc-proxy/";
var BC_ACCOUNT_ID = "1234567890123";
var BC_TOKEN = "";
var BC_API_URL_BASE = "https://cms.api.brightcove.com/v1/";
var BC_INGEST_API_URL_BASE = "https://ingest.api.brightcove.com/v1/";
var EXPIRED_TIME = 0;

/**
 * Get Data By Ajax
 *
 * @param  string filePath
 * @return Deferred Object
 */
function getData(filePath)
{
  var def = new $.Deferred;

  var options = {
    type : 'GET',
    url : filePath,
    dataType : 'json',
    cache : false
  };

  $.ajax(options)
  .done(function(data, textStatus, jqXHR){
    console.log('getData done', data);
    def.resolve(data);
  })
  .fail(function(jqXHR, textStatus, errorThrown){
    console.error('getData fail', jqXHR);
    def.reject(jqXHR);
  });

  return def.promise();
}

/**
 * Post Data By Ajax
 *
 * @param  string filePath
 * @param  object postParams
 * @return Deferred Object
 */
function postData(filePath, inputs)
{
  var def = new $.Deferred;

  //var csrfToken = this.getCsrfToken();
  //postParams = Object.assign(postParams, csrfToken);

  var options = {
    type : 'POST',
    url : filePath,
    data : inputs,
    dataType : 'json',
    cache : false
  };

  $.ajax(options)
  .done(function(data, textStatus, jqXHR){
    console.log('postData done', data);
    def.resolve(data);
  })
  .fail(function(jqXHR, textStatus, errorThrown){
    console.error('postData fail', errorThrown);
    def.reject(errorThrown);
  });

  return def.promise();
}

/**
 * Put Data By Ajax
 *
 * @param  string filePath
 * @return Deferred Object
 */
function putData(filePath, inputs)
{
  var def = new $.Deferred;

  var options = {
    type : 'PUT',
    url : filePath,
    data : inputs,
    dataType : 'json',
    cache : false
  };

  $.ajax(options)
  .done(function(data, textStatus, jqXHR){
    console.log('putData done', data);
    def.resolve(data);
  })
  .fail(function(jqXHR, textStatus, errorThrown){
    console.error('putData fail', errorThrown);
    def.reject(errorThrown);
  });

  return def.promise();
}

/**
 * Post Data By Ajax
 *
 * @param  string filePath
 * @param  object inputs
 * @return Deferred Object
 */
function patchData(filePath, inputs)
{
  var def = new $.Deferred;

  //var csrfToken = this.getCsrfToken();
  //postParams = Object.assign(postParams, csrfToken);

  var options = {
    type : 'PATCH',
    url : filePath,
    data : inputs,
    dataType : 'json',
    cache : false
  };

  $.ajax(options)
  .done(function(data, textStatus, jqXHR){
    console.log('patchData done', data);
    def.resolve(data);
  })
  .fail(function(jqXHR, textStatus, errorThrown){
    console.error('patchData fail', errorThrown);
    def.reject(errorThrown);
  });

  return def.promise();
}

/**
 * クエリパラメータをJSON形式で取得する
 * 
 * @doc https://medialize.github.io/URI.js/
 * @doc https://thebaker.hatenablog.com/entry/2018/02/08/154315
 */
function getQueries()
{
  var uri = new URI();
  var queries = uri.query(true);
  return queries;
}

/**
 * 特定のクエリパラメータの値を取得する
 * 
 * @param  string クエリパラメータ名 e.g. "team_id"
 * @return mixed クエリパラメータの値 e.g. "7"
 */
function getQuery(key)
{
  var queries = getQueries();
  var value = queries[key];
  return value;
}

/**
 * クエリパラメータのオブジェクトからクエリ文字列を生成する
 * 
 * @doc https://medialize.github.io/URI.js/docs.html#static-buildQuery
 */
function httpBuildQuery(obj)
{
  var str = URI.buildQuery(obj);
  return str;
}

/**
 * ブライトコーブのアクセストークンを取得する
 * アクセストークンの有効期限は最大で5分
 */
function getBcToken()
{
  var url = API_PATH_BASE + "token.php";
  var res = getData(url).then(function(data){
    //console.log(data);

    var accessToken = data.access_token; // e.g. "ANB7xKhi...UZd"
    var tokenType = data.token_type; // e.g. "Bearer"
    var expired = data.expires_in; // e.g. 300

    BC_TOKEN = accessToken;
    //EXPIRED_TIME = moment().add(expired, 'seconds').unix();
    // アクセストークン取得に約1秒かかる関係で
    // 二回目以降の取得時に有効期限切れになるため補正
    EXPIRED_TIME = moment().add(expired, 'seconds').subtract(1, 'seconds').unix();
  });

  return res;
}

/**
 * ブライトコーブのアクセストークンが有効期限内かどうかを判定する
 * 有効期限内(つまりアクセストークン取得から5分以内)ならtrueを返す
 */
function isValidBcToken()
{
  var now = moment().unix();
  //var isValid = moment(now).isSameOrBefore(EXPIRED_TIME);
  // 5分ジャストは有効期限切れになる模様(1分毎の自動更新で5回目の更新時にデータ取得に失敗する)
  var isValid = moment(now).isBefore(EXPIRED_TIME);

  if(isValid){
    console.log("current_time:", now);
    console.log("expired_time:", EXPIRED_TIME);
    console.log("Bc token is still available.");
    return true;
  }
  else {
    console.log("current_time:", now);
    console.log("expired_time:", EXPIRED_TIME);
    console.log("Bc token is expired.");
    return false;
  }
}

function getIngestUrl(videoId)
{
  var url = BC_INGEST_API_URL_BASE + "accounts/" + BC_ACCOUNT_ID + "/videos/" + videoId + "/ingest-requests";
  return url;
}

async function updateVideoInfo(videoId, inputs)
{
  // アクセストークンが失効したときだけAPIリクエストをする
  if(isValidBcToken() === false){
    await getBcToken();
  }

  var accessToken = BC_TOKEN;

  $.ajaxSetup({
    'headers' : {
      "Content-Type" : "application/json",
      "Authorization" : "Bearer " + accessToken,
    }
  });

  var inputs = {
    "name" : "new_video_title_here",
    "cue_points" : [
      {
        "force_stop": true, // スキップ不可を強制する? 未検証
        "time": 0, // タイムコード 秒単位
        "name": "", // キューポイント名
        "metadata": "", // キーと値のペア
        "type": "AD" // タイプ コード -> "DATA", 広告 -> "AD"
      }
    ]
    "custom_fields" : {
      "hogehoge" : "hoge"
    },
    "reference_id" : "reference_id_here",
    "published_at" : "2017-01-25T06:13:13.813Z",
    "geo" : {
      "countries" : ["jp"],
      "exclude_countries" : false,
      "restricted" : true
    }
  };
  inputs = JSON.stringify(inputs);

  var url = getVideoUrl(videoId);
  var res = patchData(url, inputs);

  res.then(function(data){
    console.log(data);
  });

  res.fail(function(jqXHR){
    if(jqXHR.status === 404){
      var arr = $.parseJSON(jqXHR.responseText);
      var message = arr ? "\n" + "[" + arr[0].error_code + "]" + " " + arr[0].message : "";
      alert("動画情報が見つかりませんでした" + message);
    }
    else {
      alert("情報の取得に失敗しました");
    }
  });
}

async function updateVideoImages(videoId, inputs)
{
  // アクセストークンが失効したときだけAPIリクエストをする
  if(isValidBcToken() === false){
    await getBcToken();
  }

  var accessToken = BC_TOKEN;

  $.ajaxSetup({
    'headers' : {
      "Content-Type" : "application/json",
      "Authorization" : "Bearer " + accessToken,
    }
  });

  var imgUrl = "https://www.example.com/images/test.jpg";

  var inputs = {
    "poster" : {
      "url" : imgUrl,
      "width" : 300,
      "height" : 300
    },
    "thumbnail" : {
      "url" : imgUrl,
      "width" : 300,
      "height" : 300
    },
    "images" : [
      {
        "variant" : "poster",
        "url" : imgUrl,
        "width" : 300,
        "height" : 300
      },
      {
        "variant" : "thumbnail",
        "url" : imgUrl,
        "width" : 300,
        "height" : 300
      }
    ]
  };
  inputs = JSON.stringify(inputs);

  var url = getIngestUrl(videoId);
  var res = postData(url, inputs);

  res.then(function(data){
    console.log(data);
  });

  res.fail(function(jqXHR){
    if(jqXHR.status === 404){
      var arr = $.parseJSON(jqXHR.responseText);
      var message = arr ? "\n" + "[" + arr[0].error_code + "]" + " " + arr[0].message : "";
      alert("動画情報が見つかりませんでした" + message);
    }
    else {
      alert("情報の取得に失敗しました");
    }
  });
}

$(function(){
  var videoId = "1234567890123";
  updateVideoImages(videoId);
});
```