Google Photosからランダムな1枚の画像を取得できるようにする


前回の投稿 [GoogleAPIライブラリを使わずにGoogleアカウントでログインできるようにする](https://qiita.com/poruruba/items/0f99400d178c4497f79b) で、とりあえず、Google Photos APIにアクセスする準備として、Googleアカウントでログインできる環境を作りました。

今度は、ログインしたアカウントのGoogle Photosの画像リストを取得して、ランダムに選択した画像を取得できるようにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/56364c9a-89ec-5654-9097-a9c6a9d4323a.png)

ソースコードもろもろは前回同様以下にあります。

poruruba/GooglePhotosGallery

https://github.com/poruruba/GooglePhotosGallery

# Google Photos APIを利用できるようにする

Google Cloud Platformのコンソールから、Google Photos APIを使えるように有効化します。

GCP：APIとライブラリ
https://console.cloud.google.com/apis/library

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/9124bab6-7365-c585-a838-d52a7f6b533e.png)

検索のところに、Photosと入力すると、Photos Library APIが出てきますので、選択してEnableにします。

# Google Photos APIを呼び出す

Google Photos APIの呼び出しには、いずれもGoogleアカウントログインで取得したアクセストークンが必要です。
今回実際に使うGoogle Photos APIは以下の通りです。

* 共有アルバムリストを取得
* アルバムリストを取得
* アルバムを作成
* 画像ファイルをアップロード
* アップロードした画像を登録
* アルバムまたは共有アルバムに含まれる画像リストを取得する

それ以外に関連するものとして以下が挙げられます。

* インスタグラムに投稿した画像リストを取得(※1の投稿で作成したサーバのWebAPI呼び出し)
* 画像ファイルを取得（通常のHTTP Get呼び出し）
* Googleアカウントでログインして、認可コードを取得する。(※2の投稿で作成したサーバのページに遷移)
* 認可コードからトークンに変換(※2の投稿で作成したサーバのWebAPI呼び出し)
* トークンをリフレッシュトークンで再生成(※2の投稿で作成したサーバのWebAPI呼び出し)
* IDトークンからユーザ名を取得

※1 [Instagramにアップロードした画像をランダムにESP32に表示する](https://qiita.com/poruruba/items/c25caeb77bbd1074c986)
※2 [GoogleAPIライブラリを使わずにGoogleアカウントでログインできるようにする]
(https://qiita.com/poruruba/items/0f99400d178c4497f79b)

これらのAPIがあれば、Instagramに投稿した画像をGoogle Photosのアルバムに登録したり、アルバムに登録した画像や共有アルバムにある画像からランダムに1つを選ぶことができそうです。

●共有アルバムリストを取得
Input：アクセストークン
Output：[アルバムID、アルバムタイトル、など]
https://developers.google.com/photos/library/guides/list#listing-shared-albums
ただし、OutputのnextPageTokenが含まれていた場合は、1回の呼び出しではすべてのアルバムを取得しきれなかったことを表しており、pageTokenにnextPageTokenの値を指定して再度呼び出します。

```js:node.js/api/controllers/googlephotos-api/index.js
      var album_list = await do_get_with_token('https://photoslibrary.googleapis.com/v1/sharedAlbums', {}, json.access_token);
      var albums = album_list.sharedAlbums || [];
      while(album_list.nextPageToken ){
        var params = {
          pageToken: album_list.nextPageToken
        };
        album_list = await do_get_with_token('https://photoslibrary.googleapis.com/v1/sharedAlbums', params, json.access_token);
        if (album_list.sharedAlbums )
          albums = albums.concat(album_list.sharedAlbums);
      }
```


●アルバムリストを取得
Input：アクセストークン
Output：[アルバムID、アルバムタイトル、など]
https://developers.google.com/photos/library/guides/list#listing-albums
OutputのnextPageTokenによる再呼び出しは同様です。

```js:node.js/api/controllers/googlephotos-api/index.js
      var album_list = await do_get_with_token('https://photoslibrary.googleapis.com/v1/albums', {}, json.access_token);
      var albums = album_list.albums || [];
      while( album_list.nextPageToken ){
        var params = {
          pageToken: album_list.nextPageToken
        };
        album_list = await do_get_with_token('https://photoslibrary.googleapis.com/v1/albums', params, json.access_token);
        if( album_list.albums )
          albums = albums.concat(album_list.albums);
      }
```

●アルバムを作成
Input：アクセストークン、アルバムタイトル
Output：アルバムIDなど
https://developers.google.com/photos/library/guides/manage-albums#creating-new-album

```js:node.js/api/controllers/googlephotos-api/index.js
        var params2 = {
          album: {
            title: ALBUM_NAME
          }
        };
        album = await do_post_with_token('https://photoslibrary.googleapis.com/v1/albums', params2, json.access_token);
```

●画像ファイルをアップロード
Input：アクセストークン、画像バッファ、Mime-Type
Output：アップロードトークン
https://developers.google.com/photos/library/guides/upload-media#uploading-bytes

```js:node.js/api/controllers/googlephotos-api/index.js
  var params = {
    albumId: json.albumId,
    newMediaItems: [],
  };
  for (const instagram of instagram_list.list) {
    var item = media_list.find(item => item.filename.startsWith("instagram_" + instagram.id + '.'));
    if (item)
      continue;
    var buffer = await do_get_buffer(instagram.media_url);
    //      console.log(buffer);

    var ftype = await filetype.fromBuffer(buffer);
    var uploadToken = await do_post_buffer('https://photoslibrary.googleapis.com/v1/uploads', buffer, ftype.mime, json.access_token);
    consooe.log(updateToken);

    params.newMediaItems.push({
      simpleMediaItem: {
        fileName: 'instagram_' + instagram.id + '.' + ftype.ext,
        uploadToken: uploadToken
      }
    });
  }

  if (params.newMediaItems.length > 0) {
    var result2 = await do_post_with_token('https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate', params, json.access_token);
    console.log(result2);
  }
```

●アップロードした画像を登録
Input：アクセストークン、[ファイル名、アップロードトークン]
Output：[画像のIDなど]
https://developers.google.com/photos/library/guides/upload-media#creating-media-item

●アルバムまたは共有アルバムに含まれる画像リストを取得する
Input：アクセストークン、アルバムIDまたはアルバムID
Output：[画像のID、画像のURLなど]
https://developers.google.com/photos/library/guides/list#listing-library-contents
OutputのnextPageTokenによる再呼び出しは同様です。

```js:node.js/api/controllers/googlephotos-api/index.js
  var params = {
    albumId: albumId,
    pageSize: 100
  }
  var result2 = await do_post_with_token('https://photoslibrary.googleapis.com/v1/mediaItems:search', params, access_token);
  var media_list = [];
  if (result2.mediaItems)
    media_list = result2.mediaItems;
  while (result2.nextPageToken) {
    params.pageToken = reulst2.nextPageToken;
    result2 = await do_post_with_token('https://photoslibrary.googleapis.com/v1/mediaItems:search', params, access_token);
    media_list = media_list.concat(result2.mediaItems);
  }
```

上記は、ガイドのページ( https://developers.google.com/photos/library/guides/get-started )を参照しましたが、詳細はリファレンス( https://developers.google.com/photos/library/reference/rest )があります。

なんかできそうですよね。
以下に注意事項を示しておきます。

* アクセストークンの有効期限は60分弱です。ですが、リフレッシュトークンがあれば、再生成できます。
* リフレッシュトークンは、3か月間全く使わないと無効になります。
* 「アルバムまたは共有アルバムに含まれる画像リストを取得する」で取得した画像のURLの有効期限は60分です。

●アクセストークンの有効期限は60分弱です。ですが、リフレッシュトークンがあれば、再生成できます。

有効期限が切れた場合は、リフレッシュトークンで再生成すればよいですが、有効期限ぎりぎりのタイミングで利用するのは得策ではないので、有効期限10分前だったら再生成するようにします。

```js:node.js/api/controllers/googlephotos-api/index.js
async function read_token() {
  var json = await jsonfile.read_json(TOKEN_FILE_PATH);
  if (!json || !json.albumId) {
    console.log('file is not ready.');
    throw 'file is not ready.';
  }

  var date = new Date();
  if (date.getTime() > json.created_at + json.expires_in * 1000 - 10 * 60 * 1000) {
    console.log('timeover');
    var params = {
      refresh_token: json.refresh_token
    };
    var result = await do_post_with_apikey(api_url + '/googleapi-refreshtoken', params, API_KEY);
    json.access_token = result.access_token;
    json.expires_in = result.expires_in;
    json.created_at = date.getTime();

    await jsonfile.write_json(TOKEN_FILE_PATH, json);

    await update_image_list(json);
  }

  return json;
}
```

●リフレッシュトークンは、3か月間全く使わないと無効になります。

クロンで毎月1回、read_token()を呼び出すようにします。

```js:node.js/api/controllers/googlephotos-api/index.js
exports.trigger = async (event, context, callback) => {
  console.log('googlephotos.trigger cron triggered');

  var json = await read_token();
  console.log(json);
};
```

```json:node.js/api/controllers/googlephotos-api/cron.json
  {
    "enable": true,
    "schedule": "0 0 0 1 * *",
    "handler": "trigger"
  }
```

●「アルバムまたは共有アルバムに含まれる画像リストを取得する」で取得した画像のURLの有効期限は60分です。

ちょうど、アクセストークンの有効期限と同じなので、リフレッシュトークンによるアクセストークンの更新のタイミングで、取得してあった画像リストも更新しておきます。

read_token() の関数において、トークンをリフレッシュした後に、update_image_list() を呼び出しています。


# Instagramの画像をGoogle Photosのアルバムに登録する

やっていることは、

* Instagramにある画像のリストを取得する
* Google Photosのアルバムまたは共有アルバムに、Instagramの画像が登録されているかを検索する(Instagramの画像のIDを含ませたファイル名をキーにして)。登録されていない場合は、Instagramの画像のIDをファイル名として、Google Photosのアルバムに登録する。

一応、Instagramの画像がJPEGではないかもしれないので、npmモジュール「file-type」でファイルのMime-Typeを調べてから登録しています。

```js:node.js/api/controllers/googlephotos-api/index.js
async function sync_instagram(json){
  var sharedalbum_list = await jsonfile.read_json(ALBUM_LIST_FILE_PATH, []);
  var media_list = await get_all_image_list(json.albumId, sharedalbum_list, json.access_token);

  var instagram_list = await do_get(api_url + '/instagram-imagelist');
  var params = {
    albumId: json.albumId,
    newMediaItems: [],
  };
  for (const instagram of instagram_list.list) {
    var item = media_list.find(item => item.filename.startsWith("instagram_" + instagram.id + '.'));
    if (item)
      continue;
    var buffer = await do_get_buffer(instagram.media_url);
    //      console.log(buffer);

    var ftype = await filetype.fromBuffer(buffer);
    var uploadToken = await do_post_buffer('https://photoslibrary.googleapis.com/v1/uploads', buffer, ftype.mime, json.access_token);
    consooe.log(updateToken);

    params.newMediaItems.push({
      simpleMediaItem: {
        fileName: 'instagram_' + instagram.id + '.' + ftype.ext,
        uploadToken: uploadToken
      }
    });
  }

  if (params.newMediaItems.length > 0) {
    var result2 = await do_post_with_token('https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate', params, json.access_token);
    console.log(result2);
  }

  console.log('success (' + params.newMediaItems.length + ')');

  return params.newMediaItems.length;
}
```

この関数を1日1回実行するようにクロン化します。

```js:node.js/api/controllers/googlephotos-api/index.js
exports.trigger2 = async (event, context, callback) => {
  console.log('googlephotos.trigger2 cron triggered');

  var json = await read_token();

  var num = await sync_instagram(json);

  if (num > 0)
    await update_image_list(json);
};
```

```json:node.js/api/controllers/googlephotos-api/cron.json
  {
    "enable": true,
    "schedule": "0 0 1 * * *",
    "handler": "trigger2"
  }
```


# 画像ファイルを取得（通常のHTTP Get呼び出し）

アルバムに含まれる画像リストの画像のURLを参照して、いったんサーバ側でHTTP Getを使って画像ファイルをダウンロードした後、クライアント側から取得したLCDの画面サイズに合わせてリサイズした上でクライアントに渡します。
リサイズには、npmモジュールの「sharp」を使っています。

```js:node.js/api/controllers/googlephotos-api/index.js
    case '/googlephotos-image': {
      const width = event.queryStringParameters.width ? Number(event.queryStringParameters.width) : 480;
      const height = event.queryStringParameters.height ? Number(event.queryStringParameters.height) : 320;
      const fit = event.queryStringParameters.fit || 'cover';

      var json = await read_token();
      var list = await read_image_list(json);

      if( list.data.length <= 0 )
        throw 'image_list is empty';

      var index = make_random(list.data.length - 1);
      var image = await do_get_buffer(list.data[index].baseUrl, {});

      var image_buffer = await sharp(Buffer.from(image))
        .resize({
          width: width,
          height: height,
          fit: fit
        })
        .jpeg()
        .toBuffer();

      return new BinResponse("image/jpeg", Buffer.from(image_buffer));
    }
```

# IDトークンからユーザ名を取得

IDトークンは、Googleアカウント認証したときに、アクセストークンと一緒に取得できたIDトークンに含まれます。
IDトークンは、Base64URLエンコードされており、デコードするために、npmモジュール「jwt-decode」を使いました。

```js:node.js/api/controllers/googlephotos-api/index.js
    case '/googlephotos-get-username': {
      var json = await read_token();

      const decoded = jwt_decode(json.id_token);
      return new Response( { name: decoded.name, sub: decoded.sub });
    }
```

# クライアント側の実装

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/a71110cf-e237-9a18-7f06-a24d85aa12fc.png)

以下のボタンを用意しています。

* ユーザログイン：Googleアカウントログインをします。ログインした結果のトークンは、Node.jsサーバ側に保持されます。
* 共有アルバム選択の変更：共有アルバムのリストを表示して、フォトフレームに含めたい共有アルバムを変更します。
* Instagramと同期：Instagramに投稿してある画像をGoogle Photosアルバムに登録します。
* フォトフレーム画像更新：押すたびにGoogle Photosのアルバムにある画像からランダムに選んだ画像が表示されます。

Javascriptのソースコードを示しておきます。各ボタンにvue_optionsのmethodsがそれぞれ割当たるのがわかるかと思います。
Googleアカウントログインは、こちら[GoogleAPIライブラリを使わずにGoogleアカウントでログインできるようにする](https://qiita.com/poruruba/items/0f99400d178c4497f79b)も参考にしてください。

```js:node.js/public/googlephotos/js/start.js
'use strict';

//const vConsole = new VConsole();
//window.datgui = new dat.GUI();

var new_win;
const SCOPE = 'https://www.googleapis.com/auth/photoslibrary https://www.googleapis.com/auth/userinfo.profile';

const login_url = 'https://【Node.jsサーバのホスト名】/googleapi-login';
const googlephotos_base_url = '【Node.jsサーバのホスト名】';

var vue_options = {
    el: "#top",
    mixins: [mixins_bootstrap],
    data: {
        state: Math.random().toString(32).substring(2),
        album_list: [],
        sharedalbum_list: [],
        sharedalbum_check: [],
        username: null,
        image_data: null,
    },
    computed: {
    },
    methods: {
        update_image: async function(){
            var blob = await do_get_blob(googlephotos_base_url + '/googlephotos-image');
            this.image_data = URL.createObjectURL(blob);
        },
        do_login: function () {
            var params = {
                scope: SCOPE,
                state: this.state
            };
            new_win = open(login_url + '?' + new URLSearchParams(params).toString(), null, 'width=480,height=750');
        },
        do_token: async function(qs){
            console.log(qs);
            if( qs.state != this.state ){
                alert('state mismatch');
                return;
            }

            var param = {
                code: qs.code,
                redirect_uri: qs.redirect_uri
            };
            var result = await do_post(googlephotos_base_url + '/googlephotos-account-create', param);
            console.log(result);
            this.get_albumlist();
            this.get_username();
        },
        get_username: async function(){
            var result = await do_post(googlephotos_base_url + '/googlephotos-get-username');
            console.log(result);
            this.username = result.name;
        },
        get_albumlist: async function(){
            var result = await do_post(googlephotos_base_url + '/googlephotos-get-albumlist' );
            console.log(result);
            var result2 = await do_post(googlephotos_base_url + '/googlephotos-get-sharedalbum');
            console.log(result2);
            var list = [];
            result.list.map(item =>{
                var album = result2.list.find(item2 => item2.id == item );
                if( album )
                    list.push(album)
            });
            this.album_list = list;
        },
        call_albumlist_change: async function(){
            var result = await do_post(googlephotos_base_url + '/googlephotos-get-sharedalbum');
            this.sharedalbum_list = result.list;
            this.sharedalbum_check = [];
            for (var i = 0; i < this.sharedalbum_list.length ; i++ ){
                if (this.album_list.findIndex(item => item.id == this.sharedalbum_list[i].id ) >= 0 )
                    this.sharedalbum_check[i] = true;
                else
                    this.sharedalbum_check[i] = false;
            }
            this.dialog_open('#albumlist_change_dialog');
        },
        do_albumlist_change: async function(){
            var list = [];
            for( var i = 0 ; i < this.sharedalbum_list.length ; i++ ){
                if( this.sharedalbum_check[i] )
                    list.push( this.sharedalbum_list[i].id );
            }

            await do_post(googlephotos_base_url + '/googlephotos-update-albumlist', { list: list });
            this.get_albumlist();
            this.dialog_close('#albumlist_change_dialog');
        },
        sync_instagram: async function(){
            try{
                this.progress_open();
                var result = await do_post(googlephotos_base_url + '/googlephotos-sync-instagram');
                alert( String(result.num) + '個の画像を取り込みました。' );
            }finally{
                this.progress_close();
            }
        }
    },
    created: function(){
    },
    mounted: async function(){
        proc_load();

        try{
            await this.get_albumlist();
            await this.get_username();
        }finally{
            loader_loaded();
        }
    }
};
vue_add_data(vue_options, { progress_title: '' }); // for progress-dialog
vue_add_global_components(components_bootstrap);
vue_add_global_components(components_utils);

/* add additional components */
  
window.vue = new Vue( vue_options );

function do_get_blob(url, qs) {
    const params = new URLSearchParams(qs);

    return fetch(params.toString() ? url + `?` + params.toString() : url, {
        method: 'GET',
    })
        .then((response) => {
            if (!response.ok)
                throw 'status is not 200';
        //    return response.json();
        //    return response.text();
            return response.blob();
        //    return response.arrayBuffer();
        });
}
```

# 最後に

続きはこちら

　[Google Calendarから予定を取得してESP32のLCDに表示する](https://qiita.com/poruruba/items/a828b2aef433514a4cb4)

以上