Google Photos API でアルバムの画像取得

はじめに

我が家ではこどもたちの写真を撮影月ごとにGoogle Photosの共有アルバムにまとめて両親に共有しています。
これらの写真をAPIで取得して、フォトフレームなど使えるといいなーと思っていましたが、以前はGoogle Photos のAPIは整備されておらず、Picasa APIのみでした。
Picasa自体も既にサービス終了しており、APIもあまりメンテナンスされている雰囲気でもなくいつか終わりそうな気がしたので、こっちを使うことは避けてました。)
気が付くと2018年にGoogle Photos APIがリリースされていたので、これを使っての写真取得を試してみたいと思います。
最終的には、年月を指定すると、その月の共有アルバムに含められている写真を取得する、というプログラムにしたいと思います。

公式リファレンスはこちらです

developers.google.com

Credentialを取得

まず初めに、APIを使用するのに必要なCredentialを取得します。

Google Photos APを使うにはOAuthでのユーザー認証が必要になります。
OAuthの認証にはPythonのクライアントライブラリが提供されているのでそちらを使います。

github.com

上記の公式にも下記の記述がある通りGoogle Photos APIでは、Service Account方式は使えないようです。

Note that the Library API does not support service accounts; to use this API, users must be signed in to a valid Google Account.

なので、OAuth 2.0 for Web Server Applicationsか、OAuth 2.0 for Installed Applicationsのどちらかをつかうのですが、今回はWeb Serverを立てるわけではないので、Installed Applicationsタイプで実装しました。

github.com

client_secretの取得

まずはOAuthの認証に必要なclient secretを取得します。 Google API Consoleにアクセス。

console.developers.google.com

f:id:ti_taka:20200403225259p:plain 左上の「APIとサービス」より「認証情報」を選択

f:id:ti_taka:20200403225321p:plain 「+認証情報を作成」をクリック

f:id:ti_taka:20200403225337p:plain

f:id:ti_taka:20200403225355p:plain

f:id:ti_taka:20200403225410p:plain

これでダウンロードよりclient_secret.jsonをダウンロードします。

credentialの取得

ダウンロードしたclient_secret.jsonを読み取り、credentialを取得します。

# google_photos.py
import google.oauth2.credentials
import google_auth_oauthlib.flow

SCOPES = ['https://www.googleapis.com/auth/photoslibrary']
API_SERVICE_NAME = 'photoslibrary'
API_VERSION = 'v1'
CLIENT_SECRET_FILE = 'client_secret.json'

flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE,scopes=SCOPES)        
credentials = flow.run_console()

上記プログラムを実行すると、

$ python google_photos.py
Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?response_type=code&client_id={$cliens_id}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fphotoslibrary&state={$state}&prompt=consent&access_type=offline
Enter the authorization code:

のように、URLが出力されるので、上記URLをブラウザに入力すると以下の画面になるので、Googleアカウントでログインしてください。
ログイン後は、以下のように認可コードが払い出されるので、コピーしてターミナルに貼り付けます。
f:id:ti_taka:20200403225429p:plain

上記でCredentialは取得出来るのですが、その中にはtokenやRefresh tokenも含まれており、その後はRefresh tokenを使用してCredentialを取得することが出来るようになるため、毎回ログインする必要がなくなります。
しかし、現在のgoogle_auth_oauthlibまだcredentialの保存をサポートしていないので、credentialをそのままjsonのファイルで保存して、今後はRefresh Tokenを使用してcredentialを取得するようにします。

github.com

google-auth-oauthlib does not currently have support for credentials storage. It may be added in the future. See oauth2client deprecation for more details.

credentialの中にはdatetimeオブジェクトが入っているので、↓のコードを頂戴してcredentialをシリアライズして保存しておきます。

qiita.com

def support_datetime_default(o):
  if isinstance(o, datetime):
    return o.isoformat()
  raise TypeError(repr(o) + " is not JSON serializable")

with open(CREDENTIAL_FILE, mode='w') as f_credential_w:
    f_credential_w.write(json.dumps(vars(credentials), default=support_datetime_default, sort_keys=True))

こうすることで、一度credentialを取得したあとは、ログインをし直す必要なく、Refresh Tokenでcredentialの取得が出来るようになります。

if os.path.exists(CREDENTIAL_FILE):
  with open(CREDENTIAL_FILE) as f_credential_r:
    credentials_json = json.loads(f_credential_r.read())
    credentials = google.oauth2.credentials.Credentials(
      credentials_json['token'],
      refresh_token=credentials_json['_refresh_token'],
      token_uri=credentials_json['_token_uri'],
      client_id=credentials_json['_client_id'],
      client_secret=credentials_json['_client_secret']
    )

credentialを取得後は以下のようにServiceを取得します。

service = build(
    API_SERVICE_NAME,
    API_VERSION,
    credentials=credentials
)

今後このserviceで各種APIを叩いていきます。
これまでのコードの全体像は以下のようになります。

# google_photos.py
import google.oauth2.credentials
import google_auth_oauthlib.flow
import json,os
from datetime import datetime,date
from googleapiclient.discovery import build

SCOPES = ['https://www.googleapis.com/auth/photoslibrary']
API_SERVICE_NAME = 'photoslibrary'
API_VERSION = 'v1'
CLIENT_SECRET_FILE = 'client_secret.json'
CREDENTIAL_FILE = 'credential.json'

def support_datetime_default(o):
  if isinstance(o, datetime):
    return o.isoformat()
  raise TypeError(repr(o) + " is not JSON serializable")

def getCredentials():
  if os.path.exists(CREDENTIAL_FILE):
    with open(CREDENTIAL_FILE) as f_credential_r:
      credentials_json = json.loads(f_credential_r.read())
      credentials = google.oauth2.credentials.Credentials(
        credentials_json['token'],
        refresh_token=credentials_json['_refresh_token'],
        token_uri=credentials_json['_token_uri'],
        client_id=credentials_json['_client_id'],
        client_secret=credentials_json['_client_secret']
      )
  else:
    flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE,scopes=SCOPES)
    
    credentials = flow.run_console()
  with open(CREDENTIAL_FILE, mode='w') as f_credential_w:
    f_credential_w.write(json.dumps(vars(credentials), default=support_datetime_default, sort_keys=True))
    
  return credentials

def main():
    credentials = getCredentials()
    service = build(
        API_SERVICE_NAME,
        API_VERSION,
        credentials=credentials
    )

if __name__ == "__main__":
    main()

各アルバム内の写真を取得

共有アルバムIDの取得

アルバムIDは以下のように取得できます。

sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()

我が家では前述の通り、子供たちの写真を共有アルバムにて両親に共有しています。
その際に子供の写真については、「yyyymm01〜」の命名規則でアルバム名をつけているので、
該当するアルバム名を取得します。

nextPageToken = ''
albums = {}
while True:
  sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()
  for sharedAlbum in sharedAlbums['sharedAlbums']:
    if 'title' in sharedAlbum:
      m = re.search(r'^\d\d\d\d\d\d01', sharedAlbum['title'])
      if m:
        month = m.group()
        if not (month in albums):
          albums[m.group()] = []
        albums[m.group()].append(sharedAlbum['id'])
  if 'nextPageToken' in sharedAlbums:
    nextPageToken = sharedAlbums['nextPageToken']
  else:
    break

その際に子供の写真については、「yyyymm01〜」の命名規則でアルバム名をつけているので、

この運用を始めてからは基本的に一月に1共有アルバムになったのですが、
この運用ルールで実施できていなかった頃に、一月の間に複数アルバムを作ってしまっているので、
以下のように複数取得出来るようにしています。

if not (month in albums):
  albums[m.group()] = []
albums[m.group()].append(sharedAlbum['id'])

また最終的には、年月を指定すると、その月の写真をとってくる、というプログラムにしたいので、毎回共有アルバムのIDを取得するのは面倒なので、
予め子供の写真を登録している共有アルバムのリストを取得してファイルに保存しておき、
写真を取得するときは、指定した年月の共有アルバムIDリストのファイルから読み込む、という動きにしました。

MONTH_ALBUMS_FILE = 'month_albums.json'

def updateAlbumList(service):
  nextPageToken = ''
  albums = {}
  while True:
    sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()
    for sharedAlbum in sharedAlbums['sharedAlbums']:
      if 'title' in sharedAlbum:
        m = re.search(r'^\d\d\d\d\d\d01', sharedAlbum['title'])
        if m:
          month = m.group()
          if not (month in albums):
            albums[m.group()] = []
          monthAlbums[m.group()].append(sharedAlbum['id'])
    if 'nextPageToken' in sharedAlbums:
      nextPageToken = sharedAlbums['nextPageToken']
    else:
      break
  with open(MONTH_ALBUMS_FILE, mode='w') as f_month_albums_w:
    f_month_albums_w.write(json.dumps(albums, default=support_datetime_default, sort_keys=True))

def getAlbumIds(months):
  albumIds = []
  with open(MONTH_ALBUMS_FILE) as f_month_album_r:
    monthAlbumJson = json.loads(f_month_album_r.read())
    
  for month in months:
    monthAlbumIds = monthAlbumJson[month]
    for monthAlbumId in monthAlbumIds:
      albumIds.append(monthAlbumId)

  return albumIds

共有アルバムのIDを取得してMONTH_ALBUMS_FILEというファイルに保存しておくupdateAlbumListという関数と、
年月をyyyymm01形式で渡すと共有アルバムのIDを返却するgetAlbumIdsという関数とで分けました。

共有アルバム内の写真を取得

いよいよ写真の取得ですが、写真の取得は

developers.google.com

mediaItems.searchというIFで取得ができます。 リクエストbodyのJSONの中にアルバムIDを含めてリクエストすると、そのアルバム内の写真を取得できます。

以下ように取得できます。

body = {
  'albumId' : albumId,
  'pageSize' : 50,
}
mediaItems = service.mediaItems().search(body=body).execute()

アルバムIDの配列を渡されたら、そのアルバムID内の写真を取得するような関数にしました。

def getPhotos(service, albumIds):
  photos = []
  for albumId in albumIds:
    nextPageTokenMediaItems = ''
    while True:
      photo = {}
      body = {
        'albumId' : albumId,
        'pageSize' : 50,
        'pageToken' : nextPageTokenMediaItems
      }
      mediaItems = service.mediaItems().search(body=body).execute()
      # print(mediaItems)
      for mediaItem in mediaItems['mediaItems']:
        photo['id'],photo['url'] = mediaItem['id'],mediaItem['baseUrl']
        photos.append(photo)
      if 'nextPageToken' in mediaItems:
        nextPageTokenMediaItems = mediaItems['nextPageToken']
      else:
        break
  print(photos)

最終的な全体の実装は以下の通りです。

from googleapiclient.discovery import build
import google.oauth2.credentials
import google_auth_oauthlib.flow
import json,os,re
from datetime import datetime,date

SCOPES = ['https://www.googleapis.com/auth/photoslibrary']
API_SERVICE_NAME = 'photoslibrary'
API_VERSION = 'v1'
CLIENT_SECRET_FILE = 'client_secret.json'
REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
CREDENTIAL_FILE = 'credential.json'
MONTH_ALBUMS_FILE = 'month_albums.json'

def support_datetime_default(o):
  if isinstance(o, datetime):
    return o.isoformat()
  raise TypeError(repr(o) + " is not JSON serializable")

def getCredentials():
  if os.path.exists(CREDENTIAL_FILE):
    with open(CREDENTIAL_FILE) as f_credential_r:
      credentials_json = json.loads(f_credential_r.read())
      credentials = google.oauth2.credentials.Credentials(
        credentials_json['token'],
        refresh_token=credentials_json['_refresh_token'],
        token_uri=credentials_json['_token_uri'],
        client_id=credentials_json['_client_id'],
        client_secret=credentials_json['_client_secret']
      )
  else:
    flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE,scopes=SCOPES)
    
    credentials = flow.run_console()
  with open(CREDENTIAL_FILE, mode='w') as f_credential_w:
    f_credential_w.write(json.dumps(vars(credentials), default=support_datetime_default, sort_keys=True))
    
  return credentials

def updateAlbumList(service):
  nextPageToken = ''
  albums = {}
  while True:
    sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()
    for sharedAlbum in sharedAlbums['sharedAlbums']:
      if 'title' in sharedAlbum:
        m = re.search(r'^\d\d\d\d\d\d01', sharedAlbum['title'])
        if m:
          month = m.group()
          if not (month in albums):
            albums[m.group()] = []
          albums[m.group()].append(sharedAlbum['id'])
    if 'nextPageToken' in sharedAlbums:
      nextPageToken = sharedAlbums['nextPageToken']
    else:
      break
  with open(MONTH_ALBUMS_FILE, mode='w') as f_month_albums_w:
    f_month_albums_w.write(json.dumps(albums, default=support_datetime_default, sort_keys=True))

def getPhotos(service, albumIds):
  photos = []
  for albumId in albumIds:
    nextPageTokenMediaItems = ''
    while True:
      photo = {}
      body = {
        'albumId' : albumId,
        'pageSize' : 50,
        'pageToken' : nextPageTokenMediaItems
      }
      mediaItems = service.mediaItems().search(body=body).execute()
      # print(mediaItems)
      for mediaItem in mediaItems['mediaItems']:
        photo['id'],photo['url'] = mediaItem['id'],mediaItem['baseUrl']
        photos.append(photo)
      if 'nextPageToken' in mediaItems:
        nextPageTokenMediaItems = mediaItems['nextPageToken']
      else:
        break
  print(photos)

def getAlbumIds(months):
  albumIds = []
  with open(MONTH_ALBUMS_FILE) as f_month_album_r:
    monthAlbumJson = json.loads(f_month_album_r.read())
    
  for month in months:
    monthAlbumIds = monthAlbumJson[month]
    for monthAlbumId in monthAlbumIds:
      albumIds.append(monthAlbumId)

  return albumIds

def main():
  credentials = getCredentials()
  service = build(
    API_SERVICE_NAME,
    API_VERSION,
    credentials=credentials
  )
  updateAlbumList(service)  # ここは必要に応じて実施するように後々処理を分けるつもり
  months = ['20190101','20190201']
  albumIds = getAlbumIds(months)
  getPhotos(service, albumIds)
    
if __name__ == "__main__":
  main()

まとめ

Pythonを使って、Google Photos APIを使ってみました。
共有アルバムから写真は取得出来るようになったので、今度はAlexaと連携して、声で指示した写真を取得出来るようにしたいと思います。