28
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Google Photos APIを使ってGoogleフォトからAmazon Photosへ自動コピーする

Posted at

こんにちは。

私は普段、画像ストレージにはGoogleフォトを利用しています。しかし**「Googleフォトの写真をAmazon Photosに全コピーしたい」**案件が発生しました。

幸いAmazonプライム会員ですので保存容量は問題ありませんが1、その手法が問題になりました。

そこで今回、Google Photos APIを使ったGoogleフォトからAmazon Photosへの(なんちゃって)自動バックアップの仕組みを作ったのでそこで得られた知見をまとめます。

モチベーション

改めて今回の動機はこんな感じです。

  • Amazon Fire TV StickのスクリーンセーバーにAmazon Photosの写真が使える!
  • そのためにGoogleフォトのデータをAmazon Photosにコピーしたい
  • 対象データはGoogleフォトの共有アルバムの全データ
  • Googleフォトの共有アルバムには今後も画像が追加されていく

自分で撮ったスマホの写真だけならアプリでAmazon Photosに自動アップロードできますが2、**Googleフォトの共有アルバムには家族や友人が撮影したデータも含まれるため、**何かしらの仕組みが必要だと考えました。

問題点と妥協案

最初に考えたこと

全部手動でアップロードする

Googleフォトではアルバム単位で全画像のダウンロードが可能なので、ダウンロード→アップロードを手動でやる作戦です。

1回限りなら悪くありませんが、アルバムには随時画像が追加されていくため論外です。

API連携

次に考えたのがこんな感じのAPI連携です。

しかし次の問題により諦めました。

  1. Googleフォトの更新検知やWebhookする仕組みが無い(知らないだけ?)
  2. 現在Amazon Cloud Drive APIは使えない

1点目についてはGoogle Photos APIでは提供されておらず、IFTTTやIntegromatなどのWeb連携サービスでも提供されていません。

Integromatには**「Google Photosをトリガーで使えるぞ!」**とあるのですが、実際に作ってみると内部がPicasa APIのままらしく、エラーが発生し設定ができません。
2019-05-23_15h38_37.png

2点目について、Amazon Photosの実態はAmazon Driveというオンラインストレージに格納されたデータを切り出したものです。このためGoogleフォトから取得したデータをAmazonのAPIに乗っければイケるかと思いましたが、Amazon DriveのAPIは現在、新規利用を停止しています。

なんちゃって自動化案

これらの問題を受けて、以下のような**「なんちゃって自動化案」**を考えました。

  1. 端末のタスクスケジューラに以下2.の処理を一定間隔で実行するように登録する。
  2. Google Photos APIで対象アルバムの全データを走査し、ローカル環境に存在しないデータをダウンロード。この時、Exif情報が失われているので可能な限り付加する。(後述)
  3. Amazon Photosのデスクトップアプリにて、上記1.でダウンロードしたディレクトリを自動アップロード対象に設定する。

……どうでしょうか。これは自動化なのでしょうか。

特に3.のデスクトップアプリの使用が必須であるため、ローカル端末が起動している間しか同期できません。(しかもリアルタイムじゃない)

ですが、これで一応は当初の要求を実現できそうです。

自動化の手順

ここからGoogle Photos APIを利用したプログラム部分です。

今回作成したプログラムはGitHub上にアップロードしています。
https://github.com/quotto/googlephoto-backup

以降の説明部分ではコードを一部抜粋しています。

Google OAuth2.0による認可

Google Photos APIを利用するには、Google Developer Consoleからプロジェクトを作成し、それに対するAccessTokenの発行が必要です。

ClientIDとClientSecretの取得

Google Developer Consoleからプロジェクトを作成し、ClientIDとClientSecretを入手します。

この手順は公式ガイドの通りに進めれば問題なくできるはず。

2019-05-23_13h28_17.png

AccessTokenの発行

続いてAPIを叩くのに必要なAccessTokenを発行します。

Webアプリであれば公式のサンプルがそのまま使えますが、今回はそんな立派なものは必要ないためサンプルをもとに簡易なプログラムを用意しました。

oauth.js
const fs = require('fs');
const request = require('request-promise');
const config = require('./config');
const express = require('express')
const bodyParser = require('body-parser');
const http = require('http')
const app = express();
const server = http.Server(app);

app.get('/auth/google/callback',(req,res)=>{
    request.post(`${config.oauthEndpoint}/token`,{
        headers:{'Content-Type': 'application/json'},
        json: {
            code: req.query.code,
            client_id: config.oAuthClientID,
            client_secret: config.oAuthclientSecret,
            redirect_uri: config.oAuthCallbackUrl,
            grant_type: 'authorization_code'
        }
    }).then((data)=>{
        res.send('Oauth process succeed.Please back to app console.');
        res.end();
        fs.open('credential','w',(err,fd)=>{
            const authenticate_data = {
                token: data.access_token,
                refreshToken: data.refresh_token,
                expires: Date.now() + (data.expires_in * 1000)
            }
            fs.writeSync(fd,JSON.stringify(authenticate_data)) ;
            console.log('Oauth process succeed.');
            console.log('Press Ctrl-C and run application.');
        });
    })
})

server.listen(config.port,()=>{
    const scope = config.scopes.join('%20');
    const oauth_url = `${config.oauthEndpoint}/auth?client_id=${config.oAuthClientID}&redirect_uri=${config.oAuthCallbackUrl}&response_type=code&scope=${scope}&access_type=offline`
    console.log('Please access this URL:');
    console.log(oauth_url);
})

このプログラムを実行すると認証画面のURLが表示され、expressサーバを起動してAccessTokenの発行準備に入ります。

> node oauth.js
Please access this URL:
https://accounts.google.com/o/oauth2/auth?client_id=作成したClientID&re
direct_uri=http://127.0.0.1:9999/auth/google/callback&response_type=code&scope=https://www.googleapis.com/auth/photoslibrary.rea
donly%20profile&access_type=offline

この時の認証URLに設定するパラメータは以下のとおりです。

  • client_id:前の手順で取得したClientIDを設定します。
  • redirect_uri:Web画面から認証を終えた後のコールバックURLを指定します。
  • response_type:固定でcodeを設定します。
  • scope:APIに許可する操作範囲を指定します。複数設定する場合は空白文字(%20)で区切ります。
  • access_type:RefreshTokenを受け取る場合にはofflineを指定します。

URLをコピーしてブラウザからアクセスして、アプリのAPI利用を許可します。
2019-05-23_13h37_12.png

2019-05-23_13h37_29.png 2019-05-23_13h38_33.png

これでAccessTokenの発行は完了です。コンソールはCtrl-Cで終了します。

プロジェクトルートにcredentialという名前のJSONファイルができています。このファイルはAccessTokenとRefreshToken、AccessTokenの期限時刻(ミリ秒)が格納されています。

credential
{
 "token":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
 "refreshToken":"yyyyyyyyyyyyyyyyyyyyyyyyyyy",
 "expires":1558589917676
}

Google Photos APIでローカル環境に画像をダウンロードする

準備が整ったので画像のダウンロード処理を実行します。大まかな流れとしては

「対象アルバムのデータ件数取得」→「1件ずつ画像データ読み込み」→「Exif情報を付けて保存」

となります。

アルバムのデータ件数取得

最初にGoogle Photos APIで対象アルバムのデータ件数を取得します。

app.js
const album = await request.get(`${config.apiEndpoint}/v1/albums/${config.backupAlbumId}`,{
    headers: {'Content-Type': 'application/json'},
    json: true,
    auth: {'bearer':credential.token}
});
const item_count = Number(album.mediaItemsCount);

ここではalbumIdを固定で指定しています。なおalbumIdはhttps://photoslibrary.googleapis.com/v1/albumsへのリクエストで確認できます。

画像データの読み込み

アルバム内に保存されているデータの一覧は、**1回のリクエストで最大100件取得できます。**エンドポイントhttps://photoslibrary.googleapis.com/v1/mediaItems:searchに対して、対象アルバムのIdをalbumIdに、最大取得件数をpageSizeに指定します。

2回目以降に取得するデータ(例えば101件目、201件目……)はMediaItems.nextPageTOkenにより識別されます。

app.js
// 対象アルバムの全画像を100件ずつ取得する
const iterate = Math.ceil(item_count/config.searchPageSize);
for(let i=0; i<iterate; i++) {
    ...
    const parameter = {albumId:config.backupAlbumId, pageSize:config.searchPageSize};
    if(next_page_token) {
        parameter.pageToken = next_page_token;
    }
    const items = await request.post(`${config.apiEndpoint}/v1/mediaItems:search`,{
        headers: {'Content-Type': 'application/json'},
        json: parameter,
        auth: {'bearer':credential.token}
    });

    if(items && items.mediaItems) {
        const downloadAsyncJob = [];

        items.mediaItems.forEach((media_item)=>{
            // 対象のMIMETYPEに一致するメディアのみダウンロード処理実行
            if(config.backupMimeType.indexOf(media_item.mimeType.toLowerCase()) >= 0) {
                downloadAsyncJob.push(downloadImage(media_item));
            }
        });

        // 1リクエスト最大100回の並列ダウンロードが終わるまで待機
        await Promise.all(downloadAsyncJob);
        next_page_token = items.nextPageToken;
    }
    ....
}

続いてダウンロード処理の本体です。
MediaItemオブジェクトの配列がレスポンスとして格納されるので、1件ずつ処理を回して画像データを読み出します。

app.js
// 画像データのダウンロード処理
// media_item:MediaItemオブジェクト
const downloadImage = (media_item)=>{
    return new Promise((resolve,reject)=>{
        // ファイル名はid+元々のファイルの拡張子とする
        const filename = media_item.id + media_item.filename.substring(media_item.filename.lastIndexOf('.'));
        const saveFile = path.join(config.backupDir,filename);
        fs.stat(saveFile,(err,stat)=>{
            if(!stat) {
                //ファイルが存在しなければダウンロード処理を開始する
                const metadata = media_item.mediaMetadata;
                const rawdataUrl = `${media_item.baseUrl}=w${metadata.width}-h${metadata.height}`
                logger.info(`download:${filename} from ${rawdataUrl}`)

                request({url:rawdataUrl,encoding: null,method: 'GET'},(err,res,body)=>{
                    ...
                    // rawdataからはExif情報が含まれないためJPEGであればAPIから取得したメタデータをよりExif情報を設定する
                    const data = media_item.mimeType.toLowerCase() === 'image/jpeg' ? insertExif(metadata,body) : body;
                    fs.writeFile(saveFile,data,{encoding:'buffer'},(err)=>{
                        ...
                        resolve();
                    });
                });
            } else {
                //ファイルが既に存在すれば何もせず完了
                resolve();
            }
        });
    });
}

画像データをダウンロードするにあたっては次の点がポイントです。

  • ローカル環境に保存するファイル名はMediaItem.idを用います。オリジナルのファイル名も取得できますが、Googleフォトではオリジナルのファイル名が同じでも異なるデータとして管理されるためです。
  • **「同名ファイルがローカルディレクトリに存在しない」**ものを更新(追加)データと判断してダウンロード対象とします。
  • 画像データはMediaItem.baseURLから取得できます。ただしこのURLから取得できるデータはExif情報が含まれていないので、別途設定する必要があります。(後述)

**差分のチェック方法が残念すぎますが、他に良い方法が思いつきませんでした……**APIで「アップロード日の降順」とかで指定ができれば良かったのですが、それもできず苦肉の策で全件走査しています。

1,000件、2,000件程度であれば大丈夫だと思いますが、数万件になると処理時間的にもAPIの利用制限的にも厳しいと思います。

Exif情報の注入

前段で触れましたが今回のプログラムでは対象データがJPEG画像であった場合、Exif情報を注入してバイナリデータを作り直します。

普通にGoogleフォトからダウンロードした場合には問題ないのですが、API経由で取得したMediaItem.baseURLのデータからはExif情報が削除されているためです。

このためAPI経由で取得したMediiaItem.metadataを基にExif情報を設定します。ただしAPIで取得できるExif情報は一部のみであり、いずれにしてもオリジナル画像からは大部分が削除されてしまいます。

今回Exif情報の作成にはPiexifjsを利用させていただきました。

app.js
// Exifデータの挿入
// metadata:Google Photos APIから取得したMediaItemのmedaData.Photo
// jpeg_data: 対象画像のbufferデータ
const insertExif = (metadata,jpeg_data) =>{
    const zeroth = {};
    const exif = {};
    const gps = {};
    if(metadata.photo.cameraMake) zeroth[piexif.ImageIFD.Make] = metadata.photo.cameraMake;
    if(metadata.photo.cameraModel) zeroth[piexif.ImageIFD.Model] = metadata.photo.cameraModel;
    if(metadata.width) zeroth[piexif.ImageIFD.ImageWidth] = Number(metadata.width);
    if(metadata.height) zeroth[piexif.ImageIFD.ImageLength] = Number(metadata.height);
    if(metadata.photo.focalLength) exif[piexif.ExifIFD.FocalLength] = metadata.photo.focalLength;
    if(metadata.photo.apertureFNumber) exif[piexif.ExifIFD.FNumber] = metadata.photo.apertureFNumber;
    if(metadata.photo.isoEquivalent) exif[piexif.ExifIFD.ISOSpeedRatings] = metadata.photo.isoEquivalent;
    if(metadata.photo.exposureTime) exif[piexif.ExifIFD.ExposureTime] = metadata.photo.exposureTime;
    const creationTime = new Date(metadata.creationTime);
    const year = creationTime.getFullYear();
    const month = creationTime.getMonth() < 9 ? `0${creationTime.getMonth()+1}`:str(creationTime.getMonth()+1);
    const date = creationTime.getDate() < 10 ? `0${creationTime.getDate()}`:creationTime.getDate();
    const hour = creationTime.getHours() < 10 ? `0${creationTime.getHours()}`:creationTime.getHours();
    const minute = creationTime.getMinutes() < 10 ? `0${creationTime.getMinutes()}`:creationTime.getMinutes();
    const second = creationTime.getSeconds() < 10 ? `0${creationTime.getSeconds()}`:creationTime.getSeconds();
    exif[piexif.ExifIFD.DateTimeOriginal] = `${year}:${month}:${date} ${hour}:${minute}:${second}`;

    const exifObj = {"0th":zeroth,"Exif":exif};
    const exifStr = piexif.dump(exifObj);

    return new Buffer(piexif.insert(exifStr,jpeg_data.toString('binary')), 'binary');
}

Amazon Photosデスクトップアプリで自動アップロード

ここまででGoogleフォトからの画像データダウンロードが完了しました。最後にAmazon Photosへのデータアップロードのため、デスクトップアプリをインストール・設定します。

まずは公式サイトからインストーラーを入手して、インストールします。

インストールしたアプリを起動して、「フォルダを追加」でGoogleフォトから画像をダウンロードしたフォルダを指定します。
2019-05-23_15h09_01.png

するとこのような画面が表示されるため、設定を保存します。

2019-05-23_15h10_46.png
  • バックアップ先:Amazon Cloud Drive上のアップロード先のディレクトリ
  • 変更のアップロード:即時
  • バックアップの対象:写真のみ
  • 「重複の回避」にチェック

あとはアプリがローカルディレクトリ自動でを検知してAmazon Photosにアップロードしてくれます!
2019-05-23_15h14_58.png

タスクのスケジュール化

最後にGoogleフォトからのダウンロード処理を定期実行させます。

私の環境はWindowsのため以下のようなbatファイルをプロジェクト直下に作成し、タスクスケジューラに設定しました。

autobackup.bat
@echo off
cd %~dp0
node app.js

Google Photos APIについてまとめ

Google Photos APIを使ってみた感想ですが、

  • 更新を検知する機能は無いので差分チェックが辛い
  • 「アップロード日」という項目は無いので、やっぱり差分チェックが辛い
  • アプリからダウンロードしない場合(MediaItem.baseURLから読み込んだ場合)JPEGのExif情報は削除されている

こうやって見るとAPIを使った処理は制約がけっこう多いですね。もうちょっと使い勝手がよくなればいいな、と思います。

  1. Amazon PhotosはAmazonプライム会員であれば画像データに限り容量無制限かつ非圧縮で利用できる(動画データは5GBまで)

  2. 実はこのアプリが使い物にならないほどアップロードに時間がかかって諦めた。

28
18
9

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?