diff --git a/.travis.yml b/.travis.yml
index cefc58c..fbdc918 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,11 +1,12 @@
language: python
+dist: trusty
python:
- "2.7"
virtualenv:
system_site_packages: true
before_install:
- "sudo apt-get update -qq"
- - "sudo apt-get install python-dev python-pip python-pyexiv2 libimage-exiftool-perl -y"
+ - "sudo apt-get install python-dev python-pip libimage-exiftool-perl -y"
install:
- "sudo pip install -r elodie/tests/requirements.txt"
- "sudo pip install coveralls"
diff --git a/Readme.md b/Readme.md
index b3c9925..544d888 100644
--- a/Readme.md
+++ b/Readme.md
@@ -3,6 +3,51 @@
[![Build Status](https://travis-ci.org/jmathai/elodie.svg?branch=master)](https://travis-ci.org/jmathai/elodie) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/jmathai/elodie/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/jmathai/elodie/?branch=master) [![Coverage Status](https://coveralls.io/repos/github/jmathai/elodie/badge.svg?branch=master)](https://coveralls.io/github/jmathai/elodie?branch=master)
+## Quickstart guide
+
+Getting started takes just a few minutes.
+
+### Install ExifTool
+
+Elodie relies on the great [ExifTool library by Phil Harvey](http://www.sno.phy.queensu.ca/~phil/exiftool/). You'll need to install it for your platform. Some features for video files will only work with newer versions of ExifTool and have been tested on version 10.15 or higher. Check your version by typing `exiftool -ver` and see the [manual installation instructions for ExifTool](http://www.sno.phy.queensu.ca/~phil/exiftool/install.html#Unix) if needed.
+
+```
+# OSX (uses homebrew, http://brew.sh/)
+brew install exiftool
+
+# Debian / Ubuntu
+apt-get install libimage-exiftool-perl
+
+# Fedora / Redhat
+dnf install perl-Image-ExifTool
+
+# Windows users can install the binary
+# http://www.sno.phy.queensu.ca/~phil/exiftool/install.html
+```
+
+### Clone the Elodie repository
+
+You can clone Elodie from GitHub. You'll need `git` installed ([instructions](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)).
+
+```
+git clone https://github.com/jmathai/elodie.git
+cd elodie
+pip install -r requirements.txt
+```
+
+### Give Elodie a test drive
+
+Now that you've got the minimum dependencies installed you can start using Elodie. You'll need a photo, video or audio file and a folder you'd like Elodie to organize them into.
+
+```
+# Run these commands from the root of the repository you just cloned.
+./elodie.py import --destination="/where/i/want/my/photos/to/go" /where/my/photo/is.jpg
+```
+
+You'll notice that the photo was organized into an *Unknown Location* folder. That's because you haven't set up your MapQuest API ([instructions](#using-openstreetmap-data-from-mapquest).
+
+Now you're ready to learn more about Elodie.
+
[Read a 3 part blog post on why I was created](https://medium.com/vantage/understanding-my-need-for-an-automated-photo-workflow-a2ff95b46f8f#.dmwyjlc57) and how [I can be used with Google Photos](https://medium.com/@jmathai/my-automated-photo-workflow-using-google-photos-and-elodie-afb753b8c724).
@@ -42,16 +87,16 @@ Updating EXIF of photos from the command line.
I'm most helpful when I'm fully utilized to keep your photos organized. My parents had ambitious aspirations for me even when I was growing in my momma's belly . They're dreamers and so am I.
-Here's dada's (very asynchronous) setup.
-* Specify a folder in his Dropbox to store the organized photo library.
+Here's an example of a very asynchronous setup.
+* Specify a folder in your Dropbox to store the organized photo library.
* Set up a Hazel rule to notify me when photos arrive in `~/Downloads` so I can import them.
- * The rule waits 1 minute before processing the photo which gives him a chance to move it elsewhere if it's not something he wants in the library.
-* Use AirDrop to transfer files from his or momma's iPhone to his laptop. That goes to `~/Downloads` for the Hazel rule to process.
- * AirDrop is fast, easy for momma to use and once the transfer is finished they don't have to stick around. I'll move it to Dropbox and Dropbox will sync it to their servers.
+ * The rule waits 1 minute before processing the photo which gives you a chance to move it elsewhere if it's not something you want in the library.
+* Use AirDrop to transfer files from any iPhone to your laptop. That goes to `~/Downloads` for the Hazel rule to process.
+ * AirDrop is fast, easy for anyone to use and once the transfer is finished your don't have to stick around. I'll move it to Dropbox and Dropbox will sync it to their servers.
* Periodically recategorize photos by fixing their location or date or by adding them to an album.
* Have a Synology at home set to automatically sync down from Dropbox.
-This setup means dada can quickly get photos off his or momma's phone and know that they'll be organized and backed up by the time they're ready to view them.
+This setup means you can quickly get photos off your or anyone's phone and know that they'll be organized and backed up in 3 locations by the time they're ready to view them.
## Let's organize your existing photos
@@ -164,52 +209,20 @@ I'm simple. I put a photo into its proper location. I can update a photo to have
I don't do anything else so don't bother asking.
-## Install everything you need
+## EXIF and XMP tags
-You'll need to clone this repository and install a few dependencies. Let's start by cloning.
+When I organize photos I look at the embedded metadata. Here are the details of how I determine what information to use.
+| Dimension | Fields | Notes |
+|---|---|---|
+| Date Taken (photo) | EXIF:DateTimeOriginal,EXIF:DateTime, EXIF:DateTimeDigitized, file created, file modified | |
+| Date Taken (video, audio) | QuickTime:CreationDate, QuickTime:CreationDate-und-US, QuickTime:MediaCreateDate, file created, file modified | |
+| Location (photo) | EXIF:GPSLatitude/EXIF:GPSLatitudeRef, EXIF:GPSLongitude/EXIF:GPSLongitudeRef | |
+| Location (video, audio) | XMP:GPSLatitude, Composite:GPSLatitude, XMP:GPSLongitude, Composite:GPSLongitude | Composite tags are read-only |
+| Title (photo) | XMP:Title | |
+| Title (video, audio) | XMP:DisplayName | |
+| Album | XMP:Album | User defined tag in `configs/ExifTool_config` |
-```
-git clone https://github.com/jmathai/elodie.git
-```
-
-The commands on this page assume you're running them from the root of this repository. I don't have any submodules but you'll need to install the following packages.
-
-```
-pip install -r requirements.txt
-```
-
-You'll need to install *exiftool* *pyexiv2* using `homebrew` on OSX. If you're running another operating system you're sort of on your own but my pal Google should be able to help. Some folks may be able to simply run these commands. Installing *boost* is a drag and can take up to 30 minutes. Don't say I didn't warn you.
-
-```
-brew update
-brew install exiftool
-brew install boost --build-from-source
-brew install pyexiv2
-```
-
-On Debian and Ubuntu you can install dependencies using `apt-get`.
-
-```
-apt-get install libimage-exiftool-perl
-apt-get install python-pyexiv2
-```
-
-On Fedora / Redhat you can install dependencies using `dnf` (fedora) or yum (redhat)
-
-```
-dnf install perl-Image-ExifTool
-dnf install pyexiv2
-```
-
-On Windows you can download and install pre-built binaries:
-
-* [exiftool](http://www.sno.phy.queensu.ca/~phil/exiftool/install.html)
-* [pyexiv2](http://tilloy.net/dev/pyexiv2/download.html)
-
-
-If you have problems you can run the following commands which the fine folks at StackOverflow [suggested to me once](http://stackoverflow.com/a/18817419/1318758).
-
-### Using OpenStreetMap data from MapQuest
+## Using OpenStreetMap data from MapQuest
I use MapQuest to help me organize your photos by location. You'll need to sign up for a [free developer account](https://developer.mapquest.com/plan_purchase/steps/business_edition/business_edition_free) and get an API key. They give you 15,000 calls per month so I can't do any more than that unless you shell out some big bucks to them. Once I hit my limit the best I'll be able to do is *Unknown Location* until the following month.
diff --git a/elodie/dependencies.py b/elodie/dependencies.py
index 8183594..09ab6b8 100644
--- a/elodie/dependencies.py
+++ b/elodie/dependencies.py
@@ -16,16 +16,6 @@ Please take a look at the installation steps in the readme:
https://github.com/jmathai/elodie#install-everything-you-need
""".lstrip()
-#: Template for the error to print when pyexiv2 can't be found.
-PYEXIV2_ERROR = u"""
-{error_class_name}: {error}
-
-It looks like you don't have pyexiv2 installed, which Elodie requires for
-geolocation. Please take a look at the installation steps in the readme:
-
-https://github.com/jmathai/elodie#install-everything-you-need
-""".lstrip()
-
def get_exiftool():
"""Get path to executable exiftool binary.
@@ -56,11 +46,4 @@ def verify_dependencies():
print >>sys.stderr, EXIFTOOL_ERROR
return False
- try:
- import pyexiv2 # noqa
- except ImportError as e:
- print >>sys.stderr, PYEXIV2_ERROR.format(
- error_class_name=e.__class__.__name__, error=e)
- return False
-
return True
diff --git a/elodie/external/__init__.py b/elodie/external/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/elodie/external/pyexiftool.py b/elodie/external/pyexiftool.py
new file mode 100644
index 0000000..540ecc5
--- /dev/null
+++ b/elodie/external/pyexiftool.py
@@ -0,0 +1,469 @@
+# -*- coding: utf-8 -*-
+# PyExifTool
+# Copyright 2012 Sven Marnach. Enhancements by Leo Broska
+
+# This file is part of PyExifTool.
+#
+# PyExifTool is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# PyExifTool is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with PyExifTool. If not, see .
+
+"""
+PyExifTool is a Python library to communicate with an instance of Phil
+Harvey's excellent ExifTool_ command-line application. The library
+provides the class :py:class:`ExifTool` that runs the command-line
+tool in batch mode and features methods to send commands to that
+program, including methods to extract meta-information from one or
+more image files. Since ``exiftool`` is run in batch mode, only a
+single instance needs to be launched and can be reused for many
+queries. This is much more efficient than launching a separate
+process for every single query.
+
+.. _ExifTool: http://www.sno.phy.queensu.ca/~phil/exiftool/
+
+The source code can be checked out from the github repository with
+
+::
+
+ git clone git://github.com/smarnach/pyexiftool.git
+
+Alternatively, you can download a tarball_. There haven't been any
+releases yet.
+
+.. _tarball: https://github.com/smarnach/pyexiftool/tarball/master
+
+PyExifTool is licenced under GNU GPL version 3 or later.
+
+Example usage::
+
+ import exiftool
+
+ files = ["a.jpg", "b.png", "c.tif"]
+ with exiftool.ExifTool() as et:
+ metadata = et.get_metadata_batch(files)
+ for d in metadata:
+ print("{:20.20} {:20.20}".format(d["SourceFile"],
+ d["EXIF:DateTimeOriginal"]))
+"""
+
+from __future__ import unicode_literals
+
+import sys
+import subprocess
+import os
+import json
+import warnings
+import logging
+import codecs
+
+try: # Py3k compatibility
+ basestring
+except NameError:
+ basestring = (bytes, str)
+
+executable = "exiftool"
+"""The name of the executable to run.
+
+If the executable is not located in one of the paths listed in the
+``PATH`` environment variable, the full path should be given here.
+"""
+
+# Sentinel indicating the end of the output of a sequence of commands.
+# The standard value should be fine.
+sentinel = b"{ready}"
+
+# The block size when reading from exiftool. The standard value
+# should be fine, though other values might give better performance in
+# some cases.
+block_size = 4096
+
+# constants related to keywords manipulations
+KW_TAGNAME = "IPTC:Keywords"
+KW_REPLACE, KW_ADD, KW_REMOVE = range(3)
+
+
+# This code has been adapted from Lib/os.py in the Python source tree
+# (sha1 265e36e277f3)
+def _fscodec():
+ encoding = sys.getfilesystemencoding()
+ errors = "strict"
+ if encoding != "mbcs":
+ try:
+ codecs.lookup_error("surrogateescape")
+ except LookupError:
+ pass
+ else:
+ errors = "surrogateescape"
+
+ def fsencode(filename):
+ """
+ Encode filename to the filesystem encoding with 'surrogateescape' error
+ handler, return bytes unchanged. On Windows, use 'strict' error handler if
+ the file system encoding is 'mbcs' (which is the default encoding).
+ """
+ if isinstance(filename, bytes):
+ return filename
+ else:
+ return filename.encode(encoding, errors)
+
+ return fsencode
+
+fsencode = _fscodec()
+del _fscodec
+
+#string helper
+def strip_nl (s):
+ return ' '.join(s.splitlines())
+
+
+# Error checking function
+# Note: They are quite fragile, beacsue teh just parse the output text from exiftool
+def check_ok (result):
+ """Evaluates the output from a exiftool write operation (e.g. `set_tags`)
+
+ The argument is the result from the execute method.
+
+ The result is True or False.
+ """
+ return not result is None and (not "due to errors" in result)
+
+def format_error (result):
+ """Evaluates the output from a exiftool write operation (e.g. `set_tags`)
+
+ The argument is the result from the execute method.
+
+ The result is a human readable one-line string.
+ """
+ if check_ok (result):
+ return 'exiftool finished probably properly. ("%s")' % strip_nl(result)
+ else:
+ if result is None:
+ return "exiftool operation can't be evaluated: No result given"
+ else:
+ return 'exiftool finished with error: "%s"' % strip_nl(result)
+
+
+class ExifTool(object):
+ """Run the `exiftool` command-line tool and communicate to it.
+
+ You can pass two arguments to the constructor:
+ - ``addedargs`` (list of strings): contains additional paramaters for
+ the stay-open instance of exiftool
+ - ``executable`` (string): file name of the ``exiftool`` executable.
+ The default value ``exiftool`` will only work if the executable
+ is in your ``PATH``
+
+ Most methods of this class are only available after calling
+ :py:meth:`start()`, which will actually launch the subprocess. To
+ avoid leaving the subprocess running, make sure to call
+ :py:meth:`terminate()` method when finished using the instance.
+ This method will also be implicitly called when the instance is
+ garbage collected, but there are circumstance when this won't ever
+ happen, so you should not rely on the implicit process
+ termination. Subprocesses won't be automatically terminated if
+ the parent process exits, so a leaked subprocess will stay around
+ until manually killed.
+
+ A convenient way to make sure that the subprocess is terminated is
+ to use the :py:class:`ExifTool` instance as a context manager::
+
+ with ExifTool() as et:
+ ...
+
+ .. warning:: Note that there is no error handling. Nonsensical
+ options will be silently ignored by exiftool, so there's not
+ much that can be done in that regard. You should avoid passing
+ non-existent files to any of the methods, since this will lead
+ to undefied behaviour.
+
+ .. py:attribute:: running
+
+ A Boolean value indicating whether this instance is currently
+ associated with a running subprocess.
+ """
+
+ def __init__(self, executable_=None, addedargs=None):
+
+ if executable_ is None:
+ self.executable = executable
+ else:
+ self.executable = executable_
+
+ if addedargs is None:
+ self.addedargs = []
+ elif type(addedargs) is list:
+ self.addedargs = addedargs
+ else:
+ raise TypeError("addedargs not a list of strings")
+
+ self.running = False
+
+ def start(self):
+ """Start an ``exiftool`` process in batch mode for this instance.
+
+ This method will issue a ``UserWarning`` if the subprocess is
+ already running. The process is started with the ``-G`` and
+ ``-n`` as common arguments, which are automatically included
+ in every command you run with :py:meth:`execute()`.
+ """
+ if self.running:
+ warnings.warn("ExifTool already running; doing nothing.")
+ return
+ with open(os.devnull, "w") as devnull:
+ procargs = [self.executable, "-stay_open", "True", "-@", "-",
+ "-common_args", "-G", "-n"];
+ procargs.extend(self.addedargs)
+ logging.debug(procargs)
+ self._process = subprocess.Popen(
+ procargs,
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=devnull)
+ self.running = True
+
+ def terminate(self):
+ """Terminate the ``exiftool`` process of this instance.
+
+ If the subprocess isn't running, this method will do nothing.
+ """
+ if not self.running:
+ return
+ self._process.stdin.write(b"-stay_open\nFalse\n")
+ self._process.stdin.flush()
+ self._process.communicate()
+ del self._process
+ self.running = False
+
+ def __enter__(self):
+ self.start()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.terminate()
+
+ def __del__(self):
+ self.terminate()
+
+ def execute(self, *params):
+ """Execute the given batch of parameters with ``exiftool``.
+
+ This method accepts any number of parameters and sends them to
+ the attached ``exiftool`` process. The process must be
+ running, otherwise ``ValueError`` is raised. The final
+ ``-execute`` necessary to actually run the batch is appended
+ automatically; see the documentation of :py:meth:`start()` for
+ the common options. The ``exiftool`` output is read up to the
+ end-of-output sentinel and returned as a raw ``bytes`` object,
+ excluding the sentinel.
+
+ The parameters must also be raw ``bytes``, in whatever
+ encoding exiftool accepts. For filenames, this should be the
+ system's filesystem encoding.
+
+ .. note:: This is considered a low-level method, and should
+ rarely be needed by application developers.
+ """
+ if not self.running:
+ raise ValueError("ExifTool instance not running.")
+ cmd_txt = b"\n".join(params + (b"-execute\n",))
+ self._process.stdin.write(cmd_txt.encode("utf-8"))
+ self._process.stdin.flush()
+ output = b""
+ fd = self._process.stdout.fileno()
+ while not output[-32:].strip().endswith(sentinel):
+ output += os.read(fd, block_size)
+ return output.strip()[:-len(sentinel)]
+
+ def execute_json(self, *params):
+ """Execute the given batch of parameters and parse the JSON output.
+
+ This method is similar to :py:meth:`execute()`. It
+ automatically adds the parameter ``-j`` to request JSON output
+ from ``exiftool`` and parses the output. The return value is
+ a list of dictionaries, mapping tag names to the corresponding
+ values. All keys are Unicode strings with the tag names
+ including the ExifTool group name in the format :.
+ The values can have multiple types. All strings occurring as
+ values will be Unicode strings. Each dictionary contains the
+ name of the file it corresponds to in the key ``"SourceFile"``.
+
+ The parameters to this function must be either raw strings
+ (type ``str`` in Python 2.x, type ``bytes`` in Python 3.x) or
+ Unicode strings (type ``unicode`` in Python 2.x, type ``str``
+ in Python 3.x). Unicode strings will be encoded using
+ system's filesystem encoding. This behaviour means you can
+ pass in filenames according to the convention of the
+ respective Python version – as raw strings in Python 2.x and
+ as Unicode strings in Python 3.x.
+ """
+ params = map(fsencode, params)
+ return json.loads(self.execute(b"-j", *params).decode("utf-8"))
+
+ def get_metadata_batch(self, filenames):
+ """Return all meta-data for the given files.
+
+ The return value will have the format described in the
+ documentation of :py:meth:`execute_json()`.
+ """
+ return self.execute_json(*filenames)
+
+ def get_metadata(self, filename):
+ """Return meta-data for a single file.
+
+ The returned dictionary has the format described in the
+ documentation of :py:meth:`execute_json()`.
+ """
+ return self.execute_json(filename)[0]
+
+ def get_tags_batch(self, tags, filenames):
+ """Return only specified tags for the given files.
+
+ The first argument is an iterable of tags. The tag names may
+ include group names, as usual in the format :.
+
+ The second argument is an iterable of file names.
+
+ The format of the return value is the same as for
+ :py:meth:`execute_json()`.
+ """
+ # Explicitly ruling out strings here because passing in a
+ # string would lead to strange and hard-to-find errors
+ if isinstance(tags, basestring):
+ raise TypeError("The argument 'tags' must be "
+ "an iterable of strings")
+ if isinstance(filenames, basestring):
+ raise TypeError("The argument 'filenames' must be "
+ "an iterable of strings")
+ params = ["-" + t for t in tags]
+ params.extend(filenames)
+ return self.execute_json(*params)
+
+ def get_tags(self, tags, filename):
+ """Return only specified tags for a single file.
+
+ The returned dictionary has the format described in the
+ documentation of :py:meth:`execute_json()`.
+ """
+ return self.get_tags_batch(tags, [filename])[0]
+
+ def get_tag_batch(self, tag, filenames):
+ """Extract a single tag from the given files.
+
+ The first argument is a single tag name, as usual in the
+ format :.
+
+ The second argument is an iterable of file names.
+
+ The return value is a list of tag values or ``None`` for
+ non-existent tags, in the same order as ``filenames``.
+ """
+ data = self.get_tags_batch([tag], filenames)
+ result = []
+ for d in data:
+ d.pop("SourceFile")
+ result.append(next(iter(d.values()), None))
+ return result
+
+ def get_tag(self, tag, filename):
+ """Extract a single tag from a single file.
+
+ The return value is the value of the specified tag, or
+ ``None`` if this tag was not found in the file.
+ """
+ return self.get_tag_batch(tag, [filename])[0]
+
+ def set_tags_batch(self, tags, filenames):
+ """Writes the values of the specified tags for the given files.
+
+ The first argument is a dictionary of tags and values. The tag names may
+ include group names, as usual in the format :.
+
+ The second argument is an iterable of file names.
+
+ The format of the return value is the same as for
+ :py:meth:`execute()`.
+
+ It can be passed into `check_ok()` and `format_error()`.
+ """
+ # Explicitly ruling out strings here because passing in a
+ # string would lead to strange and hard-to-find errors
+ if isinstance(tags, basestring):
+ raise TypeError("The argument 'tags' must be dictionary "
+ "of strings")
+ if isinstance(filenames, basestring):
+ raise TypeError("The argument 'filenames' must be "
+ "an iterable of strings")
+
+ params = []
+ for tag, value in tags.items():
+ params.append(u'-%s=%s' % (tag, value))
+
+ params.extend(filenames)
+ return self.execute(*params)
+
+ def set_tags(self, tags, filename):
+ """Writes the values of the specified tags for the given file.
+
+ This is a convenience function derived from `set_tags_batch()`.
+ Only difference is that it takes as last arugemnt only one file name
+ as a string.
+ """
+ return self.set_tags_batch(tags, [filename])
+
+ def set_keywords_batch(self, mode, keywords, filenames):
+ """Modifies the keywords tag for the given files.
+
+ The first argument is the operation mode:
+ KW_REPLACE: Replace (i.e. set) the full keywords tag with `keywords`.
+ KW_ADD: Add `keywords` to the keywords tag.
+ If a keyword is present, just keep it.
+ KW_REMOVE: Remove `keywords` from the keywords tag.
+ If a keyword wasn't present, just leave it.
+
+ The second argument is an iterable of key words.
+
+ The third argument is an iterable of file names.
+
+ The format of the return value is the same as for
+ :py:meth:`execute()`.
+
+ It can be passed into `check_ok()` and `format_error()`.
+ """
+ # Explicitly ruling out strings here because passing in a
+ # string would lead to strange and hard-to-find errors
+ if isinstance(keywords, basestring):
+ raise TypeError("The argument 'keywords' must be "
+ "an iterable of strings")
+ if isinstance(filenames, basestring):
+ raise TypeError("The argument 'filenames' must be "
+ "an iterable of strings")
+
+ params = []
+
+ kw_operation = {KW_REPLACE:"-%s=%s",
+ KW_ADD:"-%s+=%s",
+ KW_REMOVE:"-%s-=%s"}[mode]
+
+ kw_params = [ kw_operation % (KW_TAGNAME, w) for w in keywords ]
+
+ params.extend(kw_params)
+ params.extend(filenames)
+ logging.debug (params)
+ return self.execute(*params)
+
+ def set_keywords(self, mode, keywords, filename):
+ """Modifies the keywords tag for the given file.
+
+ This is a convenience function derived from `set_keywords_batch()`.
+ Only difference is that it takes as last argument only one file name
+ as a string.
+ """
+ return self.set_keywords_batch(mode, keywords, [filename])
diff --git a/elodie/geolocation.py b/elodie/geolocation.py
index 0e3280c..f83bdd1 100644
--- a/elodie/geolocation.py
+++ b/elodie/geolocation.py
@@ -3,7 +3,6 @@
from os import path
from ConfigParser import ConfigParser
import fractions
-import pyexiv2
import requests
import urllib
@@ -74,33 +73,14 @@ def coordinates_by_name(name):
return None
-def decimal_to_dms(decimal, signed=True):
- # if decimal is negative we need to make the degrees and minutes
- # negative also
- sign = 1
- if(decimal < 0):
- sign = -1
-
- # http://anothergisblog.blogspot.com/2011/11/convert-decimal-degree-to-degrees.html # noqa
- degrees = int(decimal)
- subminutes = abs((decimal - int(decimal)) * 60)
- minutes = int(subminutes) * sign
- subseconds = abs((subminutes - int(subminutes)) * 60) * sign
- subseconds_fraction = Fraction(subseconds)
-
- if(signed is False):
- degrees = abs(degrees)
- minutes = abs(minutes)
- subseconds_fraction = Fraction(abs(subseconds))
-
- return (
- pyexiv2.Rational(degrees, 1),
- pyexiv2.Rational(minutes, 1),
- pyexiv2.Rational(
- subseconds_fraction.numerator,
- subseconds_fraction.denominator
- )
- )
+def decimal_to_dms(decimal):
+ decimal = float(decimal)
+ decimal_abs = abs(decimal)
+ minutes,seconds = divmod(decimal_abs*3600,60)
+ degrees,minutes = divmod(minutes,60)
+ degrees = degrees
+ sign = 1 if decimal >= 0 else -1
+ return (degrees,minutes,seconds, sign)
def dms_to_decimal(degrees, minutes, seconds, direction=' '):
@@ -112,6 +92,16 @@ def dms_to_decimal(degrees, minutes, seconds, direction=' '):
) * sign
+def dms_string(decimal, type='latitude'):
+ # Example string -> 38 deg 14' 27.82" S
+ dms = decimal_to_dms(decimal)
+ if type == 'latitude':
+ direction = 'N' if decimal >= 0 else 'S'
+ elif type == 'longitude':
+ direction = 'E' if decimal >= 0 else 'W'
+ return '{} deg {}\' {}" {}'.format(dms[0], dms[1], dms[2], direction)
+
+
def get_key():
config_file = '%s/config.ini' % constants.application_directory
if not path.exists(config_file):
diff --git a/elodie/media/media.py b/elodie/media/media.py
index 2b53edc..2bb7846 100644
--- a/elodie/media/media.py
+++ b/elodie/media/media.py
@@ -12,10 +12,10 @@ are used to represent the actual files.
# load modules
from elodie import constants
from elodie.dependencies import get_exiftool
+from elodie.external.pyexiftool import ExifTool
from elodie.media.base import Base
import os
-import pyexiv2
import re
import subprocess
@@ -37,12 +37,24 @@ class Media(Base):
def __init__(self, source=None):
super(Media, self).__init__(source)
self.exif_map = {
- 'date_taken': ['Exif.Photo.DateTimeOriginal', 'Exif.Image.DateTime', 'Exif.Photo.DateTimeDigitized'], # , 'EXIF FileDateTime'], # noqa
- 'latitude': 'Exif.GPSInfo.GPSLatitude',
- 'latitude_ref': 'Exif.GPSInfo.GPSLatitudeRef',
- 'longitude': 'Exif.GPSInfo.GPSLongitude',
- 'longitude_ref': 'Exif.GPSInfo.GPSLongitudeRef',
+ 'date_taken': [
+ 'EXIF:DateTimeOriginal',
+ 'EXIF:DateTime',
+ 'EXIF:DateTimeDigitized'
+ ]
}
+ self.album_key = 'XMP:Album'
+ self.title_key = 'XMP:Title'
+ self.latitude_keys = ['EXIF:GPSLatitude']
+ self.longitude_keys = ['EXIF:GPSLongitude']
+ self.latitude_ref_key = 'EXIF:GPSLatitudeRef'
+ self.longitude_ref_key = 'EXIF:GPSLongitudeRef'
+ self.set_gps_ref = True
+ self.exiftool_addedargs = [
+ '-overwrite_original',
+ u'-config',
+ u'"{}"'.format(constants.exiftool_config)
+ ]
def get_album(self):
"""Get album from EXIF
@@ -53,74 +65,60 @@ class Media(Base):
return None
exiftool_attributes = self.get_exiftool_attributes()
- if(exiftool_attributes is None or 'album' not in exiftool_attributes):
+ if exiftool_attributes is None:
return None
- return exiftool_attributes['album']
+ if self.album_key not in exiftool_attributes:
+ return None
- def get_exif(self):
- """Read EXIF from a photo file.
+ return exiftool_attributes[self.album_key]
- We store the result in a member variable so we can call get_exif()
- often without performance degredation.
+ def get_coordinate(self, type='latitude'):
+ """Get latitude or longitude of media from EXIF
- :returns: list or none for a non-photo file
+ :param str type: Type of coordinate to get. Either "latitude" or
+ "longitude".
+ :returns: float or None if not present in EXIF or a non-photo file
"""
- if(not self.is_valid()):
+
+ exif = self.get_exiftool_attributes()
+ if not exif:
return None
- if(self.exif is not None):
- return self.exif
+ # The lat/lon _keys array has an order of precedence.
+ # The first key is writable and we will give the writable
+ # key precence when reading.
+ direction_multiplier = 1
+ for key in self.latitude_keys + self.longitude_keys:
+ # TODO: verify that we need to check ref key
+ # when self.set_gps_ref != True
+ if type == 'latitude' and key in self.latitude_keys and key in exif:
+ if self.latitude_ref_key in exif and exif[self.latitude_ref_key] == 'S': #noqa
+ direction_multiplier = -1
+ return exif[key] * direction_multiplier
+ elif type == 'longitude' and key in self.longitude_keys and key in exif: #noqa
+ if self.longitude_ref_key in exif and exif[self.longitude_ref_key] == 'W': #noqa
+ direction_multiplier = -1
+ return exif[key] * direction_multiplier
- source = self.source
- self.exif = pyexiv2.ImageMetadata(source)
- self.exif.read()
-
- return self.exif
+ return None
def get_exiftool_attributes(self):
"""Get attributes for the media object from exiftool.
:returns: dict, or False if exiftool was not available.
"""
- if(self.exiftool_attributes is not None):
- return self.exiftool_attributes
-
+ source = self.source
exiftool = get_exiftool()
if(exiftool is None):
return False
- source = self.source
- process_output = subprocess.Popen(
- '%s "%s"' % (exiftool, source),
- stdout=subprocess.PIPE,
- shell=True,
- universal_newlines=True
- )
- output = process_output.stdout.read()
+ with ExifTool(addedargs=self.exiftool_addedargs) as et:
+ metadata = et.get_metadata(source)
+ if not metadata:
+ return False
- # Get album from exiftool output
- album = None
- album_regex = re.search('Album +: +(.+)', output)
- if(album_regex is not None):
- album = album_regex.group(1)
-
- # Get title from exiftool output
- title = None
- for key in ['Displayname', 'Headline', 'Title', 'ImageDescription']:
- title_regex = re.search('%s +: +(.+)' % key, output)
- if(title_regex is not None):
- title_return = title_regex.group(1).strip()
- if(len(title_return) > 0):
- title = title_return
- break
-
- self.exiftool_attributes = {
- 'album': album,
- 'title': title
- }
-
- return self.exiftool_attributes
+ return metadata
def get_title(self):
"""Get the title for a photo of video
@@ -132,10 +130,13 @@ class Media(Base):
exiftool_attributes = self.get_exiftool_attributes()
- if(exiftool_attributes is None or 'title' not in exiftool_attributes):
+ if exiftool_attributes is None:
return None
- return exiftool_attributes['title']
+ if(self.title_key not in exiftool_attributes):
+ return None
+
+ return exiftool_attributes[self.title_key]
def reset_cache(self):
"""Resets any internal cache
@@ -143,41 +144,100 @@ class Media(Base):
self.exiftool_attributes = None
super(Media, self).reset_cache()
- def set_album(self, name):
+ def set_album(self, album):
"""Set album for a photo
:param str name: Name of album
:returns: bool
"""
- if(name is None):
- return False
+ if(not self.is_valid()):
+ return None
- exiftool = get_exiftool()
- if(exiftool is None):
+ source = self.source
+
+ tags = {self.album_key: album}
+ status = self.__set_tags(tags)
+ self.reset_cache()
+
+ return status
+
+ def set_date_taken(self, time):
+ """Set the date/time a photo was taken.
+
+ :param datetime time: datetime object of when the photo was taken
+ :returns: bool
+ """
+ if(time is None):
return False
source = self.source
- stat = os.stat(source)
- exiftool_config = constants.exiftool_config
- if(constants.debug is True):
- print '%s -config "%s" -xmp-elodie:Album="%s" "%s"' % (exiftool, exiftool_config, name, source) # noqa
- process_output = subprocess.Popen(
- '%s -config "%s" -xmp-elodie:Album="%s" "%s"' %
- (exiftool, exiftool_config, name, source),
- stdout=subprocess.PIPE,
- shell=True
- )
- process_output.communicate()
- if(process_output.returncode != 0):
- return False
+ tags = {}
+ formatted_time = time.strftime('%Y:%m:%d %H:%M:%S')
+ for key in self.exif_map['date_taken']:
+ tags[key] = formatted_time
- os.utime(source, (stat.st_atime, stat.st_mtime))
-
- exiftool_backup_file = '%s%s' % (source, '_original')
- if(os.path.isfile(exiftool_backup_file) is True):
- os.remove(exiftool_backup_file)
-
- self.set_metadata(album=name)
+ status = self.__set_tags(tags)
self.reset_cache()
- return True
+ return status
+
+ def set_location(self, latitude, longitude):
+ if(not self.is_valid()):
+ return None
+
+ source = self.source
+
+ # The lat/lon _keys array has an order of precedence.
+ # The first key is writable and we will give the writable
+ # key precence when reading.
+ tags = {
+ self.latitude_keys[0]: latitude,
+ self.longitude_keys[0]: longitude,
+ }
+
+ # If self.set_gps_ref == True then it means we are writing an EXIF
+ # GPS tag which requires us to set the reference key.
+ # That's because the lat/lon are absolute values.
+ if self.set_gps_ref:
+ if latitude < 0:
+ tags[self.latitude_ref_key] = 'S'
+
+ if longitude < 0:
+ tags[self.longitude_ref_key] = 'W'
+
+ status = self.__set_tags(tags)
+ self.reset_cache()
+
+ return status
+
+ def set_title(self, title):
+ """Set title for a photo.
+
+ :param str title: Title of the photo.
+ :returns: bool
+ """
+ if(not self.is_valid()):
+ return None
+
+ if(title is None):
+ return None
+
+ source = self.source
+
+ tags = {self.title_key: title}
+ status = self.__set_tags(tags)
+ self.reset_cache()
+
+ return status
+
+ def __set_tags(self, tags):
+ if(not self.is_valid()):
+ return None
+
+ source = self.source
+
+ status = ''
+ with ExifTool(addedargs=self.exiftool_addedargs) as et:
+ status = et.set_tags(tags, source)
+
+ return status != ''
diff --git a/elodie/media/photo.py b/elodie/media/photo.py
index 5086a47..5d994e6 100644
--- a/elodie/media/photo.py
+++ b/elodie/media/photo.py
@@ -7,14 +7,17 @@ image objects (JPG, DNG, etc.).
import imghdr
import os
-import pyexiv2
import re
import subprocess
import time
+from datetime import datetime
+from re import compile
+
from elodie import constants
-from media import Media
from elodie import geolocation
+from elodie.external.pyexiftool import ExifTool
+from media import Media
class Photo(Media):
@@ -57,34 +60,6 @@ class Photo(Media):
).group(1).replace('.', ':')
return None
- def get_coordinate(self, type='latitude'):
- """Get latitude or longitude of photo from EXIF
-
- :param str type: Type of coordinate to get. Either "latitude" or
- "longitude".
- :returns: float or None if not present in EXIF or a non-photo file
- """
- if(not self.is_valid()):
- return None
-
- key = self.exif_map[type]
- exif = self.get_exif()
-
- if(key not in exif):
- return None
-
- try:
- # this is a hack to get the proper direction by negating the
- # values for S and W
- coords = exif[key].value
- return geolocation.dms_to_decimal(
- *coords,
- direction=exif[self.exif_map[self.d_coordinates[type]]].value
- )
-
- except KeyError:
- return None
-
def get_date_taken(self):
"""Get the date which the photo was taken.
@@ -94,22 +69,31 @@ class Photo(Media):
"""
if(not self.is_valid()):
return None
-
+
source = self.source
seconds_since_epoch = min(os.path.getmtime(source), os.path.getctime(source)) # noqa
+
+ exif = self.get_exiftool_attributes()
+ if not exif:
+ return seconds_since_epoch
+
# We need to parse a string from EXIF into a timestamp.
# EXIF DateTimeOriginal and EXIF DateTime are both stored
# in %Y:%m:%d %H:%M:%S format
- # we use date.strptime -> .timetuple -> time.mktime to do
+ # we use split on a space and then r':|-' -> convert to int -> .timetuple()
# the conversion in the local timezone
# EXIF DateTime is already stored as a timestamp
# Sourced from https://github.com/photo/frontend/blob/master/src/libraries/models/Photo.php#L500 # noqa
- exif = self.get_exif()
for key in self.exif_map['date_taken']:
try:
if(key in exif):
- if(re.match('\d{4}(-|:)\d{2}(-|:)\d{2}', str(exif[key].value)) is not None): # noqa
- seconds_since_epoch = time.mktime(exif[key].value.timetuple()) # noqa
+ if(re.match('\d{4}(-|:)\d{2}(-|:)\d{2}', exif[key]) is not None): # noqa
+ dt, tm = exif[key].split(' ')
+ dt_list = compile(r'-|:').split(dt)
+ dt_list = dt_list + compile(r'-|:').split(tm)
+ dt_list = map(int, dt_list)
+ time_tuple = datetime(*dt_list).timetuple()
+ seconds_since_epoch = time.mktime(time_tuple)
break
except BaseException as e:
if(constants.debug is True):
@@ -137,70 +121,3 @@ class Photo(Media):
return False
return os.path.splitext(source)[1][1:].lower() in self.extensions
-
- def set_date_taken(self, time):
- """Set the date/time a photo was taken.
-
- :param datetime time: datetime object of when the photo was taken
- :returns: bool
- """
- if(time is None):
- return False
-
- source = self.source
- exif_metadata = pyexiv2.ImageMetadata(source)
- exif_metadata.read()
-
- # Writing exif with pyexiv2 differs if the key already exists so we
- # handle both cases here.
- for key in self.exif_map['date_taken']:
- if(key in exif_metadata):
- exif_metadata[key].value = time
- else:
- exif_metadata[key] = pyexiv2.ExifTag(key, time)
-
- exif_metadata.write()
- self.reset_cache()
- return True
-
- def set_location(self, latitude, longitude):
- """Set latitude and longitude for a photo.
-
- :param float latitude: Latitude of the file
- :param float longitude: Longitude of the file
- :returns: bool
- """
- if(latitude is None or longitude is None):
- return False
-
- source = self.source
- exif_metadata = pyexiv2.ImageMetadata(source)
- exif_metadata.read()
-
- exif_metadata['Exif.GPSInfo.GPSLatitude'] = geolocation.decimal_to_dms(latitude, False) # noqa
- exif_metadata['Exif.GPSInfo.GPSLatitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLatitudeRef', 'N' if latitude >= 0 else 'S') # noqa
- exif_metadata['Exif.GPSInfo.GPSLongitude'] = geolocation.decimal_to_dms(longitude, False) # noqa
- exif_metadata['Exif.GPSInfo.GPSLongitudeRef'] = pyexiv2.ExifTag('Exif.GPSInfo.GPSLongitudeRef', 'E' if longitude >= 0 else 'W') # noqa
-
- exif_metadata.write()
- self.reset_cache()
- return True
-
- def set_title(self, title):
- """Set title for a photo.
-
- :param str title: Title of the photo.
- :returns: bool
- """
- if(title is None):
- return False
-
- source = self.source
- exif_metadata = pyexiv2.ImageMetadata(source)
- exif_metadata.read()
-
- exif_metadata['Xmp.dc.title'] = title
-
- exif_metadata.write()
- self.reset_cache()
- return True
diff --git a/elodie/media/video.py b/elodie/media/video.py
index c4b517d..7083294 100644
--- a/elodie/media/video.py
+++ b/elodie/media/video.py
@@ -37,6 +37,25 @@ class Video(Media):
def __init__(self, source=None):
super(Video, self).__init__(source)
+ self.exif_map['date_taken'] = [
+ 'QuickTime:CreationDate',
+ 'QuickTime:CreationDate-und-US',
+ 'QuickTime:MediaCreateDate'
+ ]
+ self.title_key = 'XMP:DisplayName'
+ self.latitude_keys = [
+ 'XMP:GPSLatitude',
+ #'QuickTime:GPSLatitude',
+ 'Composite:GPSLatitude'
+ ]
+ self.longitude_keys = [
+ 'XMP:GPSLongitude',
+ #'QuickTime:GPSLongitude',
+ 'Composite:GPSLongitude'
+ ]
+ self.latitude_ref_key = 'EXIF:GPSLatitudeRef'
+ self.longitude_ref_key = 'EXIF:GPSLongitudeRef'
+ self.set_gps_ref = False
def get_avmetareadwrite(self):
"""Get path to executable avmetareadwrite binary.
@@ -53,363 +72,50 @@ class Video(Media):
return avmetareadwrite
- def get_coordinate(self, type='latitude'):
- """Get latitude or longitude of photo from EXIF.
-
- :returns: time object or None for non-video files or 0 timestamp
- """
- exif_data = self.get_exif()
- if(exif_data is None):
- return None
-
- coords = re.findall('(GPS %s +: .+)' % type.capitalize(), exif_data)
- if(coords is None or len(coords) == 0):
- return None
-
- coord_string = coords[0]
- coordinate = re.findall('([0-9.]+)', coord_string)
- direction = re.search('[NSEW]$', coord_string)
- if(coordinate is None or direction is None):
- return None
-
- direction = direction.group(0)
-
- decimal_degrees = float(coordinate[0]) + float(coordinate[1])/60 + float(coordinate[2])/3600 # noqa
- if(direction == 'S' or direction == 'W'):
- decimal_degrees = decimal_degrees * -1
-
- return decimal_degrees
-
def get_date_taken(self):
- """Get the date which the video was taken.
+ """Get the date which the photo was taken.
The date value returned is defined by the min() of mtime and ctime.
- :returns: time object or None for non-video files or 0 timestamp
+ :returns: time object or None for non-photo files or 0 timestamp
"""
if(not self.is_valid()):
return None
-
+
source = self.source
- # We need to parse a string from EXIF into a timestamp.
- # We use date.strptime -> .timetuple -> time.mktime to do the
- # conversion in the local timezone
- # If the time is not found in EXIF we update EXIF
seconds_since_epoch = min(os.path.getmtime(source), os.path.getctime(source)) # noqa
- exif_data = self.get_exif()
- for key in ['Creation Date', 'Creation Date \(und-US\)', 'Media Create Date']: # noqa
- date = re.search('%s +: +([0-9: ]+)' % key, exif_data)
- if(date is not None):
- date_string = date.group(1)
- try:
- exif_seconds_since_epoch = time.mktime(
- datetime.strptime(
- date_string,
- '%Y:%m:%d %H:%M:%S'
- ).timetuple()
- )
- if(exif_seconds_since_epoch < seconds_since_epoch):
- seconds_since_epoch = exif_seconds_since_epoch
- break
- except:
- pass
+
+ exif = self.get_exiftool_attributes()
+ for date_key in self.exif_map['date_taken']:
+ if date_key in exif:
+ # Example date strings we want to parse
+ # 2015:01:19 12:45:11-08:00
+ # 2013:09:30 07:06:05
+ date = re.search('([0-9: ]+)([-+][0-9:]+)?', exif[date_key])
+ if(date is not None):
+ date_string = date.group(1)
+ date_offset = date.group(2)
+ try:
+ exif_seconds_since_epoch = time.mktime(
+ datetime.strptime(
+ date_string,
+ '%Y:%m:%d %H:%M:%S'
+ ).timetuple()
+ )
+ if(exif_seconds_since_epoch < seconds_since_epoch):
+ seconds_since_epoch = exif_seconds_since_epoch
+ if date_offset is not None:
+ offset_parts = date_offset[1:].split(':')
+ offset_seconds = int(offset_parts[0]) * 3600
+ offset_seconds = offset_seconds + int(offset_parts[1]) * 60 #noqa
+ if date_offset[0] == '-':
+ seconds_since_epoch - offset_seconds
+ elif date_offset[0] == '+':
+ seconds_since_epoch + offset_seconds
+ except:
+ pass
if(seconds_since_epoch == 0):
return None
return time.gmtime(seconds_since_epoch)
-
- def get_duration(self):
- """Get the duration of a video in seconds.
-
- This uses ffmpeg/ffprobe.
-
- :returns: str or None for a non-video file
- """
- if(not self.is_valid()):
- return None
-
- source = self.source
- result = subprocess.Popen(
- ['ffprobe', source],
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT
- )
- for key in result.stdout.readlines():
- if 'Duration' in key:
- return re.search(
- '(\d{2}:\d{2}.\d{2})',
- key
- ).group(1).replace('.', ':')
- return None
-
- def get_exif(self):
- """Get exif data from video file.
-
- Not all video files have exif and this currently relies on the CLI
- exiftool program.
-
- :returns: str or None if exiftool is not found
- """
- exiftool = get_exiftool()
- if(exiftool is None):
- return None
-
- source = self.source
- process_output = subprocess.Popen(
- '%s "%s"' % (exiftool, source),
- stdout=subprocess.PIPE,
- shell=True,
- universal_newlines=True
- )
- return process_output.stdout.read()
-
- def set_date_taken(self, date_taken_as_datetime):
- """
- Set the date/time a photo was taken
-
- :param datetime date_taken_as_datetime: datetime object of when the
- video was recorded.
- :returns: bool
- """
- if(time is None):
- return False
-
- source = self.source
-
- result = self.__update_using_plist(time=date_taken_as_datetime)
- if(result is True):
- os.utime(
- source,
- (
- int(time.time()),
- time.mktime(date_taken_as_datetime.timetuple())
- )
- )
-
- self.reset_cache()
- return result
-
- def set_location(self, latitude, longitude):
- """
- Set latitude and longitude for a video.
-
- :param float latitude: Latitude of the file
- :param float longitude: Longitude of the file
- :returns: bool
- """
- if(latitude is None or longitude is None):
- return False
-
- result = self.__update_using_plist(latitude=latitude, longitude=longitude) # noqa
- self.reset_cache()
- return result
-
- def set_title(self, title):
- """Set title for a video.
-
- :param str title: Title for the file
- :returns: bool
- """
- if(title is None):
- return False
-
- result = self.__update_using_plist(title=title)
- self.reset_cache()
- return result
-
- def __update_using_plist(self, **kwargs):
- """Updates video metadata using avmetareadwrite.
-
- This method does a lot more than it should. The general steps are...
-
- 1. Check if avmetareadwrite is installed
- 2. Export a plist file to a temporary location from the source file
- 3. Regex replace values in the plist file
- 4. Update the source file using the updated plist and save it to a
- temporary location
- 5. Validate that the metadata in the updated temorary movie is valid
- 6. Copystat permission and time bits from the source file to the
- temporary movie
- 7. Move the temporary file to overwrite the source file
-
- :param float latitude: Latitude of the file
- :param float longitude: Longitude of the file
- :returns: bool
- """
- if(
- 'latitude' not in kwargs and
- 'longitude' not in kwargs and
- 'time' not in kwargs and
- 'title' not in kwargs
- ):
- if(constants.debug is True):
- print 'No lat/lon passed into __create_plist'
- return False
-
- avmetareadwrite = self.get_avmetareadwrite()
- if(avmetareadwrite is None):
- if(constants.debug is True):
- print 'Could not find avmetareadwrite'
- return False
-
- source = self.source
-
- # First we need to write the plist for an existing file
- # to a temporary location
- with tempfile.NamedTemporaryFile() as plist_temp:
- # We need to write the plist file in a child process
- # but also block for it to be complete.
- # http://stackoverflow.com/a/5631819/1318758
- avmetareadwrite_generate_plist_command = '%s -p "%s" "%s"' % (
- avmetareadwrite,
- plist_temp.name,
- source
- )
- write_process = subprocess.Popen(
- [avmetareadwrite_generate_plist_command],
- stdout=subprocess.PIPE,
- shell=True
- )
- write_process.communicate()
- if(write_process.returncode != 0):
- if(constants.debug is True):
- print 'Failed to generate plist file'
- return False
-
- plist = plist_parser.Plist(plist_temp.name)
- # Depending on the kwargs that were passed in we regex
- # the plist_text before we write it back.
- plist_should_be_written = False
- if('latitude' in kwargs and 'longitude' in kwargs):
- latitude = str(abs(kwargs['latitude'])).lstrip('0')
- longitude = kwargs['longitude']
-
- # Add a literal '+' to the lat/lon if it is positive.
- # Do this first because we convert longitude to a string below.
- lat_sign = '+' if latitude > 0 else '-'
- # We need to zeropad the longitude.
- # No clue why - ask Apple.
- # We set the sign to + or - and then we take the absolute value
- # and fill it.
- lon_sign = '+' if longitude > 0 else '-'
- longitude_str = '{:9.5f}'.format(abs(longitude)).replace(' ', '0') # noqa
- lat_lon_str = '%s%s%s%s' % (
- lat_sign,
- latitude,
- lon_sign,
- longitude_str
- )
-
- plist.update_key('common/location', lat_lon_str)
- plist_should_be_written = True
-
- if('time' in kwargs):
- # The time formats can be YYYY-mm-dd or YYYY-mm-dd hh:ii:ss
- time_parts = str(kwargs['time']).split(' ')
- ymd, hms = [None, None]
- if(len(time_parts) >= 1):
- ymd = [int(x) for x in time_parts[0].split('-')]
-
- if(len(time_parts) == 2):
- hms = [int(x) for x in time_parts[1].split(':')]
-
- if(hms is not None):
- d = datetime(ymd[0], ymd[1], ymd[2], hms[0], hms[1], hms[2]) # noqa
- else:
- d = datetime(ymd[0], ymd[1], ymd[2], 12, 00, 00)
-
- offset = time.strftime("%z", time.gmtime(time.time()))
- time_string = d.strftime('%Y-%m-%dT%H:%M:%S{}'.format(offset)) # noqa
- plist.update_key('common/creationDate', time_string)
- plist_should_be_written = True
-
- if('title' in kwargs):
- if(len(kwargs['title']) > 0):
- plist.update_key('common/title', kwargs['title'])
- plist_should_be_written = True
-
- if(plist_should_be_written is True):
- plist_final = plist_temp.name
- plist.write_file(plist_final)
- else:
- if(constants.debug is True):
- print 'Nothing to update, plist unchanged'
- return False
-
- # We create a temporary file to save the modified file to.
- # If the modification is successful we will update the
- # existing file.
- # We can't call self.get_metadata else we will run into
- # infinite loops
- # metadata = self.get_metadata()
- temp_movie = None
- with tempfile.NamedTemporaryFile() as temp_file:
- temp_movie = '%s.%s' % (temp_file.name, self.get_extension())
-
- # We need to block until the child process completes.
- # http://stackoverflow.com/a/5631819/1318758
- avmetareadwrite_command = '%s -a %s "%s" "%s"' % (
- avmetareadwrite,
- plist_final,
- source,
- temp_movie
- )
- update_process = subprocess.Popen(
- [avmetareadwrite_command],
- stdout=subprocess.PIPE,
- shell=True
- )
- update_process.communicate()
- if(update_process.returncode != 0):
- if(constants.debug is True):
- print '%s did not complete successfully' % avmetareadwrite_command # noqa
- return False
-
- # Before we do anything destructive we confirm that the
- # file is in tact.
- check_media = Base.get_class_by_file(temp_movie, [self.__class__])
- check_metadata = check_media.get_metadata()
- if(
- (
- 'latitude' in kwargs and
- 'longitude' in kwargs and
- check_metadata['latitude'] is None and
- check_metadata['longitude'] is None
- ) or (
- 'time' in kwargs and
- check_metadata['date_taken'] is None
- )
- ):
- if(constants.debug is True):
- print 'Something went wrong updating video metadata'
- return False
-
- # gh-89 Before we wrap up we check if an album was previously set
- # and if so we re-apply that album because avmetareadwrite
- # clobbers it
- source_media = Base.get_class_by_file(source, [self.__class__])
- source_metadata = source_media.get_metadata()
- if(isinstance(source_metadata, dict) and
- source_metadata['album'] is not None):
- check_media.set_album(source_metadata['album'])
-
- # Copy file information from original source to temporary file
- # before copying back over
- shutil.copystat(source, temp_movie)
- stat = os.stat(source)
- shutil.move(temp_movie, source)
- os.utime(source, (stat.st_atime, stat.st_mtime))
-
- return True
-
-
-class Transcode(object):
-
- """Constructor takes a video object as its parameter.
-
- :param Video video: Video object.
- """
-
- def __init__(self, video=None):
- self.video = video
diff --git a/elodie/tests/elodie_test.py b/elodie/tests/elodie_test.py
index 31d2561..8c8fa1c 100644
--- a/elodie/tests/elodie_test.py
+++ b/elodie/tests/elodie_test.py
@@ -111,7 +111,7 @@ def test_update_location_on_audio():
assert status == True, status
assert metadata['latitude'] != metadata_processed['latitude']
- assert helper.isclose(metadata_processed['latitude'], 37.3688305556), metadata_processed['latitude']
+ assert helper.isclose(metadata_processed['latitude'], 37.36883), metadata_processed['latitude']
assert helper.isclose(metadata_processed['longitude'], -122.03635), metadata_processed['longitude']
def test_update_location_on_photo():
@@ -189,7 +189,7 @@ def test_update_location_on_video():
assert status == True, status
assert metadata['latitude'] != metadata_processed['latitude']
- assert helper.isclose(metadata_processed['latitude'], 37.3688305556), metadata_processed['latitude']
+ assert helper.isclose(metadata_processed['latitude'], 37.36883), metadata_processed['latitude']
assert helper.isclose(metadata_processed['longitude'], -122.03635), metadata_processed['longitude']
def test_update_time_on_audio():
diff --git a/elodie/tests/geolocation_test.py b/elodie/tests/geolocation_test.py
index f55c1ef..fb85493 100644
--- a/elodie/tests/geolocation_test.py
+++ b/elodie/tests/geolocation_test.py
@@ -11,6 +11,7 @@ from elodie import geolocation
os.environ['TZ'] = 'GMT'
+
def test_decimal_to_dms():
for x in range(0, 1000):
@@ -19,24 +20,40 @@ def test_decimal_to_dms():
target_decimal_value = target_decimal_value * -1
dms = geolocation.decimal_to_dms(target_decimal_value)
- check_value = dms[0].to_float() + dms[1].to_float() / 60 + dms[2].to_float() / 3600
+
+ check_value = (dms[0] + dms[1] / 60 + dms[2] / 3600) * dms[3]
target_decimal_value = round(target_decimal_value, 8)
check_value = round(check_value, 8)
assert target_decimal_value == check_value, '%s does not match %s' % (check_value, target_decimal_value)
-def test_decimal_to_dms_unsigned():
+def test_dms_string_latitude():
- for x in range(0, 1000):
- target_decimal_value = random.uniform(0.0, 180.0) * -1
+ for x in range(0, 5):
+ target_decimal_value = random.uniform(0.0, 180.0)
+ if(x % 2 == 1):
+ target_decimal_value = target_decimal_value * -1
- dms = geolocation.decimal_to_dms(target_decimal_value, False)
- check_value = dms[0].to_float() + dms[1].to_float() / 60 + dms[2].to_float() / 3600
+ dms = geolocation.decimal_to_dms(target_decimal_value)
+ dms_string = geolocation.dms_string(target_decimal_value, 'latitude')
- target_decimal_value = round(target_decimal_value, 8)
- check_value = round(check_value, 8)
+ check_value = 'N' if target_decimal_value >= 0 else 'S'
- new_target_decimal_value = abs(target_decimal_value)
+ assert check_value in dms_string, '%s not in %s' % (check_value, dms_string)
+ assert str(dms[0]) in dms_string, '%s not in %s' % (dms[0], dms_string)
- assert new_target_decimal_value == check_value, '%s does not match %s' % (check_value, new_target_decimal_value)
+def test_dms_string_longitude():
+
+ for x in range(0, 5):
+ target_decimal_value = random.uniform(0.0, 180.0)
+ if(x % 2 == 1):
+ target_decimal_value = target_decimal_value * -1
+
+ dms = geolocation.decimal_to_dms(target_decimal_value)
+ dms_string = geolocation.dms_string(target_decimal_value, 'longitude')
+
+ check_value = 'E' if target_decimal_value >= 0 else 'W'
+
+ assert check_value in dms_string, '%s not in %s' % (check_value, dms_string)
+ assert str(dms[0]) in dms_string, '%s not in %s' % (dms[0], dms_string)
diff --git a/elodie/tests/media/audio_test.py b/elodie/tests/media/audio_test.py
index 6192ea7..9f4aedc 100644
--- a/elodie/tests/media/audio_test.py
+++ b/elodie/tests/media/audio_test.py
@@ -59,11 +59,12 @@ def test_get_date_taken():
print '%r' % date_taken
assert date_taken == (2016, 1, 4, 5, 24, 15, 0, 19, 0), date_taken
-def test_get_exif():
- audio = Audio(helper.get_file('audio.m4a'))
- exif = audio.get_exif()
+def test_get_exiftool_attributes():
+ audio = Video(helper.get_file('audio.m4a'))
+ exif = audio.get_exiftool_attributes()
assert exif is not None, exif
+ assert exif is not False, exif
def test_is_valid():
audio = Audio(helper.get_file('audio.m4a'))
@@ -127,6 +128,35 @@ def test_set_location():
assert helper.isclose(metadata['latitude'], 11.1111111111), metadata['latitude']
assert helper.isclose(metadata['longitude'], 99.9999999999), metadata['longitude']
+def test_set_location_minus():
+ if not can_edit_exif():
+ raise SkipTest('avmetareadwrite executable not found')
+
+ temporary_folder, folder = helper.create_working_folder()
+
+ origin = '%s/audio.m4a' % folder
+ shutil.copyfile(helper.get_file('audio.m4a'), origin)
+
+ audio = Audio(origin)
+ origin_metadata = audio.get_metadata()
+
+ # Verify that original audio has different location info that what we
+ # will be setting and checking
+ assert not helper.isclose(origin_metadata['latitude'], 11.111111), origin_metadata['latitude']
+ assert not helper.isclose(origin_metadata['longitude'], 99.999999), origin_metadata['longitude']
+
+ status = audio.set_location(-11.111111, -99.999999)
+
+ assert status == True, status
+
+ audio_new = Audio(origin)
+ metadata = audio_new.get_metadata()
+
+ shutil.rmtree(folder)
+
+ assert helper.isclose(metadata['latitude'], -11.111111), metadata['latitude']
+ assert helper.isclose(metadata['longitude'], -99.999999), metadata['longitude']
+
def test_set_title():
if not can_edit_exif():
raise SkipTest('avmetareadwrite executable not found')
diff --git a/elodie/tests/media/photo_test.py b/elodie/tests/media/photo_test.py
index c100b1d..d9dfa66 100644
--- a/elodie/tests/media/photo_test.py
+++ b/elodie/tests/media/photo_test.py
@@ -101,7 +101,7 @@ def test_get_date_taken():
photo = Photo(helper.get_file('plain.jpg'))
date_taken = photo.get_date_taken()
-# assert date_taken == (2015, 12, 5, 0, 59, 26, 5, 339, 0), date_taken
+ #assert date_taken == (2015, 12, 5, 0, 59, 26, 5, 339, 0), date_taken
assert date_taken == helper.time_convert((2015, 12, 5, 0, 59, 26, 5, 339, 0)), date_taken
def test_get_date_taken_without_exif():
@@ -123,6 +123,28 @@ def test_is_not_valid():
assert not photo.is_valid()
+def test_set_album():
+ temporary_folder, folder = helper.create_working_folder()
+
+ origin = '%s/photo.jpg' % folder
+ shutil.copyfile(helper.get_file('plain.jpg'), origin)
+
+ photo = Photo(origin)
+ metadata = photo.get_metadata()
+
+ assert metadata['album'] is None, metadata['album']
+
+ status = photo.set_album('Test Album')
+
+ assert status == True, status
+
+ photo_new = Photo(origin)
+ metadata_new = photo_new.get_metadata()
+
+ shutil.rmtree(folder)
+
+ assert metadata_new['album'] == 'Test Album', metadata_new['album']
+
def test_set_date_taken_with_missing_datetimeoriginal():
# When datetimeoriginal (or other key) is missing we have to add it gh-74
# https://github.com/jmathai/elodie/issues/74
@@ -193,6 +215,32 @@ def test_set_location():
assert helper.isclose(metadata['latitude'], 11.1111111111), metadata['latitude']
assert helper.isclose(metadata['longitude'], 99.9999999999), metadata['longitude']
+def test_set_location_minus():
+ temporary_folder, folder = helper.create_working_folder()
+
+ origin = '%s/photo.jpg' % folder
+ shutil.copyfile(helper.get_file('plain.jpg'), origin)
+
+ photo = Photo(origin)
+ origin_metadata = photo.get_metadata()
+
+ # Verify that original photo has different location info that what we
+ # will be setting and checking
+ assert not helper.isclose(origin_metadata['latitude'], 11.1111111111), origin_metadata['latitude']
+ assert not helper.isclose(origin_metadata['longitude'], 99.9999999999), origin_metadata['longitude']
+
+ status = photo.set_location(-11.1111111111, -99.9999999999)
+
+ assert status == True, status
+
+ photo_new = Photo(origin)
+ metadata = photo_new.get_metadata()
+
+ shutil.rmtree(folder)
+
+ assert helper.isclose(metadata['latitude'], -11.1111111111), metadata['latitude']
+ assert helper.isclose(metadata['longitude'], -99.9999999999), metadata['longitude']
+
def test_set_title():
temporary_folder, folder = helper.create_working_folder()
@@ -223,7 +271,6 @@ def test_set_title_non_ascii():
origin_metadata = photo.get_metadata()
unicode_title = u'形声字 / 形聲字'
- utf8_title = unicode_title.encode('utf-8')
status = photo.set_title(unicode_title)
assert status == True, status
@@ -233,7 +280,7 @@ def test_set_title_non_ascii():
shutil.rmtree(folder)
- assert metadata['title'] == utf8_title, metadata['title']
+ assert metadata['title'] == unicode_title, metadata['title']
def test_get_metadata_from_nef():
temporary_folder, folder = helper.create_working_folder()
diff --git a/elodie/tests/media/video_test.py b/elodie/tests/media/video_test.py
index 4b1d5e1..96c709f 100644
--- a/elodie/tests/media/video_test.py
+++ b/elodie/tests/media/video_test.py
@@ -33,17 +33,21 @@ def test_video_extensions():
assert extensions == valid_extensions, valid_extensions
+def test_empty_album():
+ video = Video(helper.get_file('video.mov'))
+ assert video.get_album() is None
+
def test_get_coordinate():
video = Video(helper.get_file('video.mov'))
coordinate = video.get_coordinate()
- assert coordinate == 38.189299999999996, coordinate
+ assert coordinate == 38.1893, coordinate
def test_get_coordinate_latitude():
video = Video(helper.get_file('video.mov'))
coordinate = video.get_coordinate('latitude')
- assert coordinate == 38.189299999999996, coordinate
+ assert coordinate == 38.1893, coordinate
def test_get_coordinate_longitude():
video = Video(helper.get_file('video.mov'))
@@ -57,11 +61,12 @@ def test_get_date_taken():
assert date_taken == (2015, 1, 19, 12, 45, 11, 0, 19, 0), date_taken
-def test_get_exif():
+def test_get_exiftool_attributes():
video = Video(helper.get_file('video.mov'))
- exif = video.get_exif()
+ exif = video.get_exiftool_attributes()
assert exif is not None, exif
+ assert exif is not False, exif
def test_is_valid():
video = Video(helper.get_file('video.mov'))
@@ -73,6 +78,28 @@ def test_is_not_valid():
assert not video.is_valid()
+def test_set_album():
+ temporary_folder, folder = helper.create_working_folder()
+
+ origin = '%s/video.mov' % folder
+ shutil.copyfile(helper.get_file('video.mov'), origin)
+
+ video = Video(origin)
+ metadata = video.get_metadata()
+
+ assert metadata['album'] is None, metadata['album']
+
+ status = video.set_album('Test Album')
+
+ assert status == True, status
+
+ video_new = Video(origin)
+ metadata_new = video_new.get_metadata()
+
+ shutil.rmtree(folder)
+
+ assert metadata_new['album'] == 'Test Album', metadata_new['album']
+
def test_set_date_taken():
if not can_edit_exif():
raise SkipTest('avmetareadwrite executable not found')