【Python】GooglePhotosAPIで写真・動画のバックアップを取得する方法

# はじめに
去年娘が生まれてからスマホで写真や動画を取る頻度がとても増えました。
私も妻もAndroidユーザーなので、自動でGoogle Photoに同期するようにしていて、非常に便利なのですが容量が15GBまでの制限があり、バックアップを取ってから削除していくというのを手動で行っておりました。
何とかならないものかと思い、自動で自宅のNASに写真や動画をバックアップするスクリプトを作りました。

# tl;dr
- AndoridのGooglePhotoへの自動アップロードしておく
- pythonでGoogleのAPIををつかって写真と動画を取得するスクリプトを作成
- ローカルサーバーのcronに仕込んで日次実行させる
- サーバーへの定義とスクリプトのデプロイはAnsible

スクリプトのコードはこちら

https://github.com/tokku5552/google-photo-backup

## 目次

- 環境と構成
- GCPでAPI有効化とOAuth認証設定
- pythonでPhoto Library APIを実行
- ReadyNASでのNFS有効化
- ローカルサーバーへのデプロイ
- 結果

## 環境と構成

- HW
    - 開発PC
        - Microsoft Windows 10 Pro (build 19043)
        - Mac Book Pro BigSur(version 11.5.2)
    - 本番環境
        - NUC (Intel NUC BOXNUC6CAYH)
            - CentOS Linux release 7.9.2009 (Core)
        - NAS (NETGEAR ReadyNAS 214 RN214)
            - RAID 5(3D+1P: 6Gb/s 256MB 5400rpm 4TB*4)
- 言語
    - python 3.6.8
        - google-api-core (2.0.1)
        - google-api-python-client (2.20.0)
        - google-auth (2.0.2)
        - google-auth-httplib2 (0.1.0)
        - google-auth-oauthlib (0.4.6)
        - python-dateutil (2.8.2)
- その他
    - VSCode(別に他のIDEでもいいです)
    - Ansible(Dockerイメージとして構築。なのでWindows/MacにはDockerの実行環境が必要)
   
### 構成
ざっくりとした構成は以下の通りです。

- GCPのコンソールでAPIの設定をしておく
- ローカルPCからNUCに対してAnsibleで定義更新とGitHubからのクローンを行う
- 初回実行は対象のユーザーのPC(AndroidのGoogleアカウントと同じユーザーでログインしているPC)でNUCにssh経由で行い、認証を通しておく
- cronでキックされたらPhoto Libraryで写真と動画のリストを取得し、新しい写真や動画があればdownloadしてNASに保存する

![gpbk構成.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/c5f6a687-1392-27ec-7ab0-efd926460123.png)

# API有効化とOAuth認証の設定
## Photo Library APIの有効化
- [Google API Console](https://console.developers.google.com/apis/library)にアクセス
- プロジェクトを作成していない場合は新規でプロジェクトを作成しておく。今回は**google-photo-backup**としました。
![evidence_004.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/aea790c5-c7bf-7e97-cb49-5abea3a83513.jpeg)

- 上部の検索バーで**Photo Library**を入力して**Photo Library API**をクリックする。
![evidence_010.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/45ee12a7-a7e5-fac8-8262-36b4e3c93570.jpeg)

- Photo Library APIの**有効にする**ボタンをクリックし、有効化しておく。
![evidence_011.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/59293813-7574-0017-1461-24fca493b8f6.jpeg)

## OAuth 2.0 クライアントIDの作成

- 画面左上のメニューから**APIとサービス**を選択して、**認証情報**をクリックする。
**アプリ名**、**ユーザーサポートメール**、**メールアドレス**を入力し、**保存して次へ**をクリックする。
![evidence_015.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/117370f5-3713-5d6b-e3d9-424b6c5897a9.jpeg)

:::note warn
アプリ名に「-」などが入っていると、エラーが発生します。
:::

- メニューから**OAuth同意画面**を選択し、**UserType**を**外部**にして**作成**をクリックする。
![evidence_018.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/620c2837-1076-55f5-85ed-ea9dfa45da15.jpeg)

:::note info
スコープは省略して大丈夫です。
:::

- 画面上部の**認証情報を作成**をクリックし、**OAuth クライアント ID**をクリックする。
![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/71dcf379-7927-69c9-0c59-5341d62e763c.png)

- **アプリケーションの種類**で**デスクトップアプリ**を選択して**作成**をクリックする。
![5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/fc03dadd-dee3-a0e2-7703-4d4d41e4bd19.png)

- 作成された**OAuth 2.0 クライアント ID**の右側にあるダウンロードボタンをクリックし、jsonファイルをダウンロードする。
![evidence_022.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/c1f1e32e-2321-1366-f8ff-632ba3f35425.png)

# pythonでPhoto Library APIを実行

https://github.com/tokku5552/google-photo-backup

先ほどダウンロードしたclient_secret~~.jsonをclient_secret.jsonにリネームして、スクリプトと同じフォルダに配置しておきます。
初回はこちらを使って認証を行い`credential.json`を生成し、次回以降は`credential.json`を用いて認証します。
[こちら](https://kittagon.hateblo.jp/entry/2020/04/03/231848)を参考にしました。

```python
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 getCredentials():
    """
    API接続時にクレデンシャルを取得する。
    初回はclient_secret.jsonをもとに認証を行い
    ユーザー操作の後credential.jsonを生成する
    次回以降はcredential.jsonをもとに認証を行う

    Returns
    ----------
    credential : Any
        クレデンシャル情報
    """
    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, static_discovery=False
    )
```

この`service`を使ってAPIにアクセスします。
`query body`を渡して、`mediaItems = service.mediaItems().search(body=queryBody).execute()`のようにアクセスします。
後々ダウンロードするときに、写真と動画を分ける必要があるので、`mediaMetadata`の中に`photo`があるかないかで判定しています。

https://developers.google.com/photos/library/reference/rest/v1/mediaItems/search

```python:
def getMediaIds(service):
    """
    APIに接続しMediaItemを取得する

    Parameters
    ----------
    service : int
        credentialsから生成したAPI接続用のservice

    Returns
    ----------
    photos : list
        id,filename,url
    videos : list
        id,filename,url
    """
    photos = []
    videos = []
    now = datetime.now()
    logger.debug('datetime.now() : %s', now)
    nextPageTokenMediaItems = ''
    while True:
        queryBody = getQueryBody(nextPageTokenMediaItems, now, QUERY_FILTER)
        mediaItems = service.mediaItems().search(body=queryBody).execute()
        logger.debug('mediaItems length = %s', len(mediaItems))
        if len(mediaItems) == 0:
            logger.info('mediaItems nothing')
            break
        for mediaItem in mediaItems['mediaItems']:
            photo = {}
            video = {}
            if 'photo' in mediaItem['mediaMetadata']:
                photo['id'], photo['filename'], photo['url'] = mediaItem['id'], mediaItem[
                    'filename'], mediaItem['baseUrl']
                photos.append(photo)
            else:
                video['id'], video['filename'], video['url'] = mediaItem['id'], mediaItem[
                    'filename'], mediaItem['baseUrl']
                videos.append(video)
        if 'nextPageToken' in mediaItems:
            nextPageTokenMediaItems = mediaItems['nextPageToken']
        else:
            break
    logger.debug('photos length = %s', len(photos))
    logger.debug('videos length = %s', len(videos))
    return photos, videos
```

`query body`にはフィルターをかけて期間を絞ることが出来ます。
日付でフィルターをかけれるようにしています。フィルターがいらないときは、`pageSize`と`pageToken`のみを渡せば良いです。

```python:
    body = {
        'pageSize': 50,
        "filters": {
            "dateFilter": {
                "ranges": [
                    {
                        "startDate": {
                            "year": startDate.year,
                            "month": startDate.month,
                            "day": startDate.day
                        },
                        "endDate": {
                            "year": referenceDate.year,
                            "month": referenceDate.month,
                            "day": referenceDate.day,
                        },
                    },
                ],
            },
        },
        'pageToken': nextPageTokenMediaItems
    }
```

# ReadyNASでのNFS有効化
我が家にはNETGEARのReadyNASがあるので、こいつにため込むことにしました。
RAID 5で実行容量が12TBあるのでクォータを1TBにしてNFSとしてexportしておきます。
やり方はこちら。

https://tokku-engineer.tech/howto_readynas_nfs_enable/

# ローカルサーバーへのデプロイ
我が家のNUCにはCentOS7をインストールしていて、構成はAnsibleで管理しています。
なので、そのままAnsibleでデプロイまで管理することにしました。
Docker上でAnsible環境を作る方法はこちら。

https://tokku-engineer.tech/build_docker_ansible_devenv/

## 各種設定
google photo backup用にroleを作成しました。
roleのmain.ymlはこんな感じです

```yaml:main.yml
# main.yml
- include: install_require_package.yml
  notify:
    - OS reboot
    - Waiting for server to down
    - Waiting for server to up
- include: setup_nfs_mount.yml
- include: setup_logrotate.yml
- include: deploy_app.yml
- include: setup_app_config.yml
- include: setup_cron_job.yml
```

nfs、logrotate、cron_jobのところは本質的ではないので、それ以外のアプリデプロイに関わる部分をかいつまんで解説します。

### install_require_package.yml
まず必要なパッケージをインストールします。python36をyumでいれるだけです。
iusリポジトリを追加しているのがミソです。

```yaml:install_require_package.yml
# Install require package
- name: install the ius-release-el7 rpm from a remote repo
  become: yes
  yum:
    name: https://repo.ius.io/ius-release-el7.rpm
    state: present

- name: install_require_package(yum)
  become: yes 
  yum: 
    name:
     - python36u 
     - python36u-libs
     - python36u-devel
     - python36u-pip
    state: present


- name: install_require_package(pip with shell confirm)
  become: yes
  shell: pip3 show {{ item }}
  register: pip3_check
  loop:
    - google-auth
    - google_auth_oauthlib
    - google-api-python-client 
    - python-dateutil
  changed_when: no
  failed_when: no

- name: install_require_package(pip with shell install)
  become: yes
  shell: pip3 install {{ item.item }}
  when: "item.rc != 0"
  loop: "{{ pip3_check.results }}"

```

Ansibleには**pip**モジュールが存在するのですが、[公式](https://docs.ansible.com/ansible/2.9_ja/modules/pip_module.html#pip-module)に書いてある方法で`executable: pip3.3`としてもうまく**pip3**を使ってくれなかったので、しかたなく**shell**で入れています。
**shell**モジュールでも冪等性が担保されるように、`install_require_package(pip with shell confirm)`でインストールされているかどうかを確認し、`install_require_package(pip with shell install)`でインストールされていないパッケージのみインストールしています。`changed_when: no`と`failed_when: no`を指定してやることで、ドライランも対応できます。

### deploy_app.yml
リポジトリをpublicにしているので、特に認証等はせずにただクローンしています。
`client_secret.json`はansibleの実行環境側で用意しておいたものを配布しています。
クローンしたソースをjobの数だけユーザー毎に別フォルダに配置しています。
配置はクローンが行われた時にだけ行います。(後で設定ファイル書き換えたりするので)

```yaml:deploy_app.yml
# deploy_app.yml

# クローン用ディレクトリの作成
- name: clone directory making
  become: yes
  file: 
    path: /opt/remote-repo/
    state: directory
    owner: root
    group: root
    mode: 0755

# git clone
- name: git clone from github
  become: yes
  ansible.builtin.git:
    repo: https://github.com/tokku5552/google-photo-backup.git
    dest: /opt/remote-repo/google-photo-backup
  register: clone_result

# client_secret.jsonのファイル配置
- name: distribute client_secret.json
  become: yes
  copy: 
    src: ../files/client_secret.json
    dest: /opt/remote-repo/google-photo-backup/src/client_secret.json
    backup: yes

# アプリケーション用ディレクトリの作成
- name: app distribute directory making
  become: yes
  file: 
    path: /opt/job/{{ item }}/bin
    state: directory
    owner: root
    group: root
    mode: 0755
  loop:
    - userA
    - userB

# srcのdirectory移動
- name : update source
  become: yes
  shell: cp -pr /opt/remote-repo/google-photo-backup/src/* /opt/job/{{ item }}/bin
  loop:
    - userA
    - userB
  when: clone_result.changed

```

### setup_app_config.yml
pythonのスクリプト側で`settings.py`というファイルで可変設定項目を定義しているのですが、そちらをansibleで変更できるようにしています。
もともとGitHubリポジトリ上のsettings.pyがクローンされてくるので、変更したいところだけ変えるようにしています。
replaceモジュールで書き換えているだけです。

```yaml:setup_app_config.yml
# setup_app_config.yml

# settingを強制上書きする場合
- name : update source (if force)
  become: yes
  shell: cp -pr /opt/remote-repo/google-photo-backup/src/* /opt/job/{{ item }}/bin
  loop:
    - userA
    - userB
  when: gpbk_setting_update_force

# setting log filename
- name: setting log filename
  become: yes
  replace:
    dest: /opt/job/{{ item }}/bin/settings.py
    regexp: LOG_FILENAME = '/var/log/google_photos_backup.log'
    replace: LOG_FILENAME = '/var/log/google_photos_backup_{{ item }}.log'
  loop:
    - userA
    - userB
 
# バックアップディレクトリの作成
- name: make backup directory
  become: yes
  file: 
    path: /gpbk/{{ item }}
    state: directory
    owner: nobody
    group: nobody
    mode: 0777
  loop:
    - userA
    - userB

# setting log filename
- name: setting backup directory
  become: yes
  replace:
    dest: /opt/job/{{ item }}/bin/settings.py
    regexp: DESTINATION_DIR = '/gpbk'
    replace: DESTINATION_DIR = '/gpbk/{{ item }}'
  loop:
    - userA
    - userB

# setting query filter
- name: setting query filter
  become: yes
  replace:
    dest: /opt/job/{{ item }}/bin/settings.py
    regexp: QUERY_FILTER = True
    replace: QUERY_FILTER = {{ gpbk_query_filter }}
  loop:
    - userA
    - userB

# setting debug mode
- name: setting debug mode
  become: yes
  replace:
    dest: /opt/job/{{ item }}/bin/settings.py
    regexp: LOGGING_LEVEL = 20  # DEBUG=10,INFO=20
    replace: LOGGING_LEVEL = {{ gpbk_log_level }}  # DEBUG=10,INFO=20
  loop:
    - userA
    - userB
```

各可変項目はgroup_varsで定義しています。

```yaml:group_vars/nuc.yml
gpbk_setting_update_force: False
gpbk_log_level: 10 # DEBUG=10,INFO=20
gpbk_query_filter: False
```

# 結果
ログの一部

```:/var/log/google_photos_backup.log
2021-09-10 04:00:31,295 : [DEBUG] [google_photos_backup.py] mediaItems length = 1
2021-09-10 04:00:31,295 : [DEBUG] [google_photos_backup.py] photos length = 939
2021-09-10 04:00:31,295 : [DEBUG] [google_photos_backup.py] videos length = 33
2021-09-10 04:00:31,296 : [INFO] [google_photos_backup.py] end : get media ids
2021-09-10 04:00:31,296 : [INFO] [google_photos_backup.py] start : remove aquired media ids
2021-09-10 04:00:31,298 : [DEBUG] [google_photos_backup.py] loadIdsJson : aquired media list size = 973
2021-09-10 04:00:31,341 : [DEBUG] [google_photos_backup.py] removeAquiredMedia : result list counts = 0
2021-09-10 04:00:31,344 : [DEBUG] [google_photos_backup.py] removeAquiredMedia : result list counts = 0
2021-09-10 04:00:31,345 : [INFO] [google_photos_backup.py] end : remove aquired media ids
2021-09-10 04:00:31,345 : [INFO] [google_photos_backup.py] start : download media
2021-09-10 04:00:31,345 : [INFO] [google_photos_backup.py] download photos
2021-09-10 04:00:31,345 : [INFO] [google_photos_backup.py] 0 media downloads completed
2021-09-10 04:00:31,345 : [INFO] [google_photos_backup.py] download videos
2021-09-10 04:00:31,346 : [INFO] [google_photos_backup.py] 0 media downloads completed
2021-09-10 04:00:31,346 : [INFO] [google_photos_backup.py] end : download media
2021-09-10 04:00:31,346 : [INFO] [google_photos_backup.py] start : move files
2021-09-10 04:00:31,347 : [INFO] [google_photos_backup.py] done file moving
2021-09-10 04:00:31,347 : [INFO] [google_photos_backup.py] end : move files
2021-09-10 04:00:31,347 : [INFO] [google_photos_backup.py] start : update aquired list json
2021-09-10 04:00:31,347 : [DEBUG] [google_photos_backup.py] aquired_media_list : aquired_list.json
2021-09-10 04:00:31,353 : [INFO] [google_photos_backup.py] end : update aquired list json
2021-09-10 04:00:31,354 : [INFO] [google_photos_backup.py] All Proccess Complete!
```
無事定期的にバックアップがとられるようになりました！
難点として現在は大量に一度に取得できないので、初期同期は何か別の方法を考えて行う必要があります。(私はテストもかねてfilterの期間を少しずつ広げながら実行していきました。aquired_listに一度登録されたら次回以降は取得しなくなるので、一度全データがリストに登録されてしまえばフィルター外しても大丈夫です。)
また、Google Photo APIは削除の処理が提供されていないので、容量を空けるための削除は手動で行う必要があります。
削除処理が実装されて欲しいですね、、、

# 追記(2021/10/24)
- AWS CloudWatchでジョブとサーバーの監視する仕組みを導入しました。

https://zenn.dev/tokku5552/articles/ansible-cloudwatch-local

# 参考
- [【Python】Google Photos API でアルバムの画像取得](https://kittagon.hateblo.jp/entry/2020/04/03/231848)
- [PythonでGoogle Photoを50GBの無料サーバーにバックアップ](https://blog.codecamp.jp/python-google-photo)
- [Method: mediaItems.search &nbsp;|&nbsp; Google Photos APIs &nbsp;|&nbsp; Google Developers](https://developers.google.com/photos/library/reference/rest/v1/mediaItems/search)
- [tokku5552/google-photo-backup: Get a backup of Google Photos](https://github.com/tokku5552/google-photo-backup)
- [Packaging modules &mdash; Ansible Documentation](https://docs.ansible.com/ansible/2.9_ja/modules/list_of_packaging_modules.html)
- [RN214 | ReadyNASストレージ | 製品 | ホーム製品 | NETGEAR](https://www.jp.netgear.com/home/products/connected-storage/rn214.aspx)
- [ReadyNASでのNFS有効化の方法  |  インフラエンジニアがもがくブログ](https://tokku-engineer.tech/howto_readynas_nfs_enable/)
- [DockerでAnsibleの実行環境を作って家のサーバーを管理する  |  インフラエンジニアがもがくブログ](https://tokku-engineer.tech/build_docker_ansible_devenv/)