#!/usr/bin/python
#
# http://www.thok.org/intranet/python/exif/flickr_post.py
# (see .../index.html for kimdaba_flickr tool that uses this.)
#
# Working directly from http://www.flickr.com/services/api/
# The need for this is at least partly driven by the existing
# http://berserk.org/uploadr/ synchronization tool, it just
# didn't fit the API I wanted to use, so I went back to the spec.
#
# Copyright 2006 Mark W. Eichin
#
# All use permitted. BSD/MIT/X11 License.

import optparse
import sys
import md5
import urllib
import urllib2
import xmlrpclib
import StringIO
import MimeWriter
import os
import urlparse
try:
    import cElementTree as etree
except ImportError:
    import elementtree.ElementTree as etree

_id = "$Id$"
__version__ = "%s/%s" % (_id.split()[3], _id.split()[2])

urllib.URLopener.__version__ = "thok.org-flickr_post.py-%s" % __version__

# Flickr Details:
# -- http://www.flickr.com/services/api/misc.overview.html 
#    method is REQUIRED
#    api_key is REQUIRED
# -- http://www.flickr.com/services/api/misc.encoding.html
#    The Flickr API expects all data to be UTF-8 encoded. [yay]
# -- http://www.flickr.com/services/api/auth.howto.desktop.html
#    is mostly for developers, see flickr_dev.py for how we get:
_api_key = "c8a212d0054c739729852ffa4f48d202"
_shared_not_actually_secret = "f3c0100d8d722af0"
flickr_xmlrpc_base = "http://www.flickr.com/services/xmlrpc/"

_tokenfile = os.path.expanduser("~/.flickr_post")
def _put_auth_token(token):
    out = open(_tokenfile, "w")
    print >> out, "auth_token:", token
    out.close()

def _get_auth_token(cache={}):
    """Fetch a stashed auth-token"""
    # for now, just one.  Later, have per-app tokens.
    # also add timestamp-checking on the cache.
    if "auth_token" in cache:
        return cache["auth_token"]
    for line in file(_tokenfile):
        # strip newlines and comments
        line = line.rstrip("\n").split("#", 1)[0]
        # skip blank lines
        if not line.strip():
            continue
        tag, val = line.split(": ", 1)
        if tag == "auth_token":
            cache[tag] = val
            return val
    raise Exception('"No auth_token found, try "flickr_post register"')

# later we'll import flickr_dev and have it do the work.

# after all that,
# -- http://www.flickr.com/services/api/upload.api.html
#    Photos should be POSTed to the following URL:
#    http://www.flickr.com/services/upload/

upload_base = "http://www.flickr.com/services/upload/"

# photo -- The file to upload. -- *not* included in sig
# title (optional) -- The title of the photo.
# description (optional)
#   -- A description of the photo. May contain some limited HTML.
# tags (optional) -- A space-seperated list of tags to apply to the photo.
# is_public, is_friend, is_family (optional)
#   --    Set to 0 for no, 1 for yes. Specifies who can view the photo.

# When an upload is sucessful, the following xml is returned:
# <photoid>1234</photoid>

# When a set of photos have been uploaded, direct the user to this url:
# http://www.flickr.com/tools/uploader_edit.gne?ids=1,2,3
# ...where "1,2,3" is a comma separated list of sucessful upload ids.

def upload_one(photopath, title=None, description=None,
               tags=None, 
               public=False, friends=False, family=False, 
               fake=False, rotate=None):
    check_status()
    args = dict()
    if title:
        args.update(dict(title=title))
    if description:
        args.update(dict(description=description))
    if tags:
        args.update(dict(tags=" ".join([tag.replace(" ", "_") for tag in tags])))
    if public:
        args.update(dict(is_public="1"))
    else:
        args.update(dict(is_public="0"))
    if family:
        args.update(dict(is_family="1"))
    else:
        args.update(dict(is_family="0"))
    if friends:
        args.update(dict(is_friend="1"))
    else:
        args.update(dict(is_friend="0"))
    args.update(dict(auth_token=_get_auth_token()))
    args.update(dict(api_key=_api_key))
    args = sign_method(**args)
    args.update(dict(photo=file(photopath).read()))

    uopen = urllib2.build_opener()
    ureq = urllib2.Request(upload_base)
    boundary, body = mimeencode(args)
    ureq.add_header("Content-type", "multipart/form-data; %s" % boundary)
    
    if fake:
        print "SKIPPING UPLOAD of", len(body), "payload for", photopath
        return

    uo = uopen.open(ureq, data=body)
    upload_res_txt = uo.read()
    photo_id = handle_generic_response(upload_res_txt, "Upload", "photoid")
    print make_redir(photo_id)

    if rotate:
        rotate_one(photo_id, rotate)

def handle_generic_response(txt, kind, member=None):
    res = etree.fromstring(txt)
    if res.get("stat") == "fail":
        msg = res.find("err").get("msg")
        if not msg:
            msg = "unparsed: %s" % txt
        raise Exception("%s failed: %s" % (kind, msg))
    if res.get("stat") != "ok":
        raise Exception("%s unknown response: %s" % (kind, txt))
    if member:
        return res.find(member).text

# http://www.flickr.com/services/api/flickr.photos.transform.rotate.html
def rotate_one(photo_id, degrees):
    degrees = str(degrees)
    if degrees == "0":
        return # dude
    assert degrees in ["90", "180", "270"]

    rot = sign_method(method="flickr.photos.transform.rotate",
                      photo_id = photo_id,
                      degrees = degrees,
                      auth_token=_get_auth_token(),
                      api_key=_api_key) # should it always do that?

    # must be a post...
    rotate_base = "http://www.flickr.com/services/rest/"
    uo = urllib.urlopen(rotate_base, data=urllib.urlencode(rot))
    rot_res_txt = uo.read()
    handle_generic_response(rot_res_txt, "Rotate")

def make_redir(ids):
    if isinstance(ids, basestring):
        ids = [ids]
    if isinstance(ids, int):
        ids = [ids]
    upload_uri = "http://www.flickr.com/tools/uploader_edit.gne"
    return upload_uri + "?ids=%s" % ",".join(map(str, ids))


# perldoc HTTP::Request::Common says:
#         The POST method also supports the "multipart/form-data" content used
#         for *Form-based File Upload* as specified in RFC 1867. You trigger
#         this content format by specifying a content type of 'form-data' as
#         one of the request headers. 

# RFC 1867 says:
#   application/octet-stream for each part if you don't know better
#   name= should be enough...

# multifile is reader-only
# mimify is close, but it's more 8bit<->qp than wrapping
# mimetools.choose_boundary() is useful, but called internal to MimeWriter already
# mimetools.encode(input, output, "base64")  might help, but otherwise decode only
# mhlib - also reader-only
# looks like MimeWriter wins

# also, I have to credit the HTTP::Request::Common examples (and the RFC)
# for pointing out optional components that might not be so optional
# in flickr's use...
# though http://www.flickr.com/services/api/upload.example.html also has
# concrete text which might have pointed out the same thing.
def mimeencode(args):
    """encode as form-data"""
    f = StringIO.StringIO()
    w = MimeWriter.MimeWriter(f)
    fw = w.startmultipartbody("form-data")
    # ignore fw, or put a comment there
    for k, v in args.items():
        # if k == "api_sig": continue
        subw = w.nextpart()
        ctype = "text/plain"
        extra_disposition = ""
        if k == "photo":
            # without this, it drops photo?
            extra_disposition = '; filename="something.jpg"'
            ctype = "image/jpeg"
            # uploadr uses mimetypes.guess_type, which might be useful
        subw.addheader("Content-Disposition", 'form-data; name="%s"%s' % (k, extra_disposition), 
                       prefix=1)
        subf = subw.startbody(ctype, prefix=0)
        subf.write(v)
        
    w.lastpart()

    # body = f.getvalue()
    # rewind
    # read the boundary
    # return the rest
    f.seek(0)
    ct = f.readline()
    bound = f.readline().strip()
    blank = f.readline()
    blank = f.readline()
    remainder = f.read()
    # we can strip the content types here... or at least some of them
    # remainder = "".join([line for line in f if not line.startswith("Content-Type:")])
    return bound, remainder

# without auth_token:
# xmlrpclib.Fault: <Fault 99: 'Insufficient permissions. Method requires read privileges; none granted.'>
# with:
# <user id="35034350551@N01" ispro="1">
# 	<username>Mark Eichin</username>
# 	<bandwidth max="2147483648" used="12713578" />
# 	<filesize max="10485760" />
# </user>

def check_status():
    gups = sign_method(method="flickr.people.getUploadStatus",
                       auth_token=_get_auth_token(),
                       api_key=_api_key) # should it always do that?
    prox = xmlrpclib.ServerProxy(flickr_xmlrpc_base)
    gupstxt = prox.flickr.people.getUploadStatus(gups)
    gupsresp = etree.fromstring(gupstxt)
    max_bandwidth = gupsresp.find("bandwidth").get("max")
    used_bandwidth = gupsresp.find("bandwidth").get("used")
    max_filesize = gupsresp.find("filesize").get("max")
    print "Maximum filesize allowed:", max_filesize
    perc = (100*long(used_bandwidth))/long(max_bandwidth)
    print "%s%% of your monthly bandwidth used (%s/%s bytes)" % (perc, used_bandwidth, max_bandwidth)

def md5hex(s):
    """hex form of md5 digest of string"""
    return md5.new(s).hexdigest()

def sign_method(**kwargs):
    kwargs[""] = _shared_not_actually_secret
    api_sig_string = "".join([k+str(v) for k, v in sorted(kwargs.items())])
    # print api_sig_string
    api_sig = md5hex(api_sig_string)
    kwargs["api_sig"] = api_sig
    del kwargs[""] # we don't actually pass the shared secret
    return kwargs

# register!
#   using _api_key and _shared_not_actually_secret, get a frob
#   then use it to get an auth token and stash it in ~/.flickr_post
#   then make the user deal

def register():
    getFrob = sign_method(method="flickr.auth.getFrob",
                          api_key=_api_key)
    prox = xmlrpclib.ServerProxy(flickr_xmlrpc_base)
    frobtxt = prox.flickr.auth.getFrob(getFrob)
    frobresp = etree.fromstring(frobtxt)
    assert frobresp.tag == "frob", "Got %s instead of frob" % frobtxt
    frob = frobresp.text
    # in here, generate the approval url...

    # http://flickr.com/services/auth/?api_key=[api_key]&perms=[perms]&frob=[frob]&api_sig=[api_sig]

    authorize_base = "http://flickr.com/services/auth/"
    # we don't give it a method, but I think it's still valid
    authorize_args = sign_method(perms='write',
                                 frob=frob,
                                 api_key=_api_key)
    authorize_me = urlparse.urlunsplit(("", "", authorize_base, 
                                        urllib.urlencode(authorize_args), None))

    while 1:
        print "Please open:"
        print authorize_me
        print "in your browser of choice.  Then hit return..."
        raw_input()

        getToken = sign_method(method="flickr.auth.getToken",
                               api_key=_api_key, frob=frob)
        try:
            tokentxt = prox.flickr.auth.getToken(getToken)
        except xmlrpclib.Fault, xfault:
            if xfault.faultCode == 108: # invalid frob
                print "frob not registered, please hit the web page first..."
                continue
            else:
                raise
        tokenresp = etree.fromstring(tokentxt)
        tokenvalue = tokenresp.find("token").text
        _put_auth_token(tokenvalue)
        # test it with checkToken
        if check_stored_token():
            return
        else:
            print "token didn't work, try again"

def check_stored_token():
    check = sign_method(method="flickr.auth.checkToken",
                        auth_token=_get_auth_token(),
                        api_key=_api_key)
    prox = xmlrpclib.ServerProxy(flickr_xmlrpc_base)
    try:
        checktxt = prox.flickr.auth.checkToken(check)
    except xmlrpclib.Fault, xfault:
        if xfault.faultCode == 98: # Invalid auth token
            print xfault.faultString, "while checking stored token"
            return False
    checkresp = etree.fromstring(checktxt)
    who = checkresp.find("user").get("username")
    what = checkresp.find("perms").text
    print who, "has granted", what
    if what != "write":
        print "Warning, we asked for write and got", what
    return True

def check_everything():
    if not check_stored_token():
        return "Token not valid, try --register"
    check_status()
    

def constrain_to_one_option(field, refname):
    """optparse allows multiple "store" arguments without complaint.
    By using this function with "append" instead, we can tell that
    we want to complain, instead of silently discarding earlier arguments.
    (optparse is supporting a unix convention, just not a good one.)"""
    
    if field:
        if len(field) == 1:
            # also pull out the single value, for convenience
            return field[0]
        sys.exit("Only one --%s option allowed" % refname)
    return None

if __name__ == "__main__":
    usage = """%prog [options...] images
    where the options apply across all images, which are uploaded
    """

    parser = optparse.OptionParser(usage=usage,
                                   version = "%prog " + __version__)
    # many
    parser.add_option("--tag", action="append", dest="tags",
                      help="flickr tag to apply to these pictures")
    
    # only one comment/comment source
    parser.add_option("--comment", action="append", dest="comment",
                      help="comment to apply to these pictures")
    parser.add_option("--comment-file", action="append", dest="comment_file",
                      help="comment to apply to these pictures")
    # only one title
    parser.add_option("--title", action="append", dest="title",
                      help="comment to apply to these pictures")
    
    # only one rotation
    parser.add_option("--rotate", action="append", dest="rotate",
                      help="rotate clockwise, degrees, relative to current")
    
    parser.add_option("--public", action="store_true", dest="public",
                      help="make these pictures public")
    parser.add_option("--friends", action="store_true", dest="friends",
                      help="make these pictures friends-only")
    parser.add_option("--family", action="store_true", dest="family",
                      help="make these pictures family-only")
    
    parser.add_option("--register", action="store_true", dest="register",
                      help="register flickr_post with your flickr account")
    parser.add_option("--check", action="store_true", dest="check",
                      help="check upload stats and registration")

    options, images = parser.parse_args()

    if options.register:
        # the alternative is to scan options.__dict__.items(), eww.
        if options.tags or options.comment or options.comment_file \
           or options.title or options.rotate or options.check \
           or options.public or options.friends or options.family:
            sys.exit("--register conflicts with everything else")
        sys.exit(register())

    if options.check:
        # the alternative is to scan options.__dict__.items(), eww.
        if options.tags or options.comment or options.comment_file \
           or options.title or options.rotate or options.register \
           or options.public or options.friends or options.family:
            sys.exit("--check conflicts with everything else")
        sys.exit(check_everything())

    if not images:
        parser.print_help()
        sys.exit("image pathnames required")

    if options.comment and options.comment_file:
        sys.exit("only one of --comment or --comment-file allowed")
        
    comment = constrain_to_one_option(options.comment, "comment")
    if not comment:
        comment_file = constrain_to_one_option(options.comment_file, "comment-file")
        if comment_file:
            comment = open(comment_file, "r").read()

    title = constrain_to_one_option(options.title, "title")

    rotate = constrain_to_one_option(options.rotate, "rotate")
    # exposures are simple flags


    # now, we want to upload each of images, in order,
    # with title, exposure, comment, options.tags

    for img in images:
        upload_one(img, title=title, description=comment,
                   tags=options.tags,
                   public=options.public, 
                   friends=options.friends, 
                   family=options.family,
                   rotate=rotate)

### actual usage:
### ./flickr_post.py --tag convertible --tag sun --tag mini \
###        --tag road --tag cambridgema  --public --title "kicx6848" \
###        --comment "57 degrees on Mt Auburn st near Elliot Sq :-)" kicx6848.jpg