15
14

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 3 years have passed since last update.

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

Last updated at Posted at 2021-09-11

はじめに

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

tl;dr

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

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

目次

  • 環境と構成
  • 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

API有効化とOAuth認証の設定

Photo Library APIの有効化

  • Google API Consoleにアクセス

  • プロジェクトを作成していない場合は新規でプロジェクトを作成しておく。今回はgoogle-photo-backupとしました。
    evidence_004.JPG

  • 上部の検索バーでPhoto Libraryを入力してPhoto Library APIをクリックする。
    evidence_010.JPG

  • Photo Library APIの有効にするボタンをクリックし、有効化しておく。
    evidence_011.JPG

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

  • 画面左上のメニューからAPIとサービスを選択して、認証情報をクリックする。
    アプリ名ユーザーサポートメールメールアドレスを入力し、保存して次へをクリックする。
    evidence_015.JPG

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

  • メニューからOAuth同意画面を選択し、UserType外部にして作成をクリックする。
    evidence_018.JPG

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

  • 画面上部の認証情報を作成をクリックし、OAuth クライアント IDをクリックする。
    4.png

  • アプリケーションの種類デスクトップアプリを選択して作成をクリックする。
    5.png

  • 作成されたOAuth 2.0 クライアント IDの右側にあるダウンロードボタンをクリックし、jsonファイルをダウンロードする。
    evidence_022.png

pythonでPhoto Library APIを実行

先ほどダウンロードしたclient_secret~~.jsonをclient_secret.jsonにリネームして、スクリプトと同じフォルダに配置しておきます。
初回はこちらを使って認証を行いcredential.jsonを生成し、次回以降はcredential.jsonを用いて認証します。
こちらを参考にしました。

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があるかないかで判定しています。

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にはフィルターをかけて期間を絞ることが出来ます。
日付でフィルターをかけれるようにしています。フィルターがいらないときは、pageSizepageTokenのみを渡せば良いです。

    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しておきます。
やり方はこちら。

ローカルサーバーへのデプロイ

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

各種設定

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

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リポジトリを追加しているのがミソです。

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モジュールが存在するのですが、公式に書いてある方法でexecutable: pip3.3としてもうまくpip3を使ってくれなかったので、しかたなくshellで入れています。
shellモジュールでも冪等性が担保されるように、install_require_package(pip with shell confirm)でインストールされているかどうかを確認し、install_require_package(pip with shell install)でインストールされていないパッケージのみインストールしています。changed_when: nofailed_when: noを指定してやることで、ドライランも対応できます。

deploy_app.yml

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

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モジュールで書き換えているだけです。

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で定義しています。

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でジョブとサーバーの監視する仕組みを導入しました。

参考

15
14
0

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
15
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?