はじめに
去年娘が生まれてからスマホで写真や動画を取る頻度がとても増えました。
私も妻も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)
- NUC (Intel NUC BOXNUC6CAYH)
- 開発PC
- 言語
- 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)
- python 3.6.8
- その他
- VSCode(別に他のIDEでもいいです)
- Ansible(Dockerイメージとして構築。なのでWindows/MacにはDockerの実行環境が必要)
構成
ざっくりとした構成は以下の通りです。
- GCPのコンソールでAPIの設定をしておく
- ローカルPCからNUCに対してAnsibleで定義更新とGitHubからのクローンを行う
- 初回実行は対象のユーザーのPC(AndroidのGoogleアカウントと同じユーザーでログインしているPC)でNUCにssh経由で行い、認証を通しておく
- cronでキックされたらPhoto Libraryで写真と動画のリストを取得し、新しい写真や動画があればdownloadしてNASに保存する
API有効化とOAuth認証の設定
Photo Library APIの有効化
-
Google API Consoleにアクセス
-
プロジェクトを作成していない場合は新規でプロジェクトを作成しておく。今回はgoogle-photo-backupとしました。
OAuth 2.0 クライアントIDの作成
アプリ名に「-」などが入っていると、エラーが発生します。
スコープは省略して大丈夫です。
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
にはフィルターをかけて期間を絞ることが出来ます。
日付でフィルターをかけれるようにしています。フィルターがいらないときは、pageSize
とpageToken
のみを渡せば良いです。
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
- 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
- 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: no
とfailed_when: no
を指定してやることで、ドライランも対応できます。
deploy_app.yml
リポジトリをpublicにしているので、特に認証等はせずにただクローンしています。
client_secret.json
はansibleの実行環境側で用意しておいたものを配布しています。
クローンしたソースをjobの数だけユーザー毎に別フォルダに配置しています。
配置はクローンが行われた時にだけ行います。(後で設定ファイル書き換えたりするので)
# 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
# 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で定義しています。
gpbk_setting_update_force: False
gpbk_log_level: 10 # DEBUG=10,INFO=20
gpbk_query_filter: False
結果
ログの一部
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でジョブとサーバーの監視する仕組みを導入しました。
参考
- 【Python】Google Photos API でアルバムの画像取得
- PythonでGoogle Photoを50GBの無料サーバーにバックアップ
- Method: mediaItems.search | Google Photos APIs | Google Developers
- tokku5552/google-photo-backup: Get a backup of Google Photos
- Packaging modules — Ansible Documentation
- RN214 | ReadyNASストレージ | 製品 | ホーム製品 | NETGEAR
- ReadyNASでのNFS有効化の方法 | インフラエンジニアがもがくブログ
- DockerでAnsibleの実行環境を作って家のサーバーを管理する | インフラエンジニアがもがくブログ