#!/usr/bin/env python
# -*-*- encoding: utf-8 -*-*-
#
# This is the service file for the Google Photo python client.
# It is used for higher level operations.
#
# $Id: service.py 144 2007-10-25 21:03:34Z havard.gulldahl $
#
# Copyright 2007 Håvard Gulldahl 
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Google PhotoService provides a human-friendly interface to
Google Photo (a.k.a Picasa Web) services[1].

It extends gdata.service.GDataService and as such hides all the
nasty details about authenticating, parsing and communicating with
Google Photos. 

[1]: http://code.google.com/apis/picasaweb/gdata.html

Example:
  import gdata.photos, gdata.photos.service
  pws = gdata.photos.service.PhotosService()
  pws.ClientLogin(username, password)
  #Get all albums
  albums = pws.GetUserFeed().entry
  # Get all photos in second album
  photos = pws.GetFeed(albums[1].GetPhotosUri()).entry
  # Get all tags for photos in second album and print them
  tags = pws.GetFeed(albums[1].GetTagsUri()).entry
  print [ tag.summary.text for tag in tags ]
  # Get all comments for the first photos in list and print them
  comments = pws.GetCommentFeed(photos[0].GetCommentsUri()).entry
  print [ c.summary.text for c in comments ]

  # Get a photo to work with
  photo = photos[0]
  # Update metadata

  # Attributes from the <gphoto:*> namespace
  photo.summary.text = u'A nice view from my veranda'
  photo.title.text = u'Verandaview.jpg'

  # Attributes from the <media:*> namespace
  photo.media.keywords.text = u'Home, Long-exposure, Sunset' # Comma-separated

  # Adding attributes to media object

  # Rotate 90 degrees clockwise
  photo.rotation = gdata.photos.Rotation(text='90') 

  # Submit modified photo object
  photo = pws.UpdatePhotoMetadata(photo)
  
  # Make sure you only modify the newly returned object, else you'll get
  # versioning errors. See Optimistic-concurrency

  # Add comment to a picture
  comment = pws.InsertComment(photo, u'I wish the water always was this warm')

  # Remove comment because it was silly
  print "*blush*"
  pws.Delete(comment.GetEditLink().href)

"""

__author__ = 'havard@gulldahl.no'# (Håvard Gulldahl)' #BUG: pydoc chokes on non-ascii chars in __author__
__license__ = 'Apache License v2'
__version__ = '$Revision: 176 $'[11:-2] 


import sys, os.path, io
import time
import gdata.service
import gdata
import atom.service
import atom
import gdata.photos

SUPPORTED_UPLOAD_TYPES = ('bmp', 'jpeg', 'jpg', 'gif', 'png')

UNKOWN_ERROR=1000
GPHOTOS_BAD_REQUEST=400
GPHOTOS_CONFLICT=409
GPHOTOS_INTERNAL_SERVER_ERROR=500
GPHOTOS_INVALID_ARGUMENT=601
GPHOTOS_INVALID_CONTENT_TYPE=602
GPHOTOS_NOT_AN_IMAGE=603
GPHOTOS_INVALID_KIND=604

class GooglePhotosException(Exception):
  def __init__(self, response):

    self.error_code = response['status']
    self.reason = response['reason'].strip()
    if '<html>' in str(response['body']): #general html message, discard it
      response['body'] = ""
    self.body = response['body'].strip()
    self.message = "(%(status)s) %(body)s -- %(reason)s" % response

    #return explicit error codes
    error_map = { '(12) Not an image':GPHOTOS_NOT_AN_IMAGE,
                  'kind: That is not one of the acceptable values':
                      GPHOTOS_INVALID_KIND,
                  
                }
    for msg, code in error_map.items():
      if self.body == msg:
        self.error_code = code
        break
    self.args = [self.error_code, self.reason, self.body]

class PhotosService(gdata.service.GDataService):
  ssl = True
  userUri = '/data/feed/api/user/%s'
  
  def __init__(self, email=None, password=None, source=None,
               server='picasaweb.google.com', additional_headers=None,
               **kwargs):
    """Creates a client for the Google Photos service.

    Args:
      email: string (optional) The user's email address, used for
          authentication.
      password: string (optional) The user's password.
      source: string (optional) The name of the user's application.
      server: string (optional) The name of the server to which a connection
          will be opened. Default value: 'picasaweb.google.com'.
      **kwargs: The other parameters to pass to gdata.service.GDataService
          constructor.
    """
    self.email = email
    self.client = source
    gdata.service.GDataService.__init__(
        self, email=email, password=password, service='lh2', source=source,
        server=server, additional_headers=additional_headers, **kwargs)

  def GetFeed(self, uri, limit=None, start_index=None):
    """Get a feed.

     The results are ordered by the values of their `updated' elements,
     with the most recently updated entry appearing first in the feed.
    
    Arguments:
    uri: the uri to fetch
    limit (optional): the maximum number of entries to return. Defaults to what
      the server returns.
     
    Returns:
    one of gdata.photos.AlbumFeed,
           gdata.photos.UserFeed,
           gdata.photos.PhotoFeed,
           gdata.photos.CommentFeed,
           gdata.photos.TagFeed,
      depending on the results of the query.
    Raises:
    GooglePhotosException

    See:
    http://code.google.com/apis/picasaweb/gdata.html#Get_Album_Feed_Manual
    """
    if limit is not None:
      uri += '&max-results=%s' % limit
    if start_index is not None:
      uri += '&start-index=%s' % start_index
    try:
      return self.Get(uri, converter=gdata.photos.AnyFeedFromString)
    except gdata.service.RequestError as e:
      raise GooglePhotosException(e.args[0])

  def GetEntry(self, uri, limit=None, start_index=None):
    """Get an Entry.

    Arguments:
    uri: the uri to the entry
    limit (optional): the maximum number of entries to return. Defaults to what
      the server returns.
     
    Returns:
    one of gdata.photos.AlbumEntry,
           gdata.photos.UserEntry,
           gdata.photos.PhotoEntry,
           gdata.photos.CommentEntry,
           gdata.photos.TagEntry,
      depending on the results of the query.
    Raises:
    GooglePhotosException
    """
    if limit is not None:
      uri += '&max-results=%s' % limit
    if start_index is not None:
      uri += '&start-index=%s' % start_index
    try:
      return self.Get(uri, converter=gdata.photos.AnyEntryFromString)
    except gdata.service.RequestError as e:
      raise GooglePhotosException(e.args[0])
      
  def GetUserFeed(self, kind='album', user='default', limit=None):
    """Get user-based feed, containing albums, photos, comments or tags;
      defaults to albums.

    The entries are ordered by the values of their `updated' elements,
    with the most recently updated entry appearing first in the feed.
    
    Arguments:
    kind: the kind of entries to get, either `album', `photo',
      `comment' or `tag', or a python list of these. Defaults to `album'.
    user (optional): whose albums we're querying. Defaults to current user.
    limit (optional): the maximum number of entries to return.
      Defaults to everything the server returns.

     
    Returns:
    gdata.photos.UserFeed, containing appropriate Entry elements

    See:
    http://code.google.com/apis/picasaweb/gdata.html#Get_Album_Feed_Manual
    http://googledataapis.blogspot.com/2007/07/picasa-web-albums-adds-new-api-features.html
    """
    if isinstance(kind, (list, tuple) ):
      kind = ",".join(kind)
    
    uri = '/data/feed/api/user/%s?kind=%s' % (user, kind)
    return self.GetFeed(uri, limit=limit)
  
  def GetTaggedPhotos(self, tag, user='default', limit=None):
    """Get all photos belonging to a specific user, tagged by the given keyword

    Arguments:
    tag: The tag you're looking for, e.g. `dog'
    user (optional): Whose images/videos you want to search, defaults
      to current user
    limit (optional): the maximum number of entries to return.
      Defaults to everything the server returns.

    Returns:
    gdata.photos.UserFeed containing PhotoEntry elements
    """
    # Lower-casing because of
    #   http://code.google.com/p/gdata-issues/issues/detail?id=194
    uri = '/data/feed/api/user/%s?kind=photo&tag=%s' % (user, tag.lower())
    return self.GetFeed(uri, limit)

  def SearchUserPhotos(self, query, user='default', limit=100):
    """Search through all photos for a specific user and return a feed.
    This will look for matches in file names and image tags (a.k.a. keywords)

    Arguments:
    query: The string you're looking for, e.g. `vacation'
    user (optional): The username of whose photos you want to search, defaults
      to current user.
    limit (optional): Don't return more than `limit' hits, defaults to 100

    Only public photos are searched, unless you are authenticated and
    searching through your own photos.

    Returns:
    gdata.photos.UserFeed with PhotoEntry elements
    """
    uri = '/data/feed/api/user/%s?kind=photo&q=%s' % (user, query)
    return self.GetFeed(uri, limit=limit)

  def SearchCommunityPhotos(self, query, limit=100):
    """Search through all public photos and return a feed.
    This will look for matches in file names and image tags (a.k.a. keywords)

    Arguments:
    query: The string you're looking for, e.g. `vacation'
    limit (optional): Don't return more than `limit' hits, defaults to 100

    Returns:
    gdata.GDataFeed with PhotoEntry elements
    """
    uri='/data/feed/api/all?q=%s' % query
    return self.GetFeed(uri, limit=limit)

  def GetContacts(self, user='default', limit=None):
    """Retrieve a feed that contains a list of your contacts

    Arguments:
    user: Username of the user whose contacts you want

    Returns
    gdata.photos.UserFeed, with UserEntry entries

    See:
    http://groups.google.com/group/Google-Picasa-Data-API/msg/819b0025b5ff5e38
    """
    uri = '/data/feed/api/user/%s/contacts?kind=user' % user
    return self.GetFeed(uri, limit=limit)

  def SearchContactsPhotos(self, user='default', search=None, limit=None):
    """Search over your contacts' photos and return a feed

    Arguments:
    user: Username of the user whose contacts you want
    search (optional): What to search for (photo title, description and keywords)

    Returns
    gdata.photos.UserFeed, with PhotoEntry elements

    See:
    http://groups.google.com/group/Google-Picasa-Data-API/msg/819b0025b5ff5e38
    """

    uri = '/data/feed/api/user/%s/contacts?kind=photo&q=%s' % (user, search)
    return self.GetFeed(uri, limit=limit)

  def InsertAlbum(self, title, summary, location=None, access='public',
    commenting_enabled='true', timestamp=None):
    """Add an album.

    Needs authentication, see self.ClientLogin()

    Arguments:
    title: Album title 
    summary: Album summary / description
    access (optional): `private' or `public'. Public albums are searchable
      by everyone on the internet. Defaults to `public'
    commenting_enabled (optional): `true' or `false'. Defaults to `true'.
    timestamp (optional): A date and time for the album, in milliseconds since
      Unix epoch[1] UTC. Defaults to now.

    Returns:
    The newly created gdata.photos.AlbumEntry

    See:
    http://code.google.com/apis/picasaweb/gdata.html#Add_Album_Manual_Installed

    [1]: http://en.wikipedia.org/wiki/Unix_epoch
    """
    album = gdata.photos.AlbumEntry()
    album.title = atom.Title(text=title, title_type='text')
    album.summary = atom.Summary(text=summary, summary_type='text')
    if location is not None:
      album.location = gdata.photos.Location(text=location)
    album.access = gdata.photos.Access(text=access)
    if commenting_enabled in ('true', 'false'):
      album.commentingEnabled = gdata.photos.CommentingEnabled(text=commenting_enabled)
    if timestamp is None:
      timestamp = '%i' % int(time.time() * 1000)
    album.timestamp = gdata.photos.Timestamp(text=timestamp)
    try:
      return self.Post(album, uri=self.userUri % self.email,
      converter=gdata.photos.AlbumEntryFromString)
    except gdata.service.RequestError as e:
      raise GooglePhotosException(e.args[0])

  def InsertPhoto(self, album_or_uri, photo, filename_or_handle,
    content_type='image/jpeg'):
    """Add a PhotoEntry

    Needs authentication, see self.ClientLogin()

    Arguments:
    album_or_uri: AlbumFeed or uri of the album where the photo should go
    photo: PhotoEntry to add
    filename_or_handle: A file-like object or file name where the image/video
      will be read from
    content_type (optional): Internet media type (a.k.a. mime type) of
      media object. Currently Google Photos supports these types:
       o image/bmp
       o image/gif
       o image/jpeg
       o image/png
       
      Images will be converted to jpeg on upload. Defaults to `image/jpeg'

    """

    try:
      assert(isinstance(photo, gdata.photos.PhotoEntry))
    except AssertionError:
      raise GooglePhotosException({'status':GPHOTOS_INVALID_ARGUMENT,
        'body':'`photo` must be a gdata.photos.PhotoEntry instance',
        'reason':'Found %s, not PhotoEntry' % type(photo)
        })
    try:
      majtype, mintype = content_type.split('/')
      assert(mintype in SUPPORTED_UPLOAD_TYPES)
    except (ValueError, AssertionError):
      raise GooglePhotosException({'status':GPHOTOS_INVALID_CONTENT_TYPE,
        'body':'This is not a valid content type: %s' % content_type,
        'reason':'Accepted content types: %s' % \
          ['image/'+t for t in SUPPORTED_UPLOAD_TYPES]
        })
    if isinstance(filename_or_handle, str) and \
      os.path.exists(filename_or_handle): # it's a file name
      mediasource = gdata.MediaSource()
      mediasource.setFile(filename_or_handle, content_type)
    elif hasattr(filename_or_handle, 'read'):# it's a file-like resource
      if hasattr(filename_or_handle, 'seek'):
        filename_or_handle.seek(0) # rewind pointer to the start of the file
      # gdata.MediaSource needs the content length, so read the whole image 
      file_handle = io.StringIO(filename_or_handle.read()) 
      name = 'image'
      if hasattr(filename_or_handle, 'name'):
        name = filename_or_handle.name
      mediasource = gdata.MediaSource(file_handle, content_type,
        content_length=file_handle.len, file_name=name)
    else: #filename_or_handle is not valid
      raise GooglePhotosException({'status':GPHOTOS_INVALID_ARGUMENT,
        'body':'`filename_or_handle` must be a path name or a file-like object',
        'reason':'Found %s, not path name or object with a .read() method' % \
          filename_or_handle
        })
    
    if isinstance(album_or_uri, str): # it's a uri
      feed_uri = album_or_uri
    elif hasattr(album_or_uri, 'GetFeedLink'): # it's a AlbumFeed object
      feed_uri = album_or_uri.GetFeedLink().href
  
    try:
      return self.Post(photo, uri=feed_uri, media_source=mediasource,
        converter=gdata.photos.PhotoEntryFromString)
    except gdata.service.RequestError as e:
      raise GooglePhotosException(e.args[0])
  
  def InsertPhotoSimple(self, album_or_uri, title, summary, filename_or_handle,
      content_type='image/jpeg', keywords=None):
    """Add a photo without constructing a PhotoEntry.

    Needs authentication, see self.ClientLogin()

    Arguments:
    album_or_uri: AlbumFeed or uri of the album where the photo should go
    title: Photo title
    summary: Photo summary / description
    filename_or_handle: A file-like object or file name where the image/video
      will be read from
    content_type (optional): Internet media type (a.k.a. mime type) of
      media object. Currently Google Photos supports these types:
       o image/bmp
       o image/gif
       o image/jpeg
       o image/png
       
      Images will be converted to jpeg on upload. Defaults to `image/jpeg'
    keywords (optional): a 1) comma separated string or 2) a python list() of
      keywords (a.k.a. tags) to add to the image.
      E.g. 1) `dog, vacation, happy' 2) ['dog', 'happy', 'vacation']
    
    Returns:
    The newly created gdata.photos.PhotoEntry or GooglePhotosException on errors

    See:
    http://code.google.com/apis/picasaweb/gdata.html#Add_Album_Manual_Installed
    [1]: http://en.wikipedia.org/wiki/Unix_epoch
    """
    
    metadata = gdata.photos.PhotoEntry()
    metadata.title=atom.Title(text=title)
    metadata.summary = atom.Summary(text=summary, summary_type='text')
    if keywords is not None:
      if isinstance(keywords, list):
        keywords = ','.join(keywords)
      metadata.media.keywords = gdata.media.Keywords(text=keywords)
    return self.InsertPhoto(album_or_uri, metadata, filename_or_handle,
      content_type)

  def UpdatePhotoMetadata(self, photo):
    """Update a photo's metadata. 

     Needs authentication, see self.ClientLogin()

     You can update any or all of the following metadata properties:
      * <title>
      * <media:description>
      * <gphoto:checksum>
      * <gphoto:client>
      * <gphoto:rotation>
      * <gphoto:timestamp>
      * <gphoto:commentingEnabled>

      Arguments:
      photo: a gdata.photos.PhotoEntry object with updated elements

      Returns:
      The modified gdata.photos.PhotoEntry

      Example:
      p = GetFeed(uri).entry[0]
      p.title.text = u'My new text'
      p.commentingEnabled.text = 'false'
      p = UpdatePhotoMetadata(p)

      It is important that you don't keep the old object around, once
      it has been updated. See
      http://code.google.com/apis/gdata/reference.html#Optimistic-concurrency
      """
    try:
      return self.Put(data=photo, uri=photo.GetEditLink().href,
      converter=gdata.photos.PhotoEntryFromString)
    except gdata.service.RequestError as e:
      raise GooglePhotosException(e.args[0])

  
  def UpdatePhotoBlob(self, photo_or_uri, filename_or_handle,
                      content_type = 'image/jpeg'):
    """Update a photo's binary data.

    Needs authentication, see self.ClientLogin()

    Arguments:
    photo_or_uri: a gdata.photos.PhotoEntry that will be updated, or a
      `edit-media' uri pointing to it
    filename_or_handle:  A file-like object or file name where the image/video
      will be read from
    content_type (optional): Internet media type (a.k.a. mime type) of
      media object. Currently Google Photos supports these types:
       o image/bmp
       o image/gif
       o image/jpeg
       o image/png
    Images will be converted to jpeg on upload. Defaults to `image/jpeg'

    Returns:
    The modified gdata.photos.PhotoEntry

    Example:
    p = GetFeed(PhotoUri)
    p = UpdatePhotoBlob(p, '/tmp/newPic.jpg')

    It is important that you don't keep the old object around, once
    it has been updated. See
    http://code.google.com/apis/gdata/reference.html#Optimistic-concurrency
    """

    try:  
      majtype, mintype = content_type.split('/')
      assert(mintype in SUPPORTED_UPLOAD_TYPES)
    except (ValueError, AssertionError):
      raise GooglePhotosException({'status':GPHOTOS_INVALID_CONTENT_TYPE,
        'body':'This is not a valid content type: %s' % content_type,
        'reason':'Accepted content types: %s' % \
          ['image/'+t for t in SUPPORTED_UPLOAD_TYPES]
        })
    
    if isinstance(filename_or_handle, str) and \
      os.path.exists(filename_or_handle): # it's a file name
      photoblob = gdata.MediaSource()
      photoblob.setFile(filename_or_handle, content_type)
    elif hasattr(filename_or_handle, 'read'):# it's a file-like resource
      if hasattr(filename_or_handle, 'seek'):
        filename_or_handle.seek(0) # rewind pointer to the start of the file
      # gdata.MediaSource needs the content length, so read the whole image 
      file_handle = io.StringIO(filename_or_handle.read()) 
      name = 'image'
      if hasattr(filename_or_handle, 'name'):
        name = filename_or_handle.name
      mediasource = gdata.MediaSource(file_handle, content_type,
        content_length=file_handle.len, file_name=name)
    else: #filename_or_handle is not valid
      raise GooglePhotosException({'status':GPHOTOS_INVALID_ARGUMENT,
        'body':'`filename_or_handle` must be a path name or a file-like object',
        'reason':'Found %s, not path name or an object with .read() method' % \
          type(filename_or_handle)
        })
    
    if isinstance(photo_or_uri, str):
      entry_uri = photo_or_uri # it's a uri
    elif hasattr(photo_or_uri, 'GetEditMediaLink'):
      entry_uri = photo_or_uri.GetEditMediaLink().href
    try:
      return self.Put(photoblob, entry_uri,
          converter=gdata.photos.PhotoEntryFromString)
    except gdata.service.RequestError as e:
      raise GooglePhotosException(e.args[0])

  def InsertTag(self, photo_or_uri, tag):
    """Add a tag (a.k.a. keyword) to a photo.

    Needs authentication, see self.ClientLogin()

    Arguments:
    photo_or_uri: a gdata.photos.PhotoEntry that will be tagged, or a
      `post' uri pointing to it
    (string) tag: The tag/keyword

    Returns:
    The new gdata.photos.TagEntry

    Example:
    p = GetFeed(PhotoUri)
    tag = InsertTag(p, 'Beautiful sunsets')

    """
    tag = gdata.photos.TagEntry(title=atom.Title(text=tag))
    if isinstance(photo_or_uri, str):
      post_uri = photo_or_uri # it's a uri
    elif hasattr(photo_or_uri, 'GetEditMediaLink'):
      post_uri = photo_or_uri.GetPostLink().href
    try:
      return self.Post(data=tag, uri=post_uri,
      converter=gdata.photos.TagEntryFromString)
    except gdata.service.RequestError as e:
      raise GooglePhotosException(e.args[0])

                  
  def InsertComment(self, photo_or_uri, comment):
    """Add a comment to a photo.

    Needs authentication, see self.ClientLogin()

    Arguments:
    photo_or_uri: a gdata.photos.PhotoEntry that is about to be commented
      , or a `post' uri pointing to it
    (string) comment: The actual comment

    Returns:
    The new gdata.photos.CommentEntry

    Example:
    p = GetFeed(PhotoUri)
    tag = InsertComment(p, 'OOOH! I would have loved to be there.
      Who's that in the back?')

    """
    comment = gdata.photos.CommentEntry(content=atom.Content(text=comment))
    if isinstance(photo_or_uri, str):
      post_uri = photo_or_uri # it's a uri
    elif hasattr(photo_or_uri, 'GetEditMediaLink'):
      post_uri = photo_or_uri.GetPostLink().href
    try:
      return self.Post(data=comment, uri=post_uri,
        converter=gdata.photos.CommentEntryFromString)
    except gdata.service.RequestError as e:
      raise GooglePhotosException(e.args[0])

  def Delete(self, object_or_uri, *args, **kwargs):
    """Delete an object.

    Re-implementing the GDataService.Delete method, to add some
    convenience.

    Arguments:
    object_or_uri: Any object that has a GetEditLink() method that
      returns a link, or a uri to that object.

    Returns:
    ? or GooglePhotosException on errors
    """
    try:
      uri = object_or_uri.GetEditLink().href
    except AttributeError:
      uri = object_or_uri
    try:
      return gdata.service.GDataService.Delete(self, uri, *args, **kwargs)
    except gdata.service.RequestError as e:
      raise GooglePhotosException(e.args[0])

def GetSmallestThumbnail(media_thumbnail_list):
  """Helper function to get the smallest thumbnail of a list of
    gdata.media.Thumbnail.
  Returns gdata.media.Thumbnail """
  r = {}
  for thumb in media_thumbnail_list:
      r[int(thumb.width)*int(thumb.height)] = thumb
  keys = list(r.keys())
  keys.sort()
  return r[keys[0]]

def ConvertAtomTimestampToEpoch(timestamp):
  """Helper function to convert a timestamp string, for instance
    from atom:updated or atom:published, to milliseconds since Unix epoch
    (a.k.a. POSIX time).

    `2007-07-22T00:45:10.000Z' -> """
  return time.mktime(time.strptime(timestamp, '%Y-%m-%dT%H:%M:%S.000Z'))
  ## TODO: Timezone aware
