Tuesday, January 21, 2020

A Google Photos API Command Line Toolkit

My usual post-trip photo workflow is this: (1) For every day of the trip, create an "All Photos" album, and add the appropriate day's photos to that album. (2) For every day of the trip, create a "Blog Photos" album. (3) Review each day's All Photos, copying the best pics into the Blog Photos. (4) For each day's blog post, include the contents of the "Blog Photos" album. Creating the 'All' and 'Blog' albums isn't hard, but it is tedious. The programmer in me doesn't do tedious.

A while back, I realized that if Google had a Photos API, I could script much of the above process. I could effortlessly create and copy each day's photos into the appropriate 'All' album and setup empty 'Blog' albums ready to fill. Alas, when I reviewed the available API I couldn't see any way to create a new album. My brilliant hack would have to wait.

I recently revisited this challenge, and to my surprise it appears Google has filled out its Photos API nicely. Most importantly, you can now create albums. I was psyched!

Using my youtube_tool as inspiration, I was able to throw together gphoto_auth and gphoto_tools. The former authenticates a command line session against your Google Photos account. The latter is the beginnings of a tool for working with the Google Photos API. Currently, gphoto_tools is limited: you can get a list of albums, as well as search for photos. To build out the above workflow, I'm going to need to add the ability to create albums and properly handle the nextPageToken. Still, even in its simple form, gphoto_tools is showing promise.

Here's a sample session:


# Setup a command line auth context
$ gphoto_auth init
Visit:
https://accounts.google.com/o/oauth2/auth?client_id=278279054954-h2du6fu5qtk9jhh2euqn0o7kdesg819u.apps.googleusercontent.com&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=https://www.googleapis.com/auth/photoslibrary.sharing https://www.googleapis.com/auth/photoslibrary&response_type=code
Code? #########################

# List out albums
$ gphoto_tools -a albums
AHnaBgvFACY2NHSg9kiIJ-tDfTGab2vOCa8aWeKmJovY0F-JhHbEL_0nuvII2w_wAJ7IT0Yk7IzP|Snap Circuits
AHnaBguW1U9UKnD5bzLS9ktWlxfgtOtyfCkSq0k4O2C0jurETHIJF4gXC2FsR3NkuiOCFN6fAhha|New Year's Day Hike 2020
...

# 'Search' for the images within a given album (by album ID)
$ gphoto_tools -a search -q "{'albumId' : 'AHnaBguW1U9UKnD5bzLS9ktWlxfgtOtyfCkSq0k4O2C0jurETHIJF4gXC2FsR3NkuiOCFN6fAhha' }"
AHnaBgtpDZNj7odMrv7gquGGcVV02k6ORDnzu5yBSEmWDdCYbvjjp49DfBFOa5TQzLJOrGOXO1xFPe22LChRUiaU3Bk_QnqOCw|20200101_130629.jpg
AHnaBguVuD5WJYJ8pO67lC2PL7QtS-JZ1nEJsti6S_-fD6S9Tn7TIj_t_NKLVAKaU-3awI3S8Oj4aiJxMBROXe2ESHLF47VEzw|20200101_132036.jpg
...

# -v spits out raw json, you can use .jq to work with
$ gphoto_tools -v -a search -q "{'albumId' : 'AHnaBguW1U9UKnD5bzLS9ktWlxfgtOtyfCkSq0k4O2C0jurETHIJF4gXC2FsR3NkuiOCFN6fAhha' }"  | \
 jq '.mediaItems[0]'
{
  "id": "AHnaBgtpDZNj7odMrv7gquGGcVV02k6ORDnzu5yBSEmWDdCYbvjjp49DfBFOa5TQzLJOrGOXO1xFPe22LChRUiaU3Bk_QnqOCw",
  "productUrl": "https://photos.google.com/lr/album/AHnaBguW1U9UKnD5bzLS9ktWlxfgtOtyfCkSq0k4O2C0jurETHIJF4gXC2FsR3NkuiOCFN6fAhha/photo/AHnaBgtpDZNj7odMrv7gquGGcVV02k6ORDnzu5yBSEmWDdCYbvjjp49DfBFOa5TQzLJOrGOXO1xFPe22LChRUiaU3Bk_QnqOCw",
  "baseUrl": "https://lh3.googleusercontent.com/lr/AGWb-e7_6Jmsl-REKgGEscC16xW5soNNF9TqcZbXcbpr9KQityWvGpk0H3gcgU-Sak5-qIqAmeDPNvLyZqVP0Y-y4mNGzQZHPmjNhpFQcEtSqHlas-9pJPNiuUWkKWp-4wovV-tfi8j3elDDJ7YmJLNrnTWtYkCEE-2rUrQlIS6oQdusTrQU9ZqlKilKK8Yxl3iavxU1uOoH1DBK7_a3LEV1K5ZzpqQh4cNClZ5e4ONf2j3NwUBmZU1-TM5gVPu-ymiCyWBImxa0E46c7FbeKduvueVm93j2Yg5ISigVeDxEyFX8Gz3GLMhgieA1CeqEYsrAoBJhGFZHuWN1haMWFIHuwIgsNiE-mDJ67lwICUf-_ldX84LDx_6q_RlkQkw3jKM6bTb4zmOtJ4iWZFTuuj2iGKch7P7vSF_f4b_gSYDjmVkRgDMBfANUEG5hiVp0bd086h3ZCFhzu79kY72eKIe_DEHUwe-Ykg9TvNBHD8xa-0TdbxplhB7tj5DA2skUVl4TdMiBeQp0nGyni7sVKow1J_JXVlHfQOAzoam0RsRADgd1Ki2MihrF1w5hL2JaEPJXY8aeUYHsZPYnC5WWVqO0KK-WVDwGztwQ7Qnh7BF0K8tvgAnGCUOGOAo2pk790eendwfcYeKFlwousUP-pJLFI-R7xiNzteC3U4e-U3HABLcrdAFmPyNYBkreP82qG7kpR3TsbCB0e3jDVE-_k50gyakxhqliy7agPTzVoNieI9acwTcEQoRzteuOBwUZqVkLMq8V5lKlku4ZgSgVkwlVzV-UzGE3C13WMzOooaxtKRZ_iu9TZBGC6ik",
  "mimeType": "image/jpeg",
  "mediaMetadata": {
    "creationTime": "2020-01-01T18:06:25Z",
    "width": "3264",
    "height": "2448",
    "photo": {
      "cameraMake": "samsung",
      "cameraModel": "SM-G965U",
      "focalLength": 2.92,
      "apertureFNumber": 1.7,
      "isoEquivalent": 40
    }
  },
  "filename": "20200101_130629.jpg"
}

# Process the raw json to get at image metadata
$ gphoto_tools -v -a search -q "{'albumId' : 'AHnaBguW1U9UKnD5bzLS9ktWlxfgtOtyfCkSq0k4O2C0jurETHIJF4gXC2FsR3NkuiOCFN6fAhha' }" | \
  jq -r '.mediaItems[] | .filename + ":" + .mediaMetadata.photo.cameraModel'
20200101_130629.jpg:SM-G965U
20200101_132036.jpg:SM-G965U

# Search your images by date range. Clunky, but powerful.
$ gphoto_tools -a search -q "{'filters': {'dateFilter' : { 'ranges' : [ {'startDate': { 'year':2018, 'month':4, 'day':1}, 'endDate': {'year': 2018, 'month':4, 'day':7} } ] } } }"
AHnaBgs8QzksmxLn9bGvkG0IrQaTJwm4uS1ViuljkbB35OeRGE8R9zrOp4AEn7a57QJ9xEUXZTC2hCUZ7ptzYs6GcUgd6af_6w|20180405_175606.jpg
AHnaBgshr_njKJygznaBLZSbAoaD-d_WYfifvzdHHninNWArHfrRqqnm0LGZ3_n0pt-Bl1OnWxoHHOkcRmKHIf5uEJekMbrJjw|20180405_102015.jpg
...

# The -u flag causes URLs to be returned instead of IDs
$   gphoto_tools -u -a search -q "{'filters': {'dateFilter' : { 'ranges' : [ {'startDate': { 'year':2018, 'month':4, 'day':1}, 'endDate': {'year': 2018, 'month':4, 'day':7} } ] } } }"
https://photos.google.com/lr/photo/AHnaBgs8QzksmxLn9bGvkG0IrQaTJwm4uS1ViuljkbB35OeRGE8R9zrOp4AEn7a57QJ9xEUXZTC2hCUZ7ptzYs6GcUgd6af_6w|20180405_175606.jpg
https://photos.google.com/lr/photo/AHnaBgshr_njKJygznaBLZSbAoaD-d_WYfifvzdHHninNWArHfrRqqnm0LGZ3_n0pt-Bl1OnWxoHHOkcRmKHIf5uEJekMbrJjw|20180405_102015.jpg
...

I'm the fence as to whether I should make a simplified querying interface, say '-r' for date range. Or, whether it's best to leave the filter as plain JSON that corresponds to the API docs. The former makes the tool easier to use, while the latter maximizes flexibility. Time will tell which is the best route to go.

Here's both scripts:

gphoto_auth

#!/bin/bash

##
## Authenticate with Google Photos API
##
USAGE="`basename $0` {auth|refresh|token} ctx"
CTX_DIR=$HOME/.gphotos_auth
CLIENT_ID=XXXXXXXXXXXXXXXXXXXX
CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXX
SCOPE='https://www.googleapis.com/auth/photoslibrary.sharing https://www.googleapis.com/auth/photoslibrary'
ctx=default

function usage {
  echo "Usage: `basename $0` [-h] [-c context] {init|token}"
  exit
}

function age {
  if [ `uname` = 'Darwin' ] ; then
    modified=`stat -f "%a" $1`
  else
    modified=`stat -c %X $1`
  fi
  now=`date +%s`
  expr $now - $modified
}

function refresh {
  refresh_token=`cat $CTX_DIR/$ctx.refresh_token`
  curl -si \
       -d client_id=$CLIENT_ID \
       -d client_secret=$CLIENT_SECRET \
       -d refresh_token=$refresh_token \
       -d grant_type=refresh_token \
       https://accounts.google.com/o/oauth2/token > $CTX_DIR/$ctx.refresh
  grep access_token $CTX_DIR/$ctx.refresh | sed -e 's/.*: "//' -e 's/",//' > $CTX_DIR/$ctx.access_token
}

while getopts :hc: opt ; do
  case $opt in
    c) ctx=$OPTARG ;;
    h) usage ;;
  esac
done
shift $(($OPTIND - 1))

cmd=$1 ; shift

mkdir -p $CTX_DIR
case $cmd in
  init)
    echo "Visit:"
    echo "https://accounts.google.com/o/oauth2/auth?client_id=$CLIENT_ID&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=$SCOPE&response_type=code"
    echo -n "Code? "
    read code
    curl -s \
         -d client_id=$CLIENT_ID \
         -d client_secret=$CLIENT_SECRET \
         -d code=$code \
         -d grant_type=authorization_code \
         -d redirect_uri=urn:ietf:wg:oauth:2.0:oob \
         https://www.googleapis.com/oauth2/v4/token > $CTX_DIR/$ctx.init
    grep access_token $CTX_DIR/$ctx.init | sed -e 's/.*: "//' -e 's/",//' > $CTX_DIR/$ctx.access_token
    grep refresh_token $CTX_DIR/$ctx.init | sed -e 's/.*: "//' -e 's/"//' > $CTX_DIR/$ctx.refresh_token
    echo "Done"
    ;;
  token)
    if [ ! -f $CTX_DIR/$ctx.access_token ] ; then
      echo "Unknown context: $ctx. Try initing first."
      exit
    fi
    age=`age $CTX_DIR/$ctx.access_token`
    if [ $age -gt 3600 ] ; then
      refresh
    fi
    cat $CTX_DIR/$ctx.access_token
    ;;
  *)
    usage
esac

gphoto_tools

#!/bin/bash

##
## command line tool for working with the google photos API.
## https://developers.google.com/photos/library/guides/overview
##

API_BASE=https://photoslibrary.googleapis.com/v1
AUTH_TOKEN=`gphoto_auth token`
ID_COL=.id

function usage {
  echo -n "Usage: `basename $0` "
  echo -n "-a {albums|search|get} [-q json-query] [-i id] [-u]"
  echo ""
  exit
}

while getopts ":a:q:i:vu" opt; do
  case $opt in
    a) ACTION=$OPTARG ;;
    q) QUERY=$OPTARG  ;;
    i) ID=$OPTARG     ;;
    v) VERBOSE=yes    ;;
    u) ID_COL=.productUrl ;;
    \?) usage         ;;
  esac
done

function invoke {
  buffer=/tmp/gphoto.buffer.$$
  curl -s -H "Authorization: Bearer $AUTH_TOKEN" "$@" > $buffer
  cat $buffer
}

function filter {
  if [ -z "$VERBOSE" ] ; then
    jq "$@"
  else
    cat
  fi
}

case $ACTION in
  albums)
    invoke -G $API_BASE/albums | filter -r ".albums[] | $ID_COL + \"|\" + .title"
    ;;
  search)
    if [ -z "$QUERY" ] ; then
      invoke $API_BASE/mediaItems | filter -r ".mediaItems[] | $ID_COL + \"|\" + .filename"
    else
      invoke -X POST $API_BASE/mediaItems:search -H "Content-Type: application/json" -d "$QUERY" | filter -r ".mediaItems[] | $ID_COL + \"|\" + .filename"
    fi
    ;;
  get)
    if [ -z "$ID" ] ; then
      echo "Missing -i  value"
      usage
    else
      invoke $API_BASE/mediaItem/$ID | filter -r '.productUrl'
    fi
    ;;
  *)
    usage
    ;;
esac

No comments:

Post a Comment