GeekFactory

int128.hatenablog.com

Googleフォトに写真をアップロードするツールを作った

Googleフォトに写真をアップロードするコマンドラインツールを作りました。

github.com

このツールは先月に公開されたGoogle Photos Library APIを利用しています。

Getting Started

APIにアクセスできるように初期設定が必要です。

  1. https://console.cloud.google.com/apis/library/photoslibrary.googleapis.com/ を開く。
  2. 「Photos Library API」を有効にする。
  3. https://console.cloud.google.com/apis/credentials を開く。
  4. 新しい「OAuth client ID」を作成する。application typeはotherを選ぶ。
  5. Client IDとClient Secretが発行されるので、以下の環境変数を設定する。
export GOOGLE_CLIENT_ID=
export GOOGLE_CLIENT_SECRET=

releasesから gpup をダウンロードします。

試しに my-photos フォルダをアップロードしてみましょう。

$ gpup my-photos/
2018/06/14 10:28:40 The following 2 files will be uploaded:
  1: travel.jpg
  2: lunch.jpg
2018/06/14 10:28:40 Open http://localhost:8000 for authorization
2018/06/14 10:28:43 GET /
2018/06/14 10:28:49 GET /?state=...&code=...
2018/06/14 10:28:49 Storing token cache to /home/user/.gpup_token
2018/06/14 10:28:49 Queued 2 file(s)
2018/06/14 10:28:49 Uploading travel.jpg
2018/06/14 10:28:49 Uploading lunch.jpg
2018/06/14 10:28:52 Adding 2 file(s) to the library

初回はAPIへのアクセスを許可するためにブラウザで http://localhost:8000 を開く必要があります。Googleアカウントで認証すればOKです。2回目からは ~/.gpup_token にキャッシュされた情報が使われます。

以下のように -n オプションを付けると、新しいアルバムにファイルをアップロードできます。

gpup -n "My Album" my-photos/

以下のオプションが使えます。

Usage:
  gpup [OPTIONS] FILE or DIRECTORY...

Setup:
1. Open https://console.cloud.google.com/apis/library/photoslibrary.googleapis.com/
2. Enable Photos Library API.
3. Open https://console.cloud.google.com/apis/credentials
4. Create an OAuth client ID where the application type is other.
5. Export GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET variables or set the options.

Application Options:
  -n, --new-album=TITLE               Create an album and add files into it
      --oauth-method=[browser|cli]    OAuth authorization method (default: browser)
      --google-client-id=             Google API client ID [$GOOGLE_CLIENT_ID]
      --google-client-secret=         Google API client secret [$GOOGLE_CLIENT_SECRET]

Help Options:
  -h, --help                          Show this help message

今のところ以下の制約事項があります。

  • アップロードしたファイルの並び順を指定できない。連番のファイルをアップロードしても、アルバム上の並び順はぐちゃぐちゃになってしまう。
  • 写真にEXIFヘッダがない場合はタイムスタンプが現在日時になってしまう。

How it works

このツールはGoで書いています。

APIクライアント

Photos Library APIのクライアントは Google APIs Client Library for Go を利用しています。これはAPI仕様から自動生成されたHTTPクライアントで、だいたいのAPIが網羅されています。今のところPhotos Library APIに特化したクライアントが公開されていないので、自動生成されたクライアントを使っています。

ただし、写真や動画などのバイナリをアップロードするAPIGoogle APIs Client Library for Goで用意されていないため、以下のように独自に実装しています。

const apiVersion = "v1"
const basePath = "https://photoslibrary.googleapis.com/"

func (p *Photos) UploadFile(ctx context.Context, filepath string) (*photoslibrary.NewMediaItem, error) {
    //...snip...
    r, err := os.Open(filepath)
    if err != nil {
        return nil, fmt.Errorf("Could not open file %s: %s", filepath, err)
    }
    defer r.Close()

    req, err := http.NewRequest("POST", fmt.Sprintf("%s%s/uploads", basePath, apiVersion), r)
    if err != nil {
        return nil, fmt.Errorf("Could not create a request for uploading file %s: %s", filepath, err)
    }
    req.Header.Add("X-Goog-Upload-File-Name", filename)

    p.log.Printf("Uploading %s", filepath)
    res, err := p.client.Do(req)
    if err != nil {
        p.log.Printf("Error while uploading %s: %s", filepath, err)
        continue
    }
    //...snip...
}

アップロードを逐次実行すると時間がかかるため、並列実行するようにしています。

Photos Library APIガイドラインではExponential backoffアルゴリズムでリトライを行うことと記載されています。さらに、HTTPレスポンスのステータスコードが500番台の場合とネットワークエラーの場合はリトライが可能と明記されています。本ツールでは、 lestrrat-go/backoff を利用し、以下のようにリトライ処理を実装しています。

func (p *Photos) UploadFile(ctx context.Context, filepath string) (*photoslibrary.NewMediaItem, error) {
    b, cancel := uploadRetryPolicy.Start(ctx)
    defer cancel()
    for backoff.Continue(b) {
        //...snip...
        switch {
        case res.StatusCode == 200:
            return // 正常系の戻り値
        case IsRetryableStatusCode(res.StatusCode):
            p.log.Printf("Error while uploading %s: status %d: %s", filepath, res.StatusCode, body)
        default:
            return nil, fmt.Errorf("Could not upload %s: status %d: %s", filepath, res.StatusCode, body)
        }
    }
}

// IsRetryableStatusCode returns true if the status code is retryable,
// such as status code is 5xx or network error occurs.
// Otherwise returns false.
// See https://developers.google.com/photos/library/guides/best-practices#retrying-failed-requests
func IsRetryableStatusCode(code int) bool {
    return code >= 500 && code <= 599
}

OAuth

OAuthの認可コードフローはブラウザで完結するように設計しています。コマンドラインツールで認可コードフローを採用する場合、一般的な実装ではユーザに認可コードを貼り付けてもらう必要があります。具体的には、以下のような流れが必要です。

  1. ターミナルでコマンドを実行する。
  2. ターミナルに表示されたURLを開く。
  3. ブラウザに表示されたGoogleの認可画面でアクセスを許可する。
  4. ブラウザに表示された認可コードをターミナルに貼り付ける。
  5. コマンドがAPIにアクセスする。

本ツールでは4の手間を省略し、3が完了したらすぐに5に進むように実装しています。

これは localhost:8000 でHTTPサーバを立ち上げて、Googleの認可画面から localhost:8000 にリダイレクトバックすることで実現しています。

まとめ

Photos Library APIを利用してGoogleフォトに写真をアップロードするコマンドラインツールを作りました。

Google Photos Library APIで写真をアップロードする

Google Photos Library APIが使えるようになったので、写真のアップロードを試してみました。

アクセストークンの取得

https://console.cloud.google.com/apis/credentials から新しいOAuth Clientを追加します。Client IDとClient Secretが表示されるのでメモしておきます。

アクセストークンを取得します。まず、ブラウザで以下のURLを開きます。YOUR_CLIENT_ID はClient IDに置き換えてください(以下同じです)。

https://accounts.google.com/o/oauth2/v2/auth?client_id=YOUR_CLIENT_ID.apps.googleusercontent.com&response_type=code&scope=https://www.googleapis.com/auth/photoslibrary&redirect_uri=urn:ietf:wg:oauth:2.0:oob

すると、ブラウザ上に認可コードが表示されます。以下のコマンドを実行します。

curl -X POST --data 'code=YOUR_AUTH_CODE&client_id=YOUR_CLIENT_ID.apps.googleusercontent.com&client_secret=YOUR_CLIENT_SECRET&redirect_uri=urn:ietf:wg:oauth:2.0:oob&grant_type=authorization_code' https://www.googleapis.com/oauth2/v4/token

アクセストークンを含むJSONが返されるのでメモしておきます。

{
 "access_token": "YOUR_ACCESS_TOKEN",
 "token_type": "Bearer",
 "expires_in": 3600,
 "refresh_token": "..."
}

写真のアップロード

ここからは Upload media にしたがって進みます。

まず、画像ファイルをアップロードします。

curl -v --upload-file YOUR_IMAGE.png -X POST -H 'Content-type: application/octet-stream' -H 'X-Goog-Upload-File-Name: YOUR_IMAGE.png' -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' https://photoslibrary.googleapis.com/v1/uploads

アップロードに成功するとトークンが返されるのでメモしておきます。

< HTTP/2 200
< x-guploader-uploadid: ...
< content-type: text/plain
< content-length: 510
< date: Wed, 06 Jun 2018 14:11:39 GMT
< server: UploadServer
< alt-svc: quic=":443"; ma=2592000; v="43,42,41,39,35"
<
UPLOAD_TOKEN

次に、アップロードした写真を自分のライブラリに追加します。

curl -v --data '{"newMediaItems":[{"simpleMediaItem":{"uploadToken":"UPLOAD_TOKEN"}}]}' -H 'Content-type: application/json' -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' "https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate"

ライブラリへの追加が成功すると以下のようなレスポンスが返されます。

< HTTP/2 200
< content-type: application/json; charset=UTF-8
< vary: X-Origin
< vary: Referer
< vary: Origin,Accept-Encoding
< date: Wed, 06 Jun 2018 14:14:27 GMT
< server: ESF
< cache-control: private
< x-xss-protection: 1; mode=block
< x-frame-options: SAMEORIGIN
< x-content-type-options: nosniff
< alt-svc: quic=":443"; ma=2592000; v="43,42,41,39,35"
< accept-ranges: none
<
{
  "newMediaItemResults": [
    {
      "uploadToken": "UPLOAD_TOKEN",
      "status": {
        "message": "OK"
      },
      "mediaItem": {
        "id": "...",
        "productUrl": "https://photos.google.com/lr/photo/...",
        "mimeType": "image/png",
        "mediaMetadata": {
          "creationTime": "2018-06-06T14:14:25Z",
          "width": "2462",
          "height": "1040"
        }
      }
    }
  ]
}

Googleフォトを開いてみましょう。新しい写真が追加されていれば成功です。

重複する写真のアップロード

ここで同じ写真を再度アップロードしてみます。

curl -v --upload-file YOUR_IMAGE.png -X POST -H 'Content-type: application/octet-stream' -H 'X-Goog-Upload-File-Name: YOUR_IMAGE..png' -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' https://photoslibrary.googleapis.com/v1/uploads

先ほどとは異なるトークンが返されました。このトークンを自分のライブラリに追加してみます。

curl -v --data '{"newMediaItems":[{"simpleMediaItem":{"uploadToken":"UPLOAD_TOKEN"}}]}' -H 'Content-type: application/json' -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' "https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate"

すると、不思議なことに先ほどと同じレスポンスが返されました。mediaItemidが同じ値になっています。

Googleフォトを開いてみましょう。やはり画像が重複することはなく、1枚だけ表示されているはずです。

ドキュメントによると同じ写真をアップロードした場合はトークンが発行されないはずですが、実際には一連のAPIは成功するが画像は重複しない振る舞いになるようです。冪等になるように工夫されているのですね。

See Also

Debian and Kubernetes optimized Kernel in kops

kopsが利用するAMIについて調べたのでまとめておく。

kopsでサポートされているOSは https://github.com/kubernetes/kops/blob/master/docs/images.md に書かれている。デフォルトではDebianが選択される。他にCoreOS、UbuntuCentOSRHELがサポートされているが、現実的にはDebianかCoreOSを選択することになると思う。ドキュメントやGitHub Issuesを見る限り、Debianが最もよくテストされているようだ。

kopsの構成でAMIを指定しない場合、 stable channel で定義されているAMIが選択される。現時点では、Kubernetes 1.9の場合は kope.io/k8s-1.8-debian-jessie-amd64-hvm-ebs-2018-02-08 というAMIが選択される。

spec:
  images:
    - name: kope.io/k8s-1.8-debian-jessie-amd64-hvm-ebs-2018-02-08
      providerID: aws
      kubernetesVersion: ">=1.9.0 <1.10.0"

このAMIはDebian 8(Jessie)がベースになっている。具体的な構成はYAML形式のテンプレートで定義されている。詳しくはテンプレートを読むと理解できるが、必要なパッケージのインストールや設定ファイルの変更が記述されている。Dockerのインストールもここで行われる。途中で apt-get dist-upgrade コマンドが実行されているので、ビルド時点で最新のJessieが生成されるようだ。

このAMIではDebianの標準カーネルではなくKubernetesに最適化されたカーネルが使われている。先ほどのAMIには 4.4.115-k8s というバージョンのカーネルが組み込まれている。カーネルの具体的な設定はconfigファイルで定義されている。

AMIのビルドおよびリリースにはImageBuilderが利用されている。カーネルのビルドおよびリリースにはMakefileが利用されている。いずれも継続的にリリースできるように工夫されている。

なお、実際の環境で使われているDebianカーネルのバージョンを調べるにはSSHするのが手っ取り早い。先ほどのAMIでは以下のバージョンが使われている。

$ uname -a
Linux hostname 4.4.115-k8s #1 SMP Thu Feb 8 15:37:40 UTC 2018 x86_64 GNU/Linux

$ cat /etc/debian_version
8.10