gh-62 [WIP] Remove pyexiv2 and avmetareadwrite dependency

This commit is contained in:
Jaisen Mathai 2016-06-21 14:19:40 -04:00
parent 162aeaada8
commit 6114328f32
14 changed files with 908 additions and 648 deletions

View File

@ -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"

113
Readme.md
View File

@ -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.
<p align="center"><img src ="creative/logo@300x.png" /></p>
[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.

View File

@ -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

0
elodie/external/__init__.py vendored Normal file
View File

469
elodie/external/pyexiftool.py vendored Normal file
View File

@ -0,0 +1,469 @@
# -*- coding: utf-8 -*-
# PyExifTool <http://github.com/smarnach/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 <http://www.gnu.org/licenses/>.
"""
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 <group>:<tag>.
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 <group>:<tag>.
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 <group>:<tag>.
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 <group>:<tag>.
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])

View File

@ -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):

View File

@ -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 != ''

View File

@ -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.
@ -97,19 +72,28 @@ class Photo(Media):
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

View File

@ -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

View File

@ -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():

View File

@ -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)

View File

@ -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')

View File

@ -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()

View File

@ -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')